Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions server/sessionUiState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}>;
Expand All @@ -40,6 +42,7 @@ const legacyBucketToColor: Record<string, SessionMarkerColorId> = {
export const defaultSessionUiState: SessionUiState = {
version: 1,
pinnedSessions: [],
pinnedFolders: [],
sessionMarkers: [],
selectedMarkerColor: "blue",
};
Expand All @@ -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() : "";
Expand Down Expand Up @@ -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);
}
Expand All @@ -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);
}
Expand Down
25 changes: 25 additions & 0 deletions src/app/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand All @@ -100,6 +101,7 @@ export const sessionMarkerColors: SessionMarkerColor[] = [
export const defaultSessionUiState: SessionUiState = {
version: 1,
pinnedSessions: [],
pinnedFolders: [],
sessionMarkers: [],
selectedMarkerColor: "blue",
};
Expand All @@ -114,6 +116,7 @@ const legacyMarkerBucketToColor: Record<string, SessionMarkerColorId> = {
};

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";

Expand All @@ -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<string>();
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<string>();
Expand Down Expand Up @@ -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,
};
Expand All @@ -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) || "[]"));
Expand Down Expand Up @@ -214,6 +237,7 @@ export type AppState = {
connectionLostTimer: number | undefined;
reconnectedClearTimer: number | undefined;
pinnedSessions: PinnedSession[];
pinnedFolders: string[];
sessionMarkers: SessionMarker[];
selectedMarkerColor: SessionMarkerColorId;
collapsedSessionFolders: Set<string>;
Expand Down Expand Up @@ -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()),
Expand Down
74 changes: 70 additions & 4 deletions src/sessions/sessionDrawer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment on lines 357 to 363
Expand All @@ -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);
}
Comment on lines 367 to 371

function hasAnySessionUiState(value: SessionUiState) {
return value.pinnedSessions.length > 0
|| value.pinnedFolders.length > 0
|| value.sessionMarkers.length > 0
|| value.selectedMarkerColor !== defaultSessionUiState.selectedMarkerColor;
}
Expand Down Expand Up @@ -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,
});
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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";
Expand All @@ -1198,14 +1253,25 @@ 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)) {
elements.sessionListEl.append(group);
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);
Expand Down
38 changes: 38 additions & 0 deletions src/styles/sessions.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Comment on lines +350 to +371
.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;
Expand Down
11 changes: 11 additions & 0 deletions tests/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,13 +199,15 @@ 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`, {
method: "PATCH",
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",
}),
Expand All @@ -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 () => {
Expand Down
Loading
Loading