diff --git a/server/sessionUiState.ts b/server/sessionUiState.ts index 0e8f1c6..c296255 100644 --- a/server/sessionUiState.ts +++ b/server/sessionUiState.ts @@ -18,12 +18,14 @@ export type SessionMarker = { export type SessionUiState = { version: 1; pinnedSessions: PinnedSession[]; + pinnedFolders: string[]; sessionMarkers: SessionMarker[]; selectedMarkerColor: SessionMarkerColorId; }; export type SessionUiStatePatch = Partial<{ pinnedSessions: unknown; + pinnedFolders: unknown; sessionMarkers: unknown; selectedMarkerColor: unknown; }>; @@ -40,6 +42,7 @@ const legacyBucketToColor: Record = { export const defaultSessionUiState: SessionUiState = { version: 1, pinnedSessions: [], + pinnedFolders: [], sessionMarkers: [], selectedMarkerColor: "blue", }; @@ -58,6 +61,12 @@ function normalizeMarkerColor(value: unknown): SessionMarkerColorId | undefined : undefined; } +function normalizePinnedFolder(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed ? trimmed : undefined; +} + function normalizePinnedSession(value: unknown): PinnedSession | undefined { if (!isRecord(value)) return undefined; const id = typeof value.id === "string" ? value.id.trim() : ""; @@ -96,6 +105,10 @@ export function normalizeSessionUiState(value: unknown): SessionUiState { state.pinnedSessions = uniqueBy(value.pinnedSessions.map(normalizePinnedSession).filter(Boolean) as PinnedSession[], (item) => item.id); } + if (Array.isArray(value.pinnedFolders)) { + state.pinnedFolders = uniqueBy(value.pinnedFolders.map(normalizePinnedFolder).filter(Boolean) as string[], (item) => item); + } + if (Array.isArray(value.sessionMarkers)) { state.sessionMarkers = uniqueBy(value.sessionMarkers.map(normalizeSessionMarker).filter(Boolean) as SessionMarker[], (item) => item.sessionId); } @@ -112,6 +125,10 @@ export function applySessionUiStatePatch(current: SessionUiState, patch: unknown next.pinnedSessions = uniqueBy(patch.pinnedSessions.map(normalizePinnedSession).filter(Boolean) as PinnedSession[], (item) => item.id); } + if ("pinnedFolders" in patch && Array.isArray(patch.pinnedFolders)) { + next.pinnedFolders = uniqueBy(patch.pinnedFolders.map(normalizePinnedFolder).filter(Boolean) as string[], (item) => item); + } + if ("sessionMarkers" in patch && Array.isArray(patch.sessionMarkers)) { next.sessionMarkers = uniqueBy(patch.sessionMarkers.map(normalizeSessionMarker).filter(Boolean) as SessionMarker[], (item) => item.sessionId); } diff --git a/src/app/types.ts b/src/app/types.ts index d4b6707..6f0e704 100644 --- a/src/app/types.ts +++ b/src/app/types.ts @@ -85,6 +85,7 @@ export type SessionMarker = { sessionId: string; color: SessionMarkerColorId; no export type SessionUiState = { version: 1; pinnedSessions: PinnedSession[]; + pinnedFolders: string[]; sessionMarkers: SessionMarker[]; selectedMarkerColor: SessionMarkerColorId; }; @@ -100,6 +101,7 @@ export const sessionMarkerColors: SessionMarkerColor[] = [ export const defaultSessionUiState: SessionUiState = { version: 1, pinnedSessions: [], + pinnedFolders: [], sessionMarkers: [], selectedMarkerColor: "blue", }; @@ -114,6 +116,7 @@ const legacyMarkerBucketToColor: Record = { }; const pinnedSessionsKey = "pi-web-pinned-sessions"; +const pinnedFoldersKey = "pi-web-pinned-folders"; const sessionMarkersKey = "pi-web-session-markers"; const selectedMarkerColorKey = "pi-web-selected-session-marker-color"; @@ -123,6 +126,19 @@ export function normalizeMarkerColor(value: unknown): SessionMarkerColorId | und : undefined; } +export function normalizePinnedFolders(value: unknown): string[] { + if (!Array.isArray(value)) return []; + const seen = new Set(); + const result: string[] = []; + for (const item of value) { + const cwd = typeof item === "string" ? item.trim() : ""; + if (!cwd || seen.has(cwd)) continue; + seen.add(cwd); + result.push(cwd); + } + return result; +} + export function normalizePinnedSessions(value: unknown): PinnedSession[] { if (!Array.isArray(value)) return []; const seen = new Set(); @@ -157,6 +173,7 @@ export function normalizeSessionUiState(value: unknown): SessionUiState { return { version: 1, pinnedSessions: normalizePinnedSessions(raw.pinnedSessions), + pinnedFolders: normalizePinnedFolders(raw.pinnedFolders), sessionMarkers: normalizeSessionMarkers(raw.sessionMarkers), selectedMarkerColor: normalizeMarkerColor(raw.selectedMarkerColor) || defaultSessionUiState.selectedMarkerColor, }; @@ -168,6 +185,12 @@ export function readLegacyPinnedSessions(): PinnedSession[] { } catch { return []; } } +export function readLegacyPinnedFolders(): string[] { + try { + return normalizePinnedFolders(JSON.parse(localStorage.getItem(pinnedFoldersKey) || "[]")); + } catch { return []; } +} + export function readLegacySessionMarkers(): SessionMarker[] { try { return normalizeSessionMarkers(JSON.parse(localStorage.getItem(sessionMarkersKey) || "[]")); @@ -214,6 +237,7 @@ export type AppState = { connectionLostTimer: number | undefined; reconnectedClearTimer: number | undefined; pinnedSessions: PinnedSession[]; + pinnedFolders: string[]; sessionMarkers: SessionMarker[]; selectedMarkerColor: SessionMarkerColorId; collapsedSessionFolders: Set; @@ -297,6 +321,7 @@ export function createAppState(): AppState { connectionLostTimer: undefined, reconnectedClearTimer: undefined, pinnedSessions: readLegacyPinnedSessions(), + pinnedFolders: readLegacyPinnedFolders(), sessionMarkers: readLegacySessionMarkers(), selectedMarkerColor: readLegacySelectedMarkerColor() || defaultSessionUiState.selectedMarkerColor, collapsedSessionFolders: new Set(readCollapsedSessionFolders()), diff --git a/src/sessions/sessionDrawer.ts b/src/sessions/sessionDrawer.ts index 74c92fd..c11d357 100644 --- a/src/sessions/sessionDrawer.ts +++ b/src/sessions/sessionDrawer.ts @@ -357,6 +357,7 @@ export function createSessions(options: { function applySessionUiState(value: unknown) { const next = normalizeSessionUiState(value); state.pinnedSessions = next.pinnedSessions; + state.pinnedFolders = next.pinnedFolders; state.sessionMarkers = next.sessionMarkers; state.selectedMarkerColor = next.selectedMarkerColor; if (selectedSessionRowTool !== "pin") selectedSessionRowTool = next.selectedMarkerColor; @@ -366,11 +367,12 @@ export function createSessions(options: { renderSessionBar(); updateCurrentSessionPinButton(); renderCurrentSessionBucketButton(); - if (state.pinnedSessions.length > 0 && cachedSessions.length === 0) refreshSessions().catch(() => undefined); + if ((state.pinnedSessions.length > 0 || state.pinnedFolders.length > 0) && cachedSessions.length === 0) refreshSessions().catch(() => undefined); } function hasAnySessionUiState(value: SessionUiState) { return value.pinnedSessions.length > 0 + || value.pinnedFolders.length > 0 || value.sessionMarkers.length > 0 || value.selectedMarkerColor !== defaultSessionUiState.selectedMarkerColor; } @@ -400,6 +402,7 @@ export function createSessions(options: { const serverState = normalizeSessionUiState(data.sessionUiState); const localState = normalizeSessionUiState({ pinnedSessions: state.pinnedSessions, + pinnedFolders: state.pinnedFolders, sessionMarkers: state.sessionMarkers, selectedMarkerColor: state.selectedMarkerColor, }); @@ -821,6 +824,29 @@ export function createSessions(options: { else pinSession(item); } + function isPinnedFolder(cwd: string) { + return state.pinnedFolders.includes(cwd); + } + + function pinFolder(cwd: string) { + if (!cwd || isPinnedFolder(cwd)) return; + state.pinnedFolders = [...state.pinnedFolders, cwd]; + persistSessionUiState({ pinnedFolders: state.pinnedFolders }); + renderSessionList(cachedSessions); + } + + function unpinFolder(cwd: string) { + if (!isPinnedFolder(cwd)) return; + state.pinnedFolders = state.pinnedFolders.filter((value) => value !== cwd); + persistSessionUiState({ pinnedFolders: state.pinnedFolders }); + renderSessionList(cachedSessions); + } + + function toggleFolderPin(cwd: string) { + if (isPinnedFolder(cwd)) unpinFolder(cwd); + else pinFolder(cwd); + } + function titleForSessionId(sessionId: string) { const live = cachedSessions.find((s) => s.id === sessionId); const pinned = state.pinnedSessions.find((s) => s.id === sessionId); @@ -1154,9 +1180,24 @@ export function createSessions(options: { groups.set(cwd, [...(groups.get(cwd) || []), item]); } - for (const [cwd, items] of groups) { + // Partition cwds into pinned (in pin order) and rest (existing recency-based order), + // so pinned folders never reshuffle when the user switches sessions. Pinned folders + // with no sessions are still rendered as ghost rows so the user can unpin them. + const pinnedFolderSet = new Set(state.pinnedFolders); + const orderedCwds: string[] = []; + for (const cwd of state.pinnedFolders) { + if (!groups.has(cwd)) groups.set(cwd, []); + orderedCwds.push(cwd); + } + for (const cwd of groups.keys()) { + if (!pinnedFolderSet.has(cwd)) orderedCwds.push(cwd); + } + + for (const cwd of orderedCwds) { + const items = groups.get(cwd) || []; + const folderPinned = pinnedFolderSet.has(cwd); const group = document.createElement("section"); - group.className = "sessionFolderGroup"; + group.className = `sessionFolderGroup${folderPinned ? " pinned" : ""}`; const header = document.createElement("div"); header.className = "sessionFolderHeader"; @@ -1185,6 +1226,20 @@ export function createSessions(options: { renderSessionList(cachedSessions); }); + const pinFolderButton = document.createElement("button"); + pinFolderButton.type = "button"; + pinFolderButton.className = `iconButton sessionFolderPinButton${folderPinned ? " pinned" : ""}`; + pinFolderButton.title = folderPinned + ? `Unpin folder ${folderName(cwd)} from the top of the drawer` + : `Pin folder ${folderName(cwd)} to the top of the drawer`; + pinFolderButton.setAttribute("aria-label", pinFolderButton.title); + pinFolderButton.setAttribute("aria-pressed", String(folderPinned)); + setIcon(pinFolderButton, "pin"); + pinFolderButton.addEventListener("click", (event) => { + event.stopPropagation(); + toggleFolderPin(cwd); + }); + const newButton = document.createElement("button"); newButton.type = "button"; newButton.className = "iconButton sessionFolderNewButton"; @@ -1198,7 +1253,7 @@ export function createSessions(options: { addMessage("system", error instanceof Error ? error.message : String(error), "error"); } }); - header.append(toggle, newButton); + header.append(toggle, pinFolderButton, newButton); group.append(header); if (state.collapsedSessionFolders.has(cwd)) { @@ -1206,6 +1261,17 @@ export function createSessions(options: { continue; } + // Pinned folders that no longer have any sessions still show a placeholder + // row so the folder header (and its unpin affordance) remains accessible. + if (items.length === 0) { + const placeholder = document.createElement("p"); + placeholder.className = "sessionEmpty"; + placeholder.textContent = "No sessions in this folder yet."; + group.append(placeholder); + elements.sessionListEl.append(group); + continue; + } + const filteredItems = items.filter(matchesFilter); const folderExpanded = state.expandedSessionFolders.has(cwd); const visibleItems = folderExpanded ? filteredItems : filteredItems.slice(0, sessionFolderPreviewLimit); diff --git a/src/styles/sessions.css b/src/styles/sessions.css index 87ed3ad..faf43e8 100644 --- a/src/styles/sessions.css +++ b/src/styles/sessions.css @@ -347,6 +347,44 @@ width: 13px; height: 13px; } +.sessionFolderPinButton { + flex: 0 0 auto; + width: 24px; + min-width: 24px; + height: 24px; + min-height: 24px; + border: none; + border-radius: 6px; + background: transparent; + color: color-mix(in srgb, var(--muted) 70%, transparent); + opacity: 0.45; + transition: opacity 0.12s ease, color 0.12s ease, background 0.12s ease; +} +.sessionFolderHeader:hover .sessionFolderPinButton { + opacity: 1; +} +.sessionFolderPinButton:hover { + border: none; + background: var(--panel-2); + color: var(--accent); + opacity: 1; +} +.sessionFolderPinButton svg { + width: 12px; + height: 12px; +} +.sessionFolderPinButton.pinned { + color: var(--accent); + opacity: 1; +} +.sessionFolderGroup.pinned > .sessionFolderHeader .sessionFolderName { + color: var(--accent); +} +.sessionFolderGroup.pinned + .sessionFolderGroup:not(.pinned) { + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid color-mix(in srgb, var(--border) 90%, transparent); +} .sessionFolderMoreButton { height: 28px; min-height: 28px; diff --git a/tests/api.test.ts b/tests/api.test.ts index 9badda9..57c50a5 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -199,6 +199,7 @@ describe("pi-web mock API", () => { it("persists and returns server session UI state", async () => { const initial = await (await fetch(`${baseUrl}/api/session-ui-state`)).json(); expect(initial.sessionUiState.pinnedSessions).toEqual([]); + expect(initial.sessionUiState.pinnedFolders).toEqual([]); expect(initial.sessionUiState.selectedMarkerColor).toBe("blue"); const patchedRes = await fetch(`${baseUrl}/api/session-ui-state`, { @@ -206,6 +207,7 @@ describe("pi-web mock API", () => { headers: { "content-type": "application/json" }, body: JSON.stringify({ pinnedSessions: [{ id: "mock-current", label: "Current mock session", cwd: "." }], + pinnedFolders: ["/work/repo-a", "/work/repo-b", "/work/repo-a", "", " "], sessionMarkers: [{ sessionId: "mock-older", color: "green", updatedAt: "2026-01-01T00:00:00.000Z" }], selectedMarkerColor: "green", }), @@ -214,12 +216,21 @@ describe("pi-web mock API", () => { const patched = await patchedRes.json(); expect(patched.sessionUiState).toMatchObject({ pinnedSessions: [{ id: "mock-current", label: "Current mock session", cwd: "." }], + pinnedFolders: ["/work/repo-a", "/work/repo-b"], sessionMarkers: [{ sessionId: "mock-older", color: "green" }], selectedMarkerColor: "green", }); const current = await (await fetch(`${baseUrl}/api/session-ui-state`)).json(); expect(current.sessionUiState.selectedMarkerColor).toBe("green"); + expect(current.sessionUiState.pinnedFolders).toEqual(["/work/repo-a", "/work/repo-b"]); + + // Reset for downstream tests. + await fetch(`${baseUrl}/api/session-ui-state`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ pinnedFolders: [] }), + }); }); it("applies saved defaults to new sessions", async () => { diff --git a/tests/e2e/session-bar.spec.ts b/tests/e2e/session-bar.spec.ts index 3baa4d3..196ed2b 100644 --- a/tests/e2e/session-bar.spec.ts +++ b/tests/e2e/session-bar.spec.ts @@ -2,6 +2,7 @@ import { expect, test } from "@playwright/test"; async function seedServerSessionUiState(page: import("@playwright/test").Page, state: { pinnedSessions?: Array<{ id: string; label: string; cwd?: string }>; + pinnedFolders?: string[]; sessionMarkers?: Array<{ sessionId: string; color: string; updatedAt: string }>; }) { await page.request.patch("/api/session-ui-state", { data: state }); @@ -247,6 +248,81 @@ test.describe("session quick bar", () => { await expect(page.locator(".sessionFolderGroup .sessionEmpty")).toHaveCount(2); }); + test("session drawer pins folders to the top regardless of session activity order", async ({ page }) => { + await page.route("**/api/sessions**", async (route) => { + await route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ sessions: [ + { + id: "mock-current", + name: "Current mock session", + firstMessage: "Latest activity is in folder-a", + modified: "2026-05-07T10:00:00.000Z", + messageCount: 2, + cwd: "/workspace/folder-a", + isCurrent: true, + }, + { + id: "mock-older", + name: "Older mock session", + firstMessage: "Older activity in folder-b", + modified: "2026-05-06T09:00:00.000Z", + messageCount: 4, + cwd: "/workspace/folder-b", + isCurrent: false, + }, + { + id: "mock-third", + name: "Third session", + firstMessage: "Even older folder-c", + modified: "2026-05-05T09:00:00.000Z", + messageCount: 1, + cwd: "/workspace/folder-c", + isCurrent: false, + }, + ] }), + }); + }); + + await page.goto("/"); + await page.locator("#sessionButton").click(); + + // Default order is by recency: folder-a, folder-b, folder-c. + await expect(page.locator(".sessionFolderName")).toContainText(["folder-a", "folder-b", "folder-c"]); + + // Pin folder-c. It should jump to the top and stay there. + const folderCGroup = page.locator(".sessionFolderGroup", { has: page.locator(".sessionFolderName", { hasText: "folder-c" }) }); + await folderCGroup.locator(".sessionFolderPinButton").click(); + await expect(folderCGroup).toHaveClass(/\bpinned\b/); + await expect(page.locator(".sessionFolderName")).toContainText(["folder-c", "folder-a", "folder-b"]); + + // Pin folder-b too — pinned region appends in pin order. + const folderBGroup = page.locator(".sessionFolderGroup", { has: page.locator(".sessionFolderName", { hasText: "folder-b" }) }); + await folderBGroup.locator(".sessionFolderPinButton").click(); + await expect(page.locator(".sessionFolderName")).toContainText(["folder-c", "folder-b", "folder-a"]); + + // Persisted server-side. + const stored = await (await page.request.get("/api/session-ui-state")).json(); + expect(stored.sessionUiState.pinnedFolders).toEqual(["/workspace/folder-c", "/workspace/folder-b"]); + + // Pin survives a marker-color filter that hides every session in the pinned folder. + await seedServerSessionUiState(page, { + sessionMarkers: [{ sessionId: "mock-current", color: "green", updatedAt: "2026-01-01T00:00:00.000Z" }], + }); + await page.locator(".sessionColorFilterButton").click(); + await page.locator(".sessionColorFilterMenuItem.marker-green").click(); + await expect(page.locator(".sessionFolderName")).toContainText(["folder-c", "folder-b", "folder-a"]); + + // Unpin folder-c, ordering reverts to recency for that folder. + await page.locator(".sessionColorFilterButton").click(); + await page.locator(".sessionColorFilterMenuItem.all").click(); + await page.locator(".sessionColorFilterButton").click({ force: true }); + // Close any lingering menu by pressing Escape. + await page.keyboard.press("Escape"); + await folderCGroup.locator(".sessionFolderPinButton").click(); + await expect(page.locator(".sessionFolderName")).toContainText(["folder-b", "folder-a", "folder-c"]); + }); + test("session drawer uses selected marker colors and one-line marker actions", async ({ page }) => { await page.goto("/"); await page.locator("#sessionButton").click();