Skip to content

Session lanes: Pinned · Parked · Bookmarks (buckets, altitude bar, compass menu) #90

Description

@ashwin-pc

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

  1. 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.
  2. 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.
  3. 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

  1. 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.
  2. Route/realtime — no changes: PATCH /api/session-ui-state is schema-agnostic; session_ui_state_changed broadcasts full state (server/realtime.ts).

Client

  1. src/app/types.ts — mirror schema, defaults, normalizers (normalizeSessionLanes); keep normalizePinnedSessions only if the localStorage bootstrap still references it, otherwise delete.
  2. 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-1232moveToLane/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".
  3. 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.
  4. src/main.ts⌘⇧P/⌘⇧B chords; prev/next-pinned shortcuts (:561,571) operate on the focused lane's list; pin toggle keeps id/description.
  5. 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

  1. 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)

  1. 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).
  2. PR2 — altitude bar: layers button, map mode, focused-lane rendering, "you are here".
  3. 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

  • v1 state files migrate losslessly (pins → pinned lane, order preserved); no user-visible change for users who never park.
  • Park/resume/bookmark/drop work from: drawer menu, chords, compass (PR3), on desktop and touch.
  • Parked always has a since; note prompt on entry; stale badge at >14d.
  • Lane map reachable in ≤1 click from ground; any lane reachable in ≤2; "you are here" always visible when off-ground.
  • Drawer: lane × bucket filters compose inside intact folder groups.
  • No color is used to encode lanes anywhere. Buckets/markers/unread behavior unchanged.
  • version: 3 file is never destroyed by this build. Deleted sessions leave no lane entries.
  • npm run typecheck && npm run build && npm test green; no known-failing tests left behind.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions