feat(web): group the chat sidebar by workspace with an agent-mode toggle (#26)

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-22 22:22:07 +08:00
committed by GitHub
parent 3806e4e441
commit 99f391f379
8 changed files with 656 additions and 130 deletions
+6 -1
View File
@@ -273,11 +273,16 @@ test("chat + tool approval + stats/cost/copy + traces + files", async ({ page })
const sidebar = page.getByRole("complementary");
// --- agent group collapse / expand (header button shows the agent name, so its state
// --- group collapse / expand (the header button shows the group name, so its state
// lives on aria-label rather than a duplicate title tooltip) ---
await expect(sidebar.getByText("Configure Tailwind theme")).toBeVisible();
await sidebar.locator('button[aria-label="折叠"]').first().click();
await expect(sidebar.getByText("Configure Tailwind theme")).toHaveCount(0);
// The collapse state persists across a reload (localStorage, keyed per Project): the group
// comes back rendered collapsed (an "展开" header), with its rows still hidden.
await page.reload();
await expect(sidebar.locator('button[aria-label="展开"]').first()).toBeVisible();
await expect(sidebar.getByText("Configure Tailwind theme")).toHaveCount(0);
await sidebar.locator('button[aria-label="展开"]').first().click();
await expect(sidebar.getByText("Configure Tailwind theme")).toBeVisible();
+58 -2
View File
@@ -4,9 +4,17 @@
* the first message is sent, and all four selections land faithfully in its meta;
* - the draft auto-caches (body persisted via debounce): after a page reload, both the body and
* the selections are restored, and the cache clears once sending succeeds;
* - the sidebar group header's "+" creates a draft scoped to that group's Agent (explicitly set
* via router state, overriding the cache).
* - the sidebar defaults to grouping by Workspace: auto temp directories merge into one
* "临时工作区" 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);
* - 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).
*/
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { basename, dirname, join } from "node:path";
import { test, expect } from "@playwright/test";
import { provisionAndLogin } from "./auth.mjs";
@@ -115,6 +123,54 @@ test("draft: pick model/approval -> reload restores them -> send creates the ses
// The cache clears as soon as sending succeeds.
await expect.poll(() => page.evaluate((k) => localStorage.getItem(k), draftKey)).toBeNull();
// —— Default grouping: the sidebar groups Sessions by Workspace — the session just created
// used the auto temp directory, so it lands in the merged "临时工作区" group. ——
await expect(page.getByText("临时工作区")).toBeVisible();
// A session in a named Workspace groups under that directory's basename, and its group
// header's "+" pre-fills the draft's Workspace selection with the group's path.
const namedWs = mkdtempSync(join(tmpdir(), "penguin-e2e-ws-"));
const namedRes = await page.request.post(
`${BASE}/api/projects/${projectId}/agents/default_agent/sessions`,
{ data: { workspace: namedWs } },
);
expect(namedRes.ok(), "create session in named workspace").toBeTruthy();
await page.reload();
const wsLabel = basename(namedWs);
const wsHeader = page
.getByText(wsLabel, { exact: true })
.locator("xpath=ancestor::div[contains(@class,'items-center')][1]");
await wsHeader.getByRole("button", { name: "在此工作区新建对话" }).click();
await expect(page.getByRole("heading", { name: "PenguinHarness" })).toBeVisible();
await expect(page.getByLabel("Workspace")).toContainText(wsLabel);
// Regression (review): the route-state prefill applies once per navigation only — after the
// user picks a different directory and reloads, the restored cached choice must win; the
// prefill must NOT re-apply (location.state survives a reload inside history.state, so the
// consumed marker lives in sessionStorage rather than a ref). Browse one level up and select
// it; the path row mirrors the loaded directory, which orders the two clicks deterministically.
await page.getByRole("button", { name: "Workspace", exact: true }).click();
// Match by trailing basename: the server realpaths the browsed directory, so the prefix may differ from the raw mkdtemp path.
await expect(page.getByRole("textbox", { name: "Workspace" })).toHaveValue(
new RegExp(`${wsLabel}$`),
);
await page.getByRole("button", { name: "上级目录" }).click();
await expect(page.getByRole("textbox", { name: "Workspace" })).not.toHaveValue(
new RegExp(`${wsLabel}$`),
);
await page.getByRole("button", { name: "使用此目录" }).click();
const parentLabel = basename(dirname(namedWs));
await expect(page.getByLabel("Workspace")).toContainText(parentLabel);
await page.reload();
await expect(page.getByLabel("Workspace")).toContainText(parentLabel);
await expect(page.getByLabel("Workspace")).not.toContainText(wsLabel);
// —— Switch the sidebar to agent mode via the section-header toggle (persists in localStorage) ——
await page.getByRole("button", { name: "按 Agent 分组" }).click();
await expect
.poll(() => page.evaluate(() => localStorage.getItem("penguin.sidebarGroupMode")))
.toBe("agent");
// —— Sidebar group-header "+": create a draft with that group's Agent (overrides the previously cached Agent) ——
const helperHeader = page.getByText("Helper Agent", { exact: true }).first();
await expect(helperHeader).toBeVisible();
+296 -123
View File
@@ -1,15 +1,19 @@
/**
* Single-column sidebar, top to bottom:
* Project switcher -> new chat (default_agent draft) + fixed nav (Agents / models / cost center /
* Trace) -> Session area grouped by Agent (group header = Agent name + new chat + Agent settings;
* 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
* 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).
* 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): Model /
* Workspace / approval mode are all chosen on the draft input card, so there's no longer a
* separate "quick / advanced" pair of new-chat dialogs.
* 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
* there's no longer a separate "quick / advanced" pair of new-chat dialogs.
* Color scheme is white/gray-based: active state uses a solid gray fill, running status uses a small color dot, no large blocks of color.
*/
import { useState } from "react";
import { useEffect, useMemo, useState } from "react";
import type { ReactNode } from "react";
import { NavLink, useMatch, useNavigate } from "react-router";
import type { SessionInfo } from "@prismshadow/penguin-server/api";
@@ -23,6 +27,7 @@ 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 { Dropdown } from "../ui/dropdown";
import { AgentAvatar } from "../ui/agent-avatar";
import { Chevron } from "../ui/chevron";
@@ -81,9 +86,47 @@ 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). */
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";
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";
/** Grouping mode of the Session list (persisted; Workspace is the default). */
type GroupMode = "workspace" | "agent";
const GROUP_MODE_KEY = "penguin.sidebarGroupMode";
function initialGroupMode(): GroupMode {
return localStorage.getItem(GROUP_MODE_KEY) === "agent" ? "agent" : "workspace";
}
/**
* 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.
*/
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();
try {
const parsed: unknown = JSON.parse(localStorage.getItem(collapsedGroupsKey(projectId)) ?? "[]");
return new Set(
Array.isArray(parsed) ? parsed.filter((x): x is string => typeof x === "string") : [],
);
} catch {
return new Set();
}
}
function saveCollapsedGroups(projectId: string | null, next: ReadonlySet<string>): void {
if (!projectId) return;
try {
localStorage.setItem(collapsedGroupsKey(projectId), JSON.stringify([...next]));
} catch {
/* best-effort persistence (quota/private mode) */
}
}
/** Session status dot: running pulses green, compacting shows an amber dot; idle shows nothing. */
function StatusDot({ session }: { session: SessionInfo }) {
if (session.status === "running") {
@@ -123,9 +166,10 @@ export function Sidebar({
setCurrentProjectId,
reloadProjects,
agents,
currentAgent,
setCurrentAgentId,
} = useProject();
const { byAgent, loading, remove, replace } = useSessions();
const { sessions, byAgent, loading, remove, replace } = useSessions();
const chatMatch = useMatch("/chat/:sessionId");
const activeSessionId = chatMatch?.params.sessionId ?? null;
@@ -134,9 +178,18 @@ export function Sidebar({
const [createProjectOpen, setCreateProjectOpen] = useState(false);
const [projectSettingsOpen, setProjectSettingsOpen] = useState(false);
const [changePasswordOpen, setChangePasswordOpen] = useState(false);
/** Collapsed Agent groups (expanded by default). */
const [collapsedAgents, setCollapsedAgents] = useState<ReadonlySet<string>>(new Set());
/** Expanded "archived" groups (collapsed by default). */
const currentProjectId = currentProject?.projectId ?? null;
/** 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),
);
// Project resolved on first load / switched: swap in that Project's persisted collapse set.
useEffect(() => {
setCollapsedGroups(loadCollapsedGroups(currentProjectId));
}, [currentProjectId]);
/** Expanded "archived" groups (collapsed by default), keyed like collapsedGroups. */
const [openArchived, setOpenArchived] = useState<ReadonlySet<string>>(new Set());
/** Session pending delete confirmation (null = none). */
const [deletingSession, setDeletingSession] = useState<SessionInfo | null>(null);
@@ -148,19 +201,33 @@ export function Sidebar({
const [renameBusy, setRenameBusy] = useState(false);
const [renameError, setRenameError] = useState<string | null>(null);
const toggleAgent = (agentId: string) =>
setCollapsedAgents((prev) => {
const next = new Set(prev);
if (next.has(agentId)) next.delete(agentId);
else next.add(agentId);
return next;
});
const setGroupMode = (mode: GroupMode) => {
localStorage.setItem(GROUP_MODE_KEY, mode);
setGroupModeState(mode);
};
const toggleArchivedGroup = (agentId: string) =>
/** Workspace groups (workspace mode): computed from the flat list, temp directories merged last. */
const workspaceGroups = useMemo(() => groupSessionsByWorkspace(sessions), [sessions]);
/** Group key of a Session under the current mode (collapse / archived-open state). */
const sessionGroupKey = (s: SessionInfo) =>
groupMode === "agent" ? s.agentId : workspaceGroupKey(s.workspace);
const toggleGroup = (key: string) => {
// Computed outside the state updater (theme.tsx convention): the persistence write is a
// side effect, and updaters must stay pure (double-invoked in StrictMode).
const next = new Set(collapsedGroups);
if (next.has(key)) next.delete(key);
else next.add(key);
setCollapsedGroups(next);
saveCollapsedGroups(currentProjectId, next);
};
const toggleArchivedGroup = (key: string) =>
setOpenArchived((prev) => {
const next = new Set(prev);
if (next.has(agentId)) next.delete(agentId);
else next.add(agentId);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
@@ -168,7 +235,7 @@ export function Sidebar({
const toggleArchive = async (s: SessionInfo) => {
// Archiving the currently open chat: expand the "archived" group so it doesn't silently vanish from the sidebar with no way back.
if (!s.archived && s.sessionId === activeSessionId) {
setOpenArchived((prev) => new Set(prev).add(s.agentId));
setOpenArchived((prev) => new Set(prev).add(sessionGroupKey(s)));
}
try {
const res = await api.patchSession(s.sessionId, { archived: !s.archived });
@@ -231,25 +298,101 @@ export function Sidebar({
* New chat: enters draft state (/chat/new) without creating a Session — Model / Workspace /
* approval mode are all chosen on the draft input card, and the Session is only actually
* created when the first message is sent. The route state explicitly carries the target
* Agent: the group header's "+" uses that group's Agent, while the menu's "New chat" uses
* default_agent; this explicit intent overrides the previously selected Agent in the draft
* cache (the rest of the draft content, such as the message body, is preserved).
* Agent: the agent-mode group header's "+" uses that group's Agent, while the menu's "New
* chat" uses default_agent; this explicit intent overrides the previously selected Agent in
* the draft cache (the rest of the draft content, such as the message body, is preserved).
* The workspace-mode group header's "+" additionally carries that group's Workspace path
* ("" = the auto temp directory), pre-filling the draft's Workspace selection the same way.
*/
const newChat = (agentId?: string) => {
const newChat = (agentId?: string, workspace?: string) => {
if (agentId) setCurrentAgentId(agentId);
navigate(`/chat/${DRAFT_SESSION_ID}`, agentId ? { state: { agentId } } : undefined);
const state = {
...(agentId ? { agentId } : {}),
...(workspace !== undefined ? { workspace } : {}),
};
navigate(`/chat/${DRAFT_SESSION_ID}`, Object.keys(state).length > 0 ? { state } : undefined);
onNavigate?.();
};
/** Target of the menu's "New chat": default_agent, falling back to the first Agent (if the list isn't ready yet, resolution is deferred to the draft page). */
const defaultAgentId = (agents.find((a) => a.agentId === "default_agent") ?? agents[0])?.agentId;
/** A Session always needs an Agent, so the workspace-mode "+" uses the current Agent, falling back to default_agent. */
const workspaceNewChatAgentId = currentAgent?.agentId ?? defaultAgentId;
const openSession = (s: SessionInfo) => {
// Cross-group click: the current Agent follows this Session's own Agent.
setCurrentAgentId(s.agentId);
go(`/chat/${s.sessionId}`);
};
/** agentId → display name (row hint tooltips in workspace mode). */
const agentNameById = useMemo(
() => new Map(agents.map((a) => [a.agentId, agentDisplayName(a)])),
[agents],
);
/** Session rows shared by both modes; withAgentHint adds a small Agent avatar per row (workspace mode, where the group no longer names the Agent). */
const renderRows = (rows: SessionInfo[], withAgentHint: boolean) => (
<ul className="space-y-0.5">
{rows.map((s) => (
<SessionRow
key={s.sessionId}
s={s}
active={s.sessionId === activeSessionId}
{...(withAgentHint ? { agentHint: agentNameById.get(s.agentId) ?? s.agentId } : {})}
onOpen={openSession}
onRename={(x) => {
setRenameError(null);
setRenameText(x.title ?? "");
setRenamingSession(x);
}}
onDelete={(x) => {
setDeleteError(null);
setDeletingSession(x);
}}
onToggleArchive={(x) => void toggleArchive(x)}
/>
))}
</ul>
);
/** Expanded group body shared by both modes: active rows + the collapsed-by-default archived subgroup (keyed by the group key). */
const renderGroupBody = (
groupKey: string,
activeList: SessionInfo[],
archivedList: SessionInfo[],
withAgentHint: boolean,
) => {
const archivedOpen = openArchived.has(groupKey);
return (
<>
{activeList.length === 0 && archivedList.length === 0 ? (
<p className="px-2.5 py-1 text-xs text-gray-400 dark:text-gray-600">
{S.chat.noSessions}
</p>
) : (
renderRows(activeList, withAgentHint)
)}
{/* Archived group (collapsed by default) */}
{archivedList.length > 0 && (
<div className="mt-1">
<button
type="button"
onClick={() => toggleArchivedGroup(groupKey)}
className="flex w-full items-center gap-1 rounded px-1.5 py-1 text-left text-[11px] font-medium text-gray-400 transition-colors duration-150 hover:bg-gray-200/50 dark:text-gray-500 dark:hover:bg-gray-800/50"
>
<Chevron open={archivedOpen} size={12} />
{S.chat.archivedGroup(archivedList.length)}
</button>
{archivedOpen && renderRows(archivedList, withAgentHint)}
</div>
)}
</>
);
};
const navItems: Array<{ to: string; label: string; icon: string }> = [
{ to: "/agents", label: S.nav.agents, icon: NAV_ICONS.agents },
{ to: "/skills", label: S.nav.skills, icon: NAV_ICONS.skills },
@@ -396,126 +539,146 @@ export function Sidebar({
))}
</nav>
{/* Session area grouped by Agent (scrollable) */}
{/* Session area (scrollable): grouped by Workspace (default) or by Agent */}
<div className="mt-3 min-h-0 flex-1 overflow-y-auto border-t border-gray-200 px-2 pb-2 dark:border-gray-800">
{loading && agents.length === 0 ? (
{/* Section header: list label + grouping-mode toggle (the choice persists in localStorage) */}
<div className="flex items-center justify-between px-1 pt-2">
<span className="px-1 text-[11px] font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">
{S.chat.sessionList}
</span>
<div className="flex items-center gap-0.5">
{(
[
{ value: "workspace", icon: FOLDER_ICON, label: S.chat.groupByWorkspace },
{ value: "agent", icon: NAV_ICONS.agents, label: S.chat.groupByAgent },
] as const
).map((opt) => (
<button
key={opt.value}
type="button"
title={opt.label}
aria-label={opt.label}
aria-pressed={groupMode === opt.value}
onClick={() => setGroupMode(opt.value)}
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-md transition-colors duration-150 ${
groupMode === opt.value
? "bg-gray-200/70 text-gray-700 dark:bg-gray-800 dark:text-gray-200"
: "text-gray-400 hover:bg-gray-200/50 hover:text-gray-700 dark:text-gray-500 dark:hover:bg-gray-800/70 dark:hover:text-gray-300"
}`}
>
<Icon d={opt.icon} size={14} />
</button>
))}
</div>
</div>
{groupMode === "agent" ? (
loading && agents.length === 0 ? (
<SkeletonList rows={5} />
) : (
agents.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);
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">
<button
type="button"
onClick={() => toggleGroup(agent.agentId)}
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"
>
<AgentAvatar
id={agent.agentId}
name={agentDisplayName(agent)}
size={18}
className="shrink-0 rounded"
/>
<span className="min-w-0 truncate text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
{agentDisplayName(agent)}
</span>
{/* Expand/collapse indicator sits right after the Agent name */}
<Chevron open={!collapsed} size={12} className="text-gray-400" />
<span className="min-w-0 flex-1" />
</button>
{/* New chat: enters draft state directly with this group's Agent (all options live on the draft input card) */}
<button
type="button"
title={S.chat.newSessionMenu}
aria-label={S.chat.newSessionMenu}
onClick={() => newChat(agent.agentId)}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-gray-400 transition-colors duration-150 hover:bg-gray-200/70 hover:text-gray-800 dark:text-gray-500 dark:hover:bg-gray-800 dark:hover:text-gray-200"
>
<Icon d="M12 5v14M5 12h14" size={18} />
</button>
<button
type="button"
title={S.agent.settings}
onClick={() => go(`/agents/${agent.agentId}`)}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-gray-400 transition-colors duration-150 hover:bg-gray-200/70 hover:text-gray-800 dark:text-gray-500 dark:hover:bg-gray-800 dark:hover:text-gray-200"
>
<Icon d={GEAR_ICON} size={16} />
</button>
</div>
{collapsed
? null
: renderGroupBody(agent.agentId, activeList, archivedList, false)}
</div>
);
})
)
) : loading && sessions.length === 0 ? (
<SkeletonList rows={5} />
) : workspaceGroups.length === 0 ? (
<p className="px-2.5 pt-3 text-xs text-gray-400 dark:text-gray-600">
{S.chat.noSessions}
</p>
) : (
agents.map((agent) => {
const list = byAgent.get(agent.agentId) ?? [];
const activeList = list.filter((s) => !s.archived);
const archivedList = list.filter((s) => s.archived);
const collapsed = collapsedAgents.has(agent.agentId);
const archivedOpen = openArchived.has(agent.agentId);
workspaceGroups.map((group) => {
const activeList = group.sessions.filter((s) => !s.archived);
const archivedList = group.sessions.filter((s) => s.archived);
const collapsed = collapsedGroups.has(group.key);
return (
<div key={agent.agentId} className="pt-2.5">
{/* Group header: collapse toggle (Agent name) + new chat + Agent settings */}
<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">
<button
type="button"
onClick={() => toggleAgent(agent.agentId)}
onClick={() => toggleGroup(group.key)}
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"
>
<AgentAvatar
id={agent.agentId}
name={agentDisplayName(agent)}
size={18}
className="shrink-0 rounded"
/>
<span className="min-w-0 truncate text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
{agentDisplayName(agent)}
<span className="shrink-0 text-gray-400 dark:text-gray-500">
<Icon d={FOLDER_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">
{group.temp ? S.chat.tempWorkspaces : group.label}
</span>
<span className="shrink-0 text-[11px] text-gray-400 dark:text-gray-500">
{activeList.length}
</span>
{/* Expand/collapse indicator sits right after the Agent name */}
<Chevron open={!collapsed} size={12} className="text-gray-400" />
<span className="min-w-0 flex-1" />
</button>
{/* New chat: enters draft state directly with this group's Agent (all options live on the draft input card) */}
{/* 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"
title={S.chat.newSessionMenu}
aria-label={S.chat.newSessionMenu}
onClick={() => newChat(agent.agentId)}
title={S.chat.newSessionInWorkspace}
aria-label={S.chat.newSessionInWorkspace}
onClick={() => newChat(workspaceNewChatAgentId, group.fullPath ?? "")}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-gray-400 transition-colors duration-150 hover:bg-gray-200/70 hover:text-gray-800 dark:text-gray-500 dark:hover:bg-gray-800 dark:hover:text-gray-200"
>
<Icon d="M12 5v14M5 12h14" size={18} />
</button>
<button
type="button"
title={S.agent.settings}
onClick={() => go(`/agents/${agent.agentId}`)}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-gray-400 transition-colors duration-150 hover:bg-gray-200/70 hover:text-gray-800 dark:text-gray-500 dark:hover:bg-gray-800 dark:hover:text-gray-200"
>
<Icon d={GEAR_ICON} size={16} />
</button>
</div>
{collapsed ? null : (
<>
{activeList.length === 0 && archivedList.length === 0 ? (
<p className="px-2.5 py-1 text-xs text-gray-400 dark:text-gray-600">
{S.chat.noSessions}
</p>
) : (
<ul className="space-y-0.5">
{activeList.map((s) => (
<SessionRow
key={s.sessionId}
s={s}
active={s.sessionId === activeSessionId}
onOpen={openSession}
onRename={(x) => {
setRenameError(null);
setRenameText(x.title ?? "");
setRenamingSession(x);
}}
onDelete={(x) => {
setDeleteError(null);
setDeletingSession(x);
}}
onToggleArchive={(x) => void toggleArchive(x)}
/>
))}
</ul>
)}
{/* Archived group (collapsed by default) */}
{archivedList.length > 0 && (
<div className="mt-1">
<button
type="button"
onClick={() => toggleArchivedGroup(agent.agentId)}
className="flex w-full items-center gap-1 rounded px-1.5 py-1 text-left text-[11px] font-medium text-gray-400 transition-colors duration-150 hover:bg-gray-200/50 dark:text-gray-500 dark:hover:bg-gray-800/50"
>
<Chevron open={archivedOpen} size={12} />
{S.chat.archivedGroup(archivedList.length)}
</button>
{archivedOpen && (
<ul className="space-y-0.5">
{archivedList.map((s) => (
<SessionRow
key={s.sessionId}
s={s}
active={s.sessionId === activeSessionId}
onOpen={openSession}
onRename={(x) => {
setRenameError(null);
setRenameText(x.title ?? "");
setRenamingSession(x);
}}
onDelete={(x) => {
setDeleteError(null);
setDeletingSession(x);
}}
onToggleArchive={(x) => void toggleArchive(x)}
/>
))}
</ul>
)}
</div>
)}
</>
)}
{collapsed ? null : renderGroupBody(group.key, activeList, archivedList, true)}
</div>
);
})
@@ -695,6 +858,7 @@ export function Sidebar({
function SessionRow({
s,
active,
agentHint,
onOpen,
onRename,
onDelete,
@@ -702,6 +866,8 @@ function SessionRow({
}: {
s: SessionInfo;
active: boolean;
/** Agent display name; when set (workspace mode) a small avatar keeps the Agent context visible on the row. */
agentHint?: string;
onOpen: (s: SessionInfo) => void;
onRename: (s: SessionInfo) => void;
onDelete: (s: SessionInfo) => void;
@@ -723,6 +889,13 @@ function SessionRow({
onClick={() => onOpen(s)}
className="flex min-w-0 flex-1 items-center gap-1.5 px-2.5 py-1.5 text-left"
>
{agentHint !== undefined && (
<span title={agentHint} className="flex shrink-0 items-center">
<AgentAvatar id={s.agentId} name={agentHint} size={14} className="rounded" />
{/* The avatar is aria-hidden and title only serves pointer users: expose the Agent name to keyboard/screen-reader users as visually hidden text inside the row button. */}
<span className="sr-only">{agentHint}</span>
</span>
)}
{/* Only attach a title attribute when the title is actually truncated (hover to see full text); don't duplicate the text otherwise. */}
<Truncated
text={s.title ?? S.chat.defaultSessionTitle}
+57 -4
View File
@@ -17,8 +17,10 @@
* change before unmount, one final write is flushed) — closing and returning to
* the page resumes where you left off; the cache is cleared on successful send.
* The sidebar group header "+" / menu "New conversation" explicitly specify an
* Agent via route state (overriding the cached selection); a direct visit or
* refresh falls back to the cache.
* Agent via route state (overriding the cached selection); the workspace-mode
* group header "+" additionally carries a Workspace path pre-filling the
* Workspace selection ("" = auto temp directory). A direct visit or refresh
* falls back to the cache.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useLocation, useNavigate } from "react-router";
@@ -54,6 +56,32 @@ import { sameModelRef } from "../models/model-grouping";
/** Coalescing window for writing body text to the cache: keystrokes are frequent, so a short batch accumulates before persisting (option changes are still written immediately). */
const DRAFT_SAVE_DEBOUNCE_MS = 300;
/**
* "Applied" markers for the route-state overrides (one slot per field, holding the last
* consumed location.key). React Router persists location.state AND location.key in
* history.state, which survives a full page reload, while a ref resets with the JS
* context — with a ref alone, a reload would re-apply the override and clobber whatever
* the user changed since (restored from the draft cache). sessionStorage is per-tab
* exactly like history.state, so the marker follows the history entry; on storage
* failure (private mode) both helpers degrade to "not consumed", and the in-component
* ref still provides the previous apply-once-per-mount behavior.
*/
type RouteStateField = "agentId" | "workspace";
function loadAppliedRouteKey(field: RouteStateField): string | null {
try {
return sessionStorage.getItem(`penguin.chatRouteApplied.${field}`);
} catch {
return null;
}
}
function saveAppliedRouteKey(field: RouteStateField, key: string): void {
try {
sessionStorage.setItem(`penguin.chatRouteApplied.${field}`, key);
} catch {
/* best-effort: the dedup marker falls back to the per-mount ref */
}
}
/**
* Example tasks on the draft screen, in display order (game card first, LoL music player,
* then the RAG build). Copy lives in S.chat.exampleTasks[id]; skills are pinned via a
@@ -124,14 +152,20 @@ export function DraftView({
// on invalid value" effect would let the former write B in one render while the
// latter, still judging by the stale closure's invalid value, writes the default
// Agent and clobbers B.
const stateAgentId = (location.state as { agentId?: string } | null)?.agentId;
const routeState = location.state as { agentId?: string; workspace?: string } | null;
const stateAgentId = routeState?.agentId;
const appliedStateKey = useRef<string | null>(null);
useEffect(() => {
if (agents.length === 0) return; // list not ready yet, nothing to validate against — wait for the next pass
const valid = (id: string | null | undefined): id is string =>
!!id && agents.some((a) => a.agentId === id);
if (stateAgentId && appliedStateKey.current !== location.key) {
if (
stateAgentId &&
appliedStateKey.current !== location.key &&
loadAppliedRouteKey("agentId") !== location.key
) {
appliedStateKey.current = location.key;
saveAppliedRouteKey("agentId", location.key);
if (valid(stateAgentId)) {
setAgentId(stateAgentId);
return;
@@ -141,6 +175,25 @@ export function DraftView({
setAgentId((agents.find((a) => a.agentId === "default_agent") ?? agents[0])?.agentId ?? null);
}, [agents, agentId, location.key, stateAgentId]);
// Explicit Workspace from route state (the workspace-mode group header "+"): applied once per
// location.key, same convention as the Agent above, overriding the cached selection ("" pre-fills
// the auto temp directory). Unlike the Agent there's no list to validate against, so this is a
// separate effect that never has to wait for a load.
const stateWorkspace = routeState?.workspace;
const appliedWorkspaceKey = useRef<string | null>(null);
useEffect(() => {
if (
stateWorkspace === undefined ||
appliedWorkspaceKey.current === location.key ||
loadAppliedRouteKey("workspace") === location.key
) {
return;
}
appliedWorkspaceKey.current = location.key;
saveAppliedRouteKey("workspace", location.key);
setWorkspace(stateWorkspace);
}, [location.key, stateWorkspace]);
// Model fallback: once config is ready, if nothing is selected or the selection is no longer valid, fall back to the project default → the first model (always as a paired reference).
useEffect(() => {
if (!models) return;
+91
View File
@@ -0,0 +1,91 @@
/**
* Pure grouping logic for the chat sidebar's "by Workspace" mode.
*
* There is no Workspace entity on the server: a Session only carries the plain
* filesystem path locked in at creation (SessionInfo.workspace), so grouping works
* on those path strings. Sessions created without an explicit Workspace get an
* auto-created temp directory shaped like `<agentDir>/workspaces/tmp-<8hex>`
* (packages/core/src/internal/session-support.ts, createTempWorkspace); each of
* those is single-use, so per-path groups would be one-session noise — they are all
* merged into ONE trailing "temp workspaces" group instead.
*/
import type { SessionInfo } from "@prismshadow/penguin-server/api";
/** Group key of the merged auto-temp group ("\0" can never appear in a filesystem path, so it never collides with a real Workspace). */
export const TEMP_WORKSPACE_GROUP_KEY = "\0temp-workspaces";
/** Auto-created temp Workspace tail: `workspaces/tmp-<8hex>` (either path separator; core supports win32). */
const TEMP_WORKSPACE_RE = /[/\\]workspaces[/\\]tmp-[0-9a-f]{8}$/;
/**
* Whether a Session's Workspace is an auto-created temp directory. An empty path
* also counts as "auto temp": the server always backfills the resolved path, so
* this is defensive only.
*/
export function isTempWorkspace(workspace: string): boolean {
const p = workspace.trim();
return p === "" || TEMP_WORKSPACE_RE.test(p);
}
/** Stable group key for a Session's Workspace (collapse state / React key): the path itself, or the temp sentinel. */
export function workspaceGroupKey(workspace: string): string {
return isTempWorkspace(workspace) ? TEMP_WORKSPACE_GROUP_KEY : workspace.trim();
}
/** Short display label: the last path segment (the filesystem root yields "/"). */
export function workspaceLabel(workspace: string): string {
const parts = workspace
.trim()
.split(/[/\\]+/)
.filter(Boolean);
return parts[parts.length - 1] ?? "/";
}
export interface WorkspaceGroup {
/** Stable group key: the Workspace path, or TEMP_WORKSPACE_GROUP_KEY for the merged temp group. */
key: string;
/** Display label: the path basename; empty for the temp group (the sidebar renders the localized name). */
label: string;
/** Full path for tooltips; null for the merged temp group (its members' paths all differ). */
fullPath: string | null;
/** True for the merged auto-temp group. */
temp: boolean;
/** Member Sessions, newest first (createdAt desc). */
sessions: SessionInfo[];
}
/**
* Groups Sessions by their Workspace path. Named groups are sorted by their newest
* Session's createdAt desc; the merged temp group (if any) always comes last.
* Sessions inside a group are re-sorted newest first — the flat store list
* concatenates per-Agent server responses, so its order isn't globally chronological.
* createdAt is a uniform ISO-8601 UTC string (server: `new Date().toISOString()`),
* so lexicographic comparison equals chronological comparison.
*/
export function groupSessionsByWorkspace(sessions: SessionInfo[]): WorkspaceGroup[] {
const byKey = new Map<string, WorkspaceGroup>();
for (const s of sessions) {
const key = workspaceGroupKey(s.workspace);
let group = byKey.get(key);
if (!group) {
const temp = key === TEMP_WORKSPACE_GROUP_KEY;
group = {
key,
label: temp ? "" : workspaceLabel(s.workspace),
fullPath: temp ? null : s.workspace.trim(),
temp,
sessions: [],
};
byKey.set(key, group);
}
group.sessions.push(s);
}
const byCreatedDesc = (a: string, b: string) => (a < b ? 1 : a > b ? -1 : 0);
const groups = [...byKey.values()];
for (const g of groups) g.sessions.sort((a, b) => byCreatedDesc(a.createdAt, b.createdAt));
groups.sort((a, b) => {
if (a.temp !== b.temp) return a.temp ? 1 : -1;
return byCreatedDesc(a.sessions[0]?.createdAt ?? "", b.sessions[0]?.createdAt ?? "");
});
return groups;
}
+5
View File
@@ -486,6 +486,11 @@ export const en: Strings = {
workspaceAuto: "Auto temp directory",
workspaceClear: "Use auto temp directory instead",
workspaceDirInvalid: "Directory does not exist or is inaccessible; reverted",
/** Sidebar conversation-list grouping toggle (workspace is the default) + workspace groups. */
groupByWorkspace: "Group by workspace",
groupByAgent: "Group by agent",
tempWorkspaces: "Temp workspaces",
newSessionInWorkspace: "New chat in this workspace",
draftSubtitle: "The self-evolving agent that excels at AI development tasks",
exampleTasks: {
game: {
+5
View File
@@ -464,6 +464,11 @@ export const zh = {
workspaceAuto: "自动临时目录",
workspaceClear: "改用自动临时目录",
workspaceDirInvalid: "目录不存在或无法访问,已回退",
/** 侧栏对话列表的分组切换(默认按工作区)与工作区分组。 */
groupByWorkspace: "按工作区分组",
groupByAgent: "按 Agent 分组",
tempWorkspaces: "临时工作区",
newSessionInWorkspace: "在此工作区新建对话",
draftSubtitle: "最擅长 AI 开发任务的自进化 Agent",
/**
* Example task cards on the draft screen: one click auto-submits the canned prompt (game
+138
View File
@@ -0,0 +1,138 @@
/**
* Workspace grouping for the chat sidebar (pure logic):
* - named Workspaces group by exact path, labeled by basename (full path kept for
* tooltips), newest group first;
* - auto temp Workspaces (`<agentDir>/workspaces/tmp-<8hex>`, the shape produced by
* core's createTempWorkspace) are all merged into ONE trailing temp group — an
* empty path (defensive; the server always backfills the resolved dir) counts too;
* - sessions inside every group are re-sorted newest first: the flat store list
* concatenates per-Agent server responses, so its order isn't globally chronological.
*/
import { describe, expect, it } from "vitest";
import type { SessionInfo } from "@prismshadow/penguin-server/api";
import {
TEMP_WORKSPACE_GROUP_KEY,
groupSessionsByWorkspace,
isTempWorkspace,
workspaceGroupKey,
workspaceLabel,
} from "../src/lib/session-grouping";
let seq = 0;
function session(
workspace: string,
createdAt: string,
over: { sessionId?: string; agentId?: string; archived?: boolean } = {},
): SessionInfo {
seq += 1;
return {
sessionId: over.sessionId ?? `session-${seq}`,
projectId: "proj",
agentId: over.agentId ?? "default_agent",
provider: "custom",
modelId: "claude-4-8",
workspace,
approvalMode: "allow-all",
createdAt,
status: "idle",
pendingApprovalCount: 0,
hasTrace: false,
archived: over.archived ?? false,
};
}
const TEMP_A = "/data/proj/agents/default_agent/workspaces/tmp-1a2b3c4d";
const TEMP_B = "/data/proj/agents/agent_helper/workspaces/tmp-00ff00aa";
describe("isTempWorkspace (auto temp directory pattern from core's createTempWorkspace)", () => {
it("matches <...>/workspaces/tmp-<8hex> with either path separator, and the empty path", () => {
expect(isTempWorkspace(TEMP_A)).toBe(true);
expect(isTempWorkspace("C:\\pg\\data\\proj\\agents\\a\\workspaces\\tmp-00ff00aa")).toBe(true);
expect(isTempWorkspace("")).toBe(true);
expect(isTempWorkspace(" ")).toBe(true);
});
it("rejects named directories and near misses", () => {
expect(isTempWorkspace("/srv/repo")).toBe(false);
// tmp-<8hex> without a workspaces/ parent is a user directory that just looks similar
expect(isTempWorkspace("/srv/tmp-1a2b3c4d")).toBe(false);
// non-hex / wrong-length ids
expect(isTempWorkspace("/x/workspaces/tmp-XYZWQPRS")).toBe(false);
expect(isTempWorkspace("/x/workspaces/tmp-1a2b3c4")).toBe(false);
expect(isTempWorkspace("/x/workspaces/tmp-1a2b3c4d5")).toBe(false);
// a subdirectory below a temp Workspace is not itself the temp Workspace
expect(isTempWorkspace(`${TEMP_A}/nested`)).toBe(false);
});
});
describe("workspaceGroupKey / workspaceLabel", () => {
it("named paths key by the (trimmed) path itself; temp and empty paths share the sentinel", () => {
expect(workspaceGroupKey("/srv/repo")).toBe("/srv/repo");
expect(workspaceGroupKey(" /srv/repo ")).toBe("/srv/repo");
expect(workspaceGroupKey(TEMP_A)).toBe(TEMP_WORKSPACE_GROUP_KEY);
expect(workspaceGroupKey("")).toBe(TEMP_WORKSPACE_GROUP_KEY);
});
it("labels are the last path segment; the filesystem root yields '/'", () => {
expect(workspaceLabel("/srv/penguin/repo")).toBe("repo");
expect(workspaceLabel("/srv/repo/")).toBe("repo");
expect(workspaceLabel("/")).toBe("/");
expect(workspaceLabel("C:\\work\\site")).toBe("site");
});
});
describe("groupSessionsByWorkspace", () => {
it("empty input yields no groups", () => {
expect(groupSessionsByWorkspace([])).toEqual([]);
});
it("groups by path across Agents, labels by basename, and keeps the full path for tooltips", () => {
const a1 = session("/srv/alpha", "2026-07-01T10:00:00.000Z", { agentId: "default_agent" });
const a2 = session("/srv/alpha", "2026-07-03T10:00:00.000Z", { agentId: "agent_helper" });
const groups = groupSessionsByWorkspace([a1, a2]);
expect(groups).toHaveLength(1);
expect(groups[0]).toMatchObject({
key: "/srv/alpha",
label: "alpha",
fullPath: "/srv/alpha",
temp: false,
});
// Newest first even though the two sessions came from different Agents' lists.
expect(groups[0]!.sessions.map((s) => s.sessionId)).toEqual([a2.sessionId, a1.sessionId]);
});
it("merges every temp Workspace into one trailing group, after named groups sorted by newest session", () => {
const oldAlpha = session("/srv/alpha", "2026-07-01T10:00:00.000Z");
const newAlpha = session("/srv/alpha", "2026-07-06T10:00:00.000Z");
const beta = session("/srv/beta", "2026-07-05T10:00:00.000Z");
const temp1 = session(TEMP_A, "2026-07-02T10:00:00.000Z");
// The newest session overall is a temp one: the temp group still stays last.
const temp2 = session(TEMP_B, "2026-07-07T10:00:00.000Z");
const groups = groupSessionsByWorkspace([oldAlpha, beta, temp1, newAlpha, temp2]);
expect(groups.map((g) => g.key)).toEqual(["/srv/alpha", "/srv/beta", TEMP_WORKSPACE_GROUP_KEY]);
const temp = groups[2]!;
expect(temp).toMatchObject({ temp: true, label: "", fullPath: null });
expect(temp.sessions.map((s) => s.sessionId)).toEqual([temp2.sessionId, temp1.sessionId]);
expect(groups[0]!.sessions.map((s) => s.sessionId)).toEqual([
newAlpha.sessionId,
oldAlpha.sessionId,
]);
});
it("named-only input has no temp group; temp-only input yields just the temp group", () => {
expect(
groupSessionsByWorkspace([session("/srv/alpha", "2026-07-01T10:00:00.000Z")]),
).toHaveLength(1);
const tempOnly = groupSessionsByWorkspace([session(TEMP_A, "2026-07-01T10:00:00.000Z")]);
expect(tempOnly).toHaveLength(1);
expect(tempOnly[0]!.key).toBe(TEMP_WORKSPACE_GROUP_KEY);
});
it("keeps archived sessions in their group (the sidebar splits active/archived per group)", () => {
const active = session("/srv/alpha", "2026-07-02T10:00:00.000Z");
const archived = session("/srv/alpha", "2026-07-01T10:00:00.000Z", { archived: true });
const groups = groupSessionsByWorkspace([archived, active]);
expect(groups).toHaveLength(1);
expect(groups[0]!.sessions).toHaveLength(2);
});
});