diff --git a/packages/web/e2e/chat.spec.mjs b/packages/web/e2e/chat.spec.mjs index 11001de..54e0839 100644 --- a/packages/web/e2e/chat.spec.mjs +++ b/packages/web/e2e/chat.spec.mjs @@ -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(); diff --git a/packages/web/e2e/draft.spec.mjs b/packages/web/e2e/draft.spec.mjs index c177e84..9ebfa24 100644 --- a/packages/web/e2e/draft.spec.mjs +++ b/packages/web/e2e/draft.spec.mjs @@ -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(); diff --git a/packages/web/src/components/layout/sidebar.tsx b/packages/web/src/components/layout/sidebar.tsx index ae74cd5..fed0265 100644 --- a/packages/web/src/components/layout/sidebar.tsx +++ b/packages/web/src/components/layout/sidebar.tsx @@ -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 { + 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): 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>(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(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), + ); + // 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>(new Set()); /** Session pending delete confirmation (null = none). */ const [deletingSession, setDeletingSession] = useState(null); @@ -148,19 +201,33 @@ export function Sidebar({ const [renameBusy, setRenameBusy] = useState(false); const [renameError, setRenameError] = useState(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) => ( +
    + {rows.map((s) => ( + { + setRenameError(null); + setRenameText(x.title ?? ""); + setRenamingSession(x); + }} + onDelete={(x) => { + setDeleteError(null); + setDeletingSession(x); + }} + onToggleArchive={(x) => void toggleArchive(x)} + /> + ))} +
+ ); + + /** 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 ? ( +

+ {S.chat.noSessions} +

+ ) : ( + renderRows(activeList, withAgentHint) + )} + + {/* Archived group (collapsed by default) */} + {archivedList.length > 0 && ( +
+ + {archivedOpen && renderRows(archivedList, withAgentHint)} +
+ )} + + ); + }; + 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({ ))} - {/* Session area grouped by Agent (scrollable) */} + {/* Session area (scrollable): grouped by Workspace (default) or by Agent */}
- {loading && agents.length === 0 ? ( + {/* Section header: list label + grouping-mode toggle (the choice persists in localStorage) */} +
+ + {S.chat.sessionList} + +
+ {( + [ + { value: "workspace", icon: FOLDER_ICON, label: S.chat.groupByWorkspace }, + { value: "agent", icon: NAV_ICONS.agents, label: S.chat.groupByAgent }, + ] as const + ).map((opt) => ( + + ))} +
+
+ + {groupMode === "agent" ? ( + loading && agents.length === 0 ? ( + + ) : ( + 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 ( +
+ {/* Group header: collapse toggle (Agent name) + new chat + Agent settings */} +
+ + {/* New chat: enters draft state directly with this group's Agent (all options live on the draft input card) */} + + +
+ + {collapsed + ? null + : renderGroupBody(agent.agentId, activeList, archivedList, false)} +
+ ); + }) + ) + ) : loading && sessions.length === 0 ? ( + ) : workspaceGroups.length === 0 ? ( +

+ {S.chat.noSessions} +

) : ( - 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 ( -
- {/* Group header: collapse toggle (Agent name) + new chat + Agent settings */} +
+ {/* Group header: collapse toggle (folder icon + directory basename + count, full path in the tooltip) + new chat in this Workspace */}
- {/* 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 */} -
- {collapsed ? null : ( - <> - {activeList.length === 0 && archivedList.length === 0 ? ( -

- {S.chat.noSessions} -

- ) : ( -
    - {activeList.map((s) => ( - { - setRenameError(null); - setRenameText(x.title ?? ""); - setRenamingSession(x); - }} - onDelete={(x) => { - setDeleteError(null); - setDeletingSession(x); - }} - onToggleArchive={(x) => void toggleArchive(x)} - /> - ))} -
- )} - - {/* Archived group (collapsed by default) */} - {archivedList.length > 0 && ( -
- - {archivedOpen && ( -
    - {archivedList.map((s) => ( - { - setRenameError(null); - setRenameText(x.title ?? ""); - setRenamingSession(x); - }} - onDelete={(x) => { - setDeleteError(null); - setDeletingSession(x); - }} - onToggleArchive={(x) => void toggleArchive(x)} - /> - ))} -
- )} -
- )} - - )} + {collapsed ? null : renderGroupBody(group.key, activeList, archivedList, true)}
); }) @@ -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 && ( + + + {/* 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. */} + {agentHint} + + )} {/* Only attach a title attribute when the title is actually truncated (hover to see full text); don't duplicate the text otherwise. */} (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(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; diff --git a/packages/web/src/lib/session-grouping.ts b/packages/web/src/lib/session-grouping.ts new file mode 100644 index 0000000..c5870ae --- /dev/null +++ b/packages/web/src/lib/session-grouping.ts @@ -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 `/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(); + 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; +} diff --git a/packages/web/src/lib/strings-en.ts b/packages/web/src/lib/strings-en.ts index 9709ab0..579ae1c 100644 --- a/packages/web/src/lib/strings-en.ts +++ b/packages/web/src/lib/strings-en.ts @@ -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: { diff --git a/packages/web/src/lib/strings.ts b/packages/web/src/lib/strings.ts index 21c94c7..31eccad 100644 --- a/packages/web/src/lib/strings.ts +++ b/packages/web/src/lib/strings.ts @@ -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 diff --git a/packages/web/test/session-grouping.test.ts b/packages/web/test/session-grouping.test.ts new file mode 100644 index 0000000..73cf7da --- /dev/null +++ b/packages/web/test/session-grouping.test.ts @@ -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 (`/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); + }); +});