/** * 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); }); });