Session lanes: Pinned · Parked · Bookmarks (+ buckets, altitude bar, compass menu)
Type: Feature
Area: pi-web session organization (tab bar, session drawer, session UI state)
Repo: /local/home/ashwinpc/oss/pi-web
Status of this doc: Design locked. Ready to implement. All decisions below were made deliberately — do not re-litigate them without asking; do flag genuine conflicts found in code.
1. Problem & motivation
When working with multiple pinned sessions, some get blocked ("waiting on CR review", "blocked on upstream release"). Today the only options are: keep them pinned (tab bar space) or unpin them (forgotten forever). We need a middle state — and once that exists, it generalizes: sessions have an attention state (lane) orthogonal to their topic (bucket).
2. Design summary (locked decisions)
2.1 Model — two orthogonal axes
- Lane = where the session sits in your attention. Mutually exclusive, changes often.
Three lanes at launch: pinned (bar ground level, default), parked (blocked/waiting; quick to reach but out of the bar), bookmarks (long-lived reference sessions).
- Bucket = what it's about. Buckets are the existing session markers — same 5 colors, zero migration. (
SessionMarkerColorId = "blue" | "purple" | "yellow" | "red" | "green", palette in src/styles/sessions.css:545-549.)
- Most sessions are in no lane — they live only in the drawer's Recents, exactly as today.
2.2 Color discipline (hard rule)
- Color means exactly one thing: bucket. Lanes carry no color — they are monochrome icons + labels:
- pinned → map-pin:
M8 1a5 5 0 0 0-5 5c0 3.6 5 9 5 9s5-5.4 5-9a5 5 0 0 0-5-5zm0 6.8A1.8 1.8 0 1 1 8 4.2a1.8 1.8 0 0 1 0 3.6z
- parked → pause bars:
M5 3h2.2v10H5zM8.8 3H11v10H8.8z
- bookmarks → bookmark:
M4 2h8v12l-4-3-4 3z
(all 16×16 viewBox, fill: currentColor)
- The gold accent keeps meaning what it means today: current/selected.
- Clarification recorded during design:
.marked/.marker-<color> on tabs/rows is the marker color tint (i.e. bucket), NOT unread. Unread is the separate .unread class + sessionIndicator() system (src/sessions/sessionDrawer.ts:666-695, class assembly at :1606 and :2043). Do not conflate them; both may appear on one tab.
2.3 Lane policies
| Lane |
icon |
in bar |
note |
stale |
| pinned |
pin |
ground level (default) |
never prompts |
— |
| parked |
pause |
via lane map |
prompt on entry (one line; empty permitted but discouraged) |
badge after 14 days in lane |
| bookmarks |
bookmark |
via lane map |
none |
— |
Moving into pinned never prompts. Bucket changes never prompt.
2.4 Surfaces
- Tab bar — "Altitude" (no extra vertical space; bar stays one ~28-30px row):
- Ground level = pinned lane, rendered exactly as today (plus optional small bucket dot per tab).
- A layers button at the bar's left edge (replaces nothing; new 36-38px cell). Icon = layers stack:
M8 1.6 14.6 5.3 8 9 1.4 5.3z + M3.1 7.8 8 10.5l4.9-2.7 1.7 1L8 12.6 1.4 8.8z (op .65) + M3.1 10.4 8 13.1l4.9-2.7 1.7 1L8 15.2 1.4 11.4z (op .35).
Explicitly NOT a magnifier/⊙ (reads as search).
- Click layers → map mode: the bar row becomes 3 monochrome "territories" (icon · label · count), width ∝ session count, current lane gets the gold top edge. Click a territory → land in that lane (bar renders that lane's sessions; non-pinned lanes render dimmed/
away). Click layers again → back to map.
- "You are here": when the focused lane ≠ pinned, the layers button swaps to that lane's icon, highlighted.
- Intra-lane horizontal scrolling of tabs is unchanged.
- Session drawer — lanes as filters:
- Folder groups stay the skeleton, untouched (
sessionFolderGroup rendering at src/sessions/sessionDrawer.ts:1902-1980).
- New filter chip row above the list:
All · [pin] · [pause] · [bookmark] (monochrome, with counts) + the 5 bucket dots right-aligned. Lane filter and bucket filter compose (AND). Folder headers persist with live match counts; folders with zero matches drop out.
- Laned rows show their lane icon; parked rows additionally show the note line inline and a red
stale badge when > 14d.
- Menu — "Compass" (single engine, two renderers; a working prototype exists, see §6):
- Triggers: click-hold ~280ms (10px movement cancels) → press-aim-release; right-click → sticky mode (click targets, click-away/Esc cancels); long-press on touch.
contextmenu is preventDefault'd on laned items.
- Desktop renderer (fan): SVG protractor seated flat on the bottom edge — wedge sectors in an annulus r≈42..102: slim red
✕ DROP wedge [-180°,-150°], then PARK [-150°,-100°], PIN [-100°,-50°], MARK [-50°,0°] (screen-coord degrees, -90° = up). Each wedge: icon + label + live lane count. Bucket dots (r≈8) ride an outer groove arc at r≈122, angles -150°..-30° step 30°. Hub circle (r≈31, cy≈-4) = OPEN. Current lane wedge gets dashed stroke; current bucket dot gets a ring.
- Mobile renderer (bubble ring): full circle around the finger — lanes at pinned(0,-55) parked(-54,18) bookmarks(54,18) r30; buckets at (-82,-48)(-48,-84)(0,-97)(48,-84)(82,-48) r17; OPEN center r30; DROP (0,90).
- Mode selection: fan when the press point is within ~185px of the container bottom edge, bubble ring otherwise. Clamp origin so the instrument stays inside the viewport (fan ±145px horizontal; ring ±110px, y ∈ [170, H-130]).
- Hit-testing is analytic (radius+angle for wedges, distance for dots/hub) — no DOM hit targets needed in hold mode.
- Readout pill above the instrument names the current aim ("Park — asks for a one-line note", "bucket → green", "PARK — already here").
- Releasing on PARK (from another lane) opens the note prompt (input,
↵ commit / esc cancel); all other bearings commit instantly. Releasing on nothing cancels.
- Fallback for discoverability: the existing session-actions menu (drawer row kebab) gains a plain "Move to ▸ Pinned/Parked/Bookmarks · Remove from lanes" submenu. Keyboard chords:
⌘⇧P park current session (opens note prompt), ⌘⇧B bookmark current session. Existing pin shortcut (src/main.ts:550) keeps its behavior (= move to pinned / remove from lanes).
3. Data model & migration (v2 — locked)
Pre-1.0 project: we do the clean single-key model with a version bump, not an additive compat field. The JSON already has a version key and we want a migration mechanism anyway.
3.1 Schema
// server/sessionUiState.ts and mirrored in src/app/types.ts
export type SessionLaneId = "pinned" | "parked" | "bookmarks";
export type SessionLaneEntry = {
sessionId: string;
lane: SessionLaneId;
cwd?: string; // carried from PinnedSession; folder-refresh logic needs it
note?: string; // parked's "why"
since: string; // ISO timestamp of entering the lane; drives age + stale badge
};
export type SessionUiState = {
version: 2; // bumped from 1
lanes: SessionLaneEntry[]; // REPLACES pinnedSessions
pinnedFolders: string[]; // unchanged
sessionMarkers: SessionMarker[]; // unchanged (= buckets)
sessionUnreadStates: SessionUnreadState[]; // unchanged
sessionOrigins: SessionOrigin[]; // unchanged
selectedMarkerColor: SessionMarkerColorId; // unchanged
allowedMarkerColors: SessionMarkerColorId[]; // unchanged
};
Invariants (enforce in normalizeSessionUiState):
uniqueBy(sessionId) over lanes → a session is in at most one lane.
- Array order within a lane = display order (tab order for pinned). Preserve relative order on all operations.
lane must be one of the three ids; entries with unknown lanes are dropped.
since defaults to now when missing/invalid.
3.2 Migration mechanism (new, generic)
const MIGRATIONS: Record<number, (raw: Record<string, unknown>) => Record<string, unknown>> = {
1: (raw) => ({
...raw,
version: 2,
lanes: (Array.isArray(raw.pinnedSessions) ? raw.pinnedSessions : [])
.map((p: any) => ({
sessionId: p?.id, lane: "pinned",
...(p?.cwd ? { cwd: p.cwd } : {}),
since: new Date().toISOString(),
}))
.filter((e: any) => typeof e.sessionId === "string" && e.sessionId.trim()),
}),
};
function migrateSessionUiState(raw: unknown): unknown { /* while (MIGRATIONS[v]) step; */ }
- Runs inside the store's
read() before normalizeSessionUiState. Missing version ⇒ treat as 1 (today's implicit contract). The store's cache + serialized write queue make it effectively once; next write persists v2.
- Forward guard: if
version > 2, log and go read-only for that file (never normalize-and-write a future schema down).
- Legacy patch alias (~8 lines): in
applySessionUiStatePatch, if a patch contains pinnedSessions (stale pre-upgrade browser tab), translate it into a replace of the pinned subset of lanes (other lanes untouched). Markers/unread/etc. patches are unchanged anyway.
- Client (
src/app/types.ts): same schema + normalizers. The localStorage bootstrap path (pi-web-pinned-sessions → server when server state empty, refreshSessionUiState at src/sessions/sessionDrawer.ts:584) needs its locally-assembled state to go through the same v1→v2 shape (or simply send v1 shape and let the server migrate — the PATCH alias covers it).
3.3 Client accessor layer
All feature code goes through helpers; nothing outside them touches state.lanes directly:
laneOf(sessionId): SessionLaneId | undefined
sessionsInLane(lane): SessionLaneEntry[] // in stored order
moveToLane(sessionId, lane, opts?: { note?: string; cwd?: string }) // one PATCH
removeFromLanes(sessionId)
setLaneNote(sessionId, note)
isStale(entry): boolean // parked && now - since > 14d
4. Required changes, file by file
Server
server/sessionUiState.ts
- Add
SessionLaneId/SessionLaneEntry; replace pinnedSessions with lanes in SessionUiState, defaultSessionUiState, normalizeSessionUiState, applySessionUiStatePatch (incl. legacy alias), SessionUiStatePatch.
- Add
migrateSessionUiState + version guard; call from read().
removeSession() must filter lanes (replaces the pinnedSessions filter) — prevents ghost parked entries for deleted sessions.
- Route/realtime — no changes:
PATCH /api/session-ui-state is schema-agnostic; session_ui_state_changed broadcasts full state (server/realtime.ts).
Client
src/app/types.ts — mirror schema, defaults, normalizers (normalizeSessionLanes); keep normalizePinnedSessions only if the localStorage bootstrap still references it, otherwise delete.
src/sessions/sessionDrawer.ts (biggest file; migrate all state.pinnedSessions consumers to the accessor layer):
- state +
applySessionUiState (:527-548), hasAnySessionUiState (:552), refreshSessionUiState (:584), persistence patch payloads (:574,591).
- cwd refresh loop
:445-454 → operates on pinned-lane entries.
pinSession/unpinSession/toggle :1205-1232 → moveToLane/removeFromLanes.
- Tab bar render
:1602+: render sessions of the focused lane (new UI state, default "pinned", NOT persisted — view state only); non-pinned lanes render with a dimmed away style; hasPinnedSessions body-class logic keys off pinned lane + current session as today.
- Lane map mode for the bar + layers button (see §2.4.1).
- Drawer filter row (lane chips + bucket dots) + row lane icons + parked note line + stale badge (see §2.4.2).
- Session-actions menu: add "Move to ▸" submenu + "Remove from lanes".
src/sessions/compass.ts (new) — port the prototype engine (§6): buildCompass(container) → { attach(el, item) }; fan + ring renderers, analytic hit-testing, readout, note prompt, Esc/pointercancel handling. Wire commits to the accessor layer + bucket setter + session open + remove.
src/main.ts — ⌘⇧P/⌘⇧B chords; prev/next-pinned shortcuts (:561,571) operate on the focused lane's list; pin toggle keeps id/description.
src/styles/sessions.css — layers button, map territories (monochrome, .cur gold top edge), away tab style, filter chips + bucket filter dots, lane icons in rows, note line, stale badge, compass (scrim/fan/ring/readout/note prompt). Bucket dot on tabs: keep the existing .marked tint AND add the small dot only if it reads well; tint alone is acceptable for PR1.
Tests
- e2e (parallel runner,
npm test; do not use test:serial):
- v1 file on disk → migrated to v2 on first read; pins preserved with order; second read idempotent.
- Legacy
pinnedSessions PATCH updates the pinned lane, leaves parked/bookmarks untouched.
- Park flow: move pinned → parked (note stored,
since set, tab leaves bar), resume, bookmark, drop.
- Exclusivity: moving lanes never duplicates;
removeSession clears lane entries.
- Drawer filters: lane chip + bucket dot compose; empty folders drop out.
- Lane map: layers → territories → land → "you are here" icon swap → back.
- Stale: parked entry with
since > 14d shows badge.
- Version guard:
version: 3 file → server does not rewrite it.
5. Delivery plan (3 PRs)
- PR1 — model + drawer (feature is usable here): v2 schema, migration chain, version guard, legacy patch alias, accessor layer, drawer filter row + lane icons + note + stale, "Move to ▸" in session-actions menu,
⌘⇧P/⌘⇧B, tests. Tab bar still renders pinned only (no map yet).
- PR2 — altitude bar: layers button, map mode, focused-lane rendering, "you are here".
- PR3 — compass:
compass.ts engine + fan/ring, attached to tabs and drawer rows; the plain menu fallback remains.
Each PR: npm run typecheck, npm run build, npm test green. Frontend changes hot-reload; server changes need POST /api/restart (never kill :8787/:8788 directly).
6. Design references (mockups — open in browser or read the HTML)
All under .pi/web/artifacts/park-mockups/ (served at /api/artifacts/park-mockups/…):
| File |
What it locks |
final.html |
v1 direction: 3 monochrome lanes, altitude bar, drawer filters, compass (mobile ring), color discipline |
addenda.html |
folder groups + filters interplay, desktop compass half-fan bearings, layers icon choice |
compass.html |
working prototype of the compass engine — port this: fan SVG geometry/path math, bubble ring, analytic hit-testing, hold/sticky/long-press triggers, note prompt, readout. Verified end-to-end in headless Chrome (hold→aim→release→note→tab removed) |
scale.html |
how the design scales to 5+ lanes (accordion/altitude/exposé studies) — context only |
lanes.html, mobile.html, lane-focus.html, bars-menus.html, creative.html, index.html |
exploration history — context only |
Screenshots: shots/both-open.png (fan aiming at PARK + mobile ring), shots/parked-committed.png (post-commit state).
7. Decision log (why, in one line each)
- Park exists because closing = forgetting and pinning = clutter; the mandatory one-line note is what makes later review a 5-second decision.
- Lanes ≠ buckets: lane = attention state (exclusive, volatile), bucket = topic (stable, travels across lanes).
- Lanes have no color because buckets own color (user got confused when both were colored); lanes are icons.
- Buckets = markers because they're the same concept already shipped (5 colors,
legacyBucketToColor even shows markers were once called buckets).
- Altitude bar chosen over chips/strips/pager-in-bar: zero extra vertical space, preserves intra-lane scroll, "map ↔ ground" is a known mental model, scales past 3 lanes.
- Compass chosen for the menu: fixed bearings build muscle memory; fan on desktop (instrument seated on the bar), bubbles on mobile (thumb scale); plain menu + chords remain as the discoverable path.
- Single
lanes key + version bump (not additive dual-key): pre-1.0, controls its own store, and we want the migration mechanism anyway; stale-tab window covered by the patch alias.
.marked is bucket tint, not unread — unread is a separate class/indicator; don't merge them.
8. Acceptance criteria
Session lanes: Pinned · Parked · Bookmarks (+ buckets, altitude bar, compass menu)
Type: Feature
Area: pi-web session organization (tab bar, session drawer, session UI state)
Repo:
/local/home/ashwinpc/oss/pi-webStatus of this doc: Design locked. Ready to implement. All decisions below were made deliberately — do not re-litigate them without asking; do flag genuine conflicts found in code.
1. Problem & motivation
When working with multiple pinned sessions, some get blocked ("waiting on CR review", "blocked on upstream release"). Today the only options are: keep them pinned (tab bar space) or unpin them (forgotten forever). We need a middle state — and once that exists, it generalizes: sessions have an attention state (lane) orthogonal to their topic (bucket).
2. Design summary (locked decisions)
2.1 Model — two orthogonal axes
Three lanes at launch: pinned (bar ground level, default), parked (blocked/waiting; quick to reach but out of the bar), bookmarks (long-lived reference sessions).
SessionMarkerColorId = "blue" | "purple" | "yellow" | "red" | "green", palette insrc/styles/sessions.css:545-549.)2.2 Color discipline (hard rule)
M8 1a5 5 0 0 0-5 5c0 3.6 5 9 5 9s5-5.4 5-9a5 5 0 0 0-5-5zm0 6.8A1.8 1.8 0 1 1 8 4.2a1.8 1.8 0 0 1 0 3.6zM5 3h2.2v10H5zM8.8 3H11v10H8.8zM4 2h8v12l-4-3-4 3z(all 16×16 viewBox,
fill: currentColor).marked/.marker-<color>on tabs/rows is the marker color tint (i.e. bucket), NOT unread. Unread is the separate.unreadclass +sessionIndicator()system (src/sessions/sessionDrawer.ts:666-695, class assembly at:1606and:2043). Do not conflate them; both may appear on one tab.2.3 Lane policies
Moving into pinned never prompts. Bucket changes never prompt.
2.4 Surfaces
M8 1.6 14.6 5.3 8 9 1.4 5.3z+M3.1 7.8 8 10.5l4.9-2.7 1.7 1L8 12.6 1.4 8.8z(op .65) +M3.1 10.4 8 13.1l4.9-2.7 1.7 1L8 15.2 1.4 11.4z(op .35).Explicitly NOT a magnifier/⊙ (reads as search).
away). Click layers again → back to map.sessionFolderGrouprendering atsrc/sessions/sessionDrawer.ts:1902-1980).All · [pin] · [pause] · [bookmark](monochrome, with counts) + the 5 bucket dots right-aligned. Lane filter and bucket filter compose (AND). Folder headers persist with live match counts; folders with zero matches drop out.stalebadge when > 14d.contextmenuis preventDefault'd on laned items.✕ DROPwedge [-180°,-150°], thenPARK[-150°,-100°],PIN[-100°,-50°],MARK[-50°,0°] (screen-coord degrees, -90° = up). Each wedge: icon + label + live lane count. Bucket dots (r≈8) ride an outer groove arc at r≈122, angles -150°..-30° step 30°. Hub circle (r≈31, cy≈-4) = OPEN. Current lane wedge gets dashed stroke; current bucket dot gets a ring.↵commit /esccancel); all other bearings commit instantly. Releasing on nothing cancels.⌘⇧Ppark current session (opens note prompt),⌘⇧Bbookmark current session. Existing pin shortcut (src/main.ts:550) keeps its behavior (= move to pinned / remove from lanes).3. Data model & migration (v2 — locked)
Pre-1.0 project: we do the clean single-key model with a version bump, not an additive compat field. The JSON already has a
versionkey and we want a migration mechanism anyway.3.1 Schema
Invariants (enforce in
normalizeSessionUiState):uniqueBy(sessionId)overlanes→ a session is in at most one lane.lanemust be one of the three ids; entries with unknown lanes are dropped.sincedefaults to now when missing/invalid.3.2 Migration mechanism (new, generic)
read()beforenormalizeSessionUiState. Missingversion⇒ treat as 1 (today's implicit contract). The store's cache + serialized write queue make it effectively once; next write persists v2.version > 2, log and go read-only for that file (never normalize-and-write a future schema down).applySessionUiStatePatch, if a patch containspinnedSessions(stale pre-upgrade browser tab), translate it into a replace of the pinned subset oflanes(other lanes untouched). Markers/unread/etc. patches are unchanged anyway.src/app/types.ts): same schema + normalizers. The localStorage bootstrap path (pi-web-pinned-sessions→ server when server state empty,refreshSessionUiStateatsrc/sessions/sessionDrawer.ts:584) needs its locally-assembled state to go through the same v1→v2 shape (or simply send v1 shape and let the server migrate — the PATCH alias covers it).3.3 Client accessor layer
All feature code goes through helpers; nothing outside them touches
state.lanesdirectly:4. Required changes, file by file
Server
server/sessionUiState.tsSessionLaneId/SessionLaneEntry; replacepinnedSessionswithlanesinSessionUiState,defaultSessionUiState,normalizeSessionUiState,applySessionUiStatePatch(incl. legacy alias),SessionUiStatePatch.migrateSessionUiState+ version guard; call fromread().removeSession()must filterlanes(replaces thepinnedSessionsfilter) — prevents ghost parked entries for deleted sessions.PATCH /api/session-ui-stateis schema-agnostic;session_ui_state_changedbroadcasts full state (server/realtime.ts).Client
src/app/types.ts— mirror schema, defaults, normalizers (normalizeSessionLanes); keepnormalizePinnedSessionsonly if the localStorage bootstrap still references it, otherwise delete.src/sessions/sessionDrawer.ts(biggest file; migrate allstate.pinnedSessionsconsumers to the accessor layer):applySessionUiState(:527-548),hasAnySessionUiState(:552),refreshSessionUiState(:584), persistence patch payloads (:574,591).:445-454→ operates on pinned-lane entries.pinSession/unpinSession/toggle:1205-1232→moveToLane/removeFromLanes.:1602+: render sessions of the focused lane (new UI state, default"pinned", NOT persisted — view state only); non-pinned lanes render with a dimmedawaystyle;hasPinnedSessionsbody-class logic keys off pinned lane + current session as today.src/sessions/compass.ts(new) — port the prototype engine (§6):buildCompass(container)→{ attach(el, item) }; fan + ring renderers, analytic hit-testing, readout, note prompt, Esc/pointercancel handling. Wire commits to the accessor layer + bucket setter + session open + remove.src/main.ts—⌘⇧P/⌘⇧Bchords; prev/next-pinned shortcuts (:561,571) operate on the focused lane's list; pin toggle keeps id/description.src/styles/sessions.css— layers button, map territories (monochrome,.curgold top edge),awaytab style, filter chips + bucket filter dots, lane icons in rows, note line, stale badge, compass (scrim/fan/ring/readout/note prompt). Bucket dot on tabs: keep the existing.markedtint AND add the small dot only if it reads well; tint alone is acceptable for PR1.Tests
npm test; do not usetest:serial):pinnedSessionsPATCH updates the pinned lane, leaves parked/bookmarks untouched.sinceset, tab leaves bar), resume, bookmark, drop.removeSessionclears lane entries.since> 14d shows badge.version: 3file → server does not rewrite it.5. Delivery plan (3 PRs)
⌘⇧P/⌘⇧B, tests. Tab bar still renders pinned only (no map yet).compass.tsengine + fan/ring, attached to tabs and drawer rows; the plain menu fallback remains.Each PR:
npm run typecheck,npm run build,npm testgreen. Frontend changes hot-reload; server changes needPOST /api/restart(never kill :8787/:8788 directly).6. Design references (mockups — open in browser or read the HTML)
All under
.pi/web/artifacts/park-mockups/(served at/api/artifacts/park-mockups/…):final.htmladdenda.htmlcompass.htmlscale.htmllanes.html,mobile.html,lane-focus.html,bars-menus.html,creative.html,index.htmlScreenshots:
shots/both-open.png(fan aiming at PARK + mobile ring),shots/parked-committed.png(post-commit state).7. Decision log (why, in one line each)
legacyBucketToColoreven shows markers were once called buckets).laneskey + version bump (not additive dual-key): pre-1.0, controls its own store, and we want the migration mechanism anyway; stale-tab window covered by the patch alias..markedis bucket tint, not unread — unread is a separate class/indicator; don't merge them.8. Acceptance criteria
since; note prompt on entry; stale badge at >14d.version: 3file is never destroyed by this build. Deleted sessions leave no lane entries.npm run typecheck&&npm run build&&npm testgreen; no known-failing tests left behind.