Skip to content

design session rail

sinrimin edited this page Oct 30, 2026 · 10 revisions

Session rail: current-session language, folder header, row rhythm

Status: shipped (#99, branch feat/99-session-rail) — rig-verified on the local bench (memory server + stub agent + agent-browser); readouts in §12. Scope: the two chat session rails — the machine side (AgentSession) and the platform-assistant side (AssistantSession). Each renders one rail component twice: the desktop left column and the phone drawer. The primary nav (spine/plate) is explicitly out of scope and keeps its current language. Issue: #99. Touches: apps/web only — CSS tokens, BAY skin, AgentSession.tsx, AssistantSession.tsx, a shared RailSessionRow, a small lib/cwd.ts helper, chat strings. No server/sdk/core/shared changes.

1. Problem

The current-session row used the same active language as the primary-nav current item (tray fill + 600), and row hover was the same tray at 60%. Three states were indistinguishable at a glance. The assistant rail reused the same row anatomy (tray active + lamp gutter + row line), so it had the same problem. The machine list also carried an 8px scroll inset and a border-b + last:border-b-0 scheme, so only the open row appeared to have a bottom border: the last row of each group computed to 0px, and the active row is usually not last.

2. Decision

2.1 Active language — A (signal edge)

  • Active row: 2px --signal left edge at x=0 + 8% signal tint; title 600; aria-current="true".
  • Every row reserves a 2px transparent left edge, so lighting it never shifts text.
  • The primary nav keeps its ink language (foreground edge + tray). Color (session) vs ink (nav) separates the two scopes without a second accent.

Rejected: B — full reverse video. It is a bright bar in dark mode and reads as an LCD window in BAY, re-mixing the nav/session languages there. B also loses the "current = live" semantic that the accent already carries.

2.2 Content — D3 folder header + M2 row

D3 folder header (desktop 36px / touch 44px):

folder icon   video-view                    /root/projects   +
  • The whole left region (icon + name + parent path) is the collapse control; the 14px folder icon alone is too small a target. There is no chevron. The header's title carries the full cwd.
  • Name 13/19 600. Parent path 10.5/15 mono, --muted-foreground, right-aligned, max-width: 132px (desktop) / 100px (touch), CSS ellipsis. Name and path are baseline-aligned; icon and + are centered in the line box.
  • Parent path = cwd minus its last segment. Hidden when the parent is / (no added information); /root/projects shows /root, /root shows nothing.
  • + (new session in this cwd) is absolutely positioned so it never squeezes the name. Desktop: opacity 0 → 1 on header hover/focus; the parent path fades out in the same slot. A no-hover pointer (@media (hover:none), the project's existing convention in kit/well.tsx) keeps it visible; a coarse pointer gets a 24×24 hit area, and the toggle reserves pr-7 so the path never runs under it.
  • The left region is one button with aria-expanded; + is a sibling button (never nested). Clicks on the name or the path collapse the group; a click on + only creates a session.

M2 session row (49.4px, no bottom border):

  • Remove the 8px lamp gutter. Text inset 32px = folder-name left edge (12px header padding + 14px icon + 6px gap). The active edge stays at the far left.
  • Status is a word at the right end of the meta line (#104 moved it off the title line): running / 运行中 in --state-ok-ink; open / 已打开 in --signal-ink; nothing when the session has no channel. open is only shown on a non-active attached row — on the current row the edge + tint already say it. running shows on the active row too. The word is real text, so state truth is not color-only.
  • Title 12/16 gets the full row width and truncates; the meta line carries relative time · 8-char id at 11/15.4 mono with a 2px top gap and the status word at its right end. Hover stays neutral --tray/60; stale rows keep 50% opacity and cursor-default.
  • The row stays square. The plate's rounded key and the rail's full-bleed ledger are different scopes (see §2.1); rounding only the free corners was tried in #104 review and read as a defect — reverted.

2.3 List geometry

  • The scroll container loses its 8px padding (p-2). Rows, header strips, hover and active fills reach the rail edges.
  • No per-row borders. One 1px --border line between groups, drawn as the next group's top border (border-t first:border-t-0). The first group has no line; the header keeps its own bottom border; the last group has no closing line.
  • Empty/loading/error notes move from px-2 to px-3 because the container no longer supplies the inset.

2.4 Assistant rail — flat list

The platform-assistant rail has no working directories, so D3 and the group separators do not apply. It reuses the same row device:

  • No group header, no group lines, no row lines; the list is flush.
  • Row geometry is identical (49.4px, two-line title/meta), including the 32px text inset (padding-left: 30px + the 2px reserved edge). The flat list keeps the machine rail's hanging indent even though there is no folder name to align with; a flush 12px edge read as stiff.
  • Status words: only running / 运行中 for a busy session. The assistant rail has no per-row "attached" concept, so it never shows open. The active row shows no word when idle.
  • The active language (A, aria-current, BAY paper fill), hover, meta line and i18n are exactly the machine rail's. Both pages render one shared RailSessionRow, so the two cannot drift.

3. Geometry

Part Desktop rail Phone drawer
rail / drawer width 288px 288px, max-width: 85vw
rail background bg-sidebar/40 bg-background (unchanged)
list scroll inset 0 0
group header height 36px (py 8 + 19 + 1 border) 44px (py 12 + 19 + 1)
header horizontal padding 12px 12px
folder icon 14px, gap 6 same
group name 13/19, 600 same
parent path 10.5/15 mono, max 132px same, max 100px
row height 49.4px (py 8 + 16 + 2 + 15.4) same
row padding 8 / 12 / 8 / 30 + 2px left edge same
text inset 32px 32px
title / meta 12/16 / 11/15.4 mono same
status word 10/15, 600 same
group separator 1px --border on the next group same
row separator none none
+ hit area 20×20 (12px glyph) 24×24 (12px glyph, p-1.5)

Assistant rail (flat): no group header and no group separators; the row is the same device with the same 32px text inset (padding-left 30px + the 2px edge) and no word except running. Width/background/row height/active fill are identical to the machine rail.

4. State matrix

State Rendering
Current session 2px --signal edge at x=0 + 8% signal tint + title 600 + aria-current
Current session (BAY) Same, but the fill is --card paper; hover keeps the paper
Current session, idle Edge + tint + 600 only; no status word (open would be redundant)
Row hover --tray 60% (neutral, distinct from active)
Running (busy) running / 运行中, --state-ok-ink; shown on the current row too
Open (attached, idle) open / 已打开, --signal-ink; non-current rows only
No channel No word
Assistant busy running / 运行中; the assistant rail has no attached concept, so no open
Assistant current, idle Edge + tint + 600 only
Stale 50% opacity + cursor-default (unchanged)
Collapsed group Header only; the group separator remains

5. Theme and skin

5.1 Palette

Pair Signal light Signal dark BAY
--signal (edge, lamp) oklch(.55 .11 230) #007ca5 oklch(.72 .13 200) #00bcc5 #ef5b13
--signal-ink (type) oklch(.5 .11 230) #006d95 var(--signal) #9c3a0b
--state-ok-ink oklch(.5 .13 152) oklch(.8 .15 155) #14713c
Active fill 8% signal over rail #e3eef4 8% signal over rail #0e2128 --card #f2f4f7
open word on row¹ / active fill 5.43 / 4.90 7.95 / 7.11 4.81 / 6.30
running word on row / active fill 5.31 / 4.79 10.52 / 9.41 4.20² / 5.51
Title on active fill 14.70 14.76 15.89
Edge on active fill / row fill 4.01 / 4.45 7.11 / 7.95 3.09 / 2.35³

¹ "Row fill" is the rail's actual ground (rows are transparent): --sidebar/40 over --background — Signal light #f6f8fa, dark #0f141a, BAY #d2d7df. The active fill is the 8% signal mix over that (opaque --card in BAY).

² BAY's #14713c is the comp's frozen status ink (P8 deepening rule). On aluminium it measures 4.20; on paper 5.51. This design adds no new green token; if BAY's palette is reopened, deepen it by the same rule as --signal-ink.

³ BAY's orange edge is 2.35 against the rail and 3.09 against the active paper. It is the same --signal used by the comp's current bay edge and active-tab underline; the skin contract requires the edge to follow the comp. The active state does not rely on the edge alone (paper fill + 600 title + aria-current).

5.2 New contract token — --signal-ink

The accent as type, mirroring the existing --lamp-* vs --state-*-ink split: lamps and edges are lit objects; words are type. Without it the 10px status word fails AA in Signal light (4.01 on the active tint) and badly in BAY (2.42 for LED orange on aluminium).

  • index.css :root: --signal-ink: oklch(0.5 0.11 230);
  • index.css html.dark: --signal-ink: var(--signal);
  • @theme inline: --color-signal-ink: var(--signal-ink); → text-signal-ink
  • skins/bay/tokens.css: --signal-ink: #9c3a0b;

Edges and lamps keep --signal; --signal-ink is used only for text.

5.3 BAY active fill

The 8% orange tint was rejected for BAY: it mixes to a grey-pink (#d7d0d1) and drops the open word to 4.06. BAY's own material language is paper + accent edge (the current bay goes to paper; the active tab is panel-plate with a 3px accent underline). The active session therefore uses --card paper and keeps the 2px orange edge (the same data-slot="session-row" hook covers both rails):

[data-skin='bay'] [data-slot='session-row'][aria-current='true'],
[data-skin='bay'] [data-slot='session-row'][aria-current='true']:hover {
  background: var(--card);
}

6. Phone drawer

Both pages render their rail in the same phone drawer. The machine rail allows two device deltas:

  1. Group header 44px on the drawer (max-md:min-h-11 max-md:py-3) and on any coarse pointer (@media (pointer:coarse), which also covers a touch tablet using the desktop rail); 36px with a mouse.
  2. + always visible with a 24×24 hit area: visible on the drawer (max-md:opacity-100) and on any no-hover pointer ([@media(hover:none)]:opacity-100, the same rule as kit/well.tsx); the hit area grows on coarse pointers ([@media(pointer:coarse)]:p-1.5); the toggle reserves pr-7 so the path and + never overlap.

Everything else matches the desktop: 288px width, row height, edge, status words, group separators, BAY paper fill. The assistant drawer has no group header, so none of the touch deltas apply; its rows are the shared device at the same 49.4px. Swipe-to-delete (SwipeRow) is unchanged; opening a session closes the drawer; the ?rail hand-off is unchanged. The drawer's bg-background vs the desktop rail's bg-sidebar/40 is existing behavior and stays.

7. Accessibility

  • Active row: aria-current="true"; active state is also carried by edge + fill + 600, so it is not color-only.
  • Group toggle: the whole left region is one button with aria-expanded; aria-label = Working directory: <cwd>; full path in title. The 14px icon is not the only hit area.
  • +: a sibling button (never nested in the toggle), labeled with the existing chat.newSessionHere copy (dir name); reachable by keyboard on desktop (focus reveals it); always visible on touch. Clicking it never collapses the group.
  • Status words are text (not a color swatch), 10px/600; contrast table above.
  • Touch targets: row ≥49px; header 44px; + 24×24; delete remains swipe on phones and a hover trash on desktop.

8. Implementation map

File Change
apps/web/src/index.css Add --signal-ink to :root and html.dark; map --color-signal-ink in @theme inline
apps/web/src/skins/bay/tokens.css --signal-ink: #9c3a0b;
apps/web/src/skins/bay/skin.css Paper fill for the active session row (see §5.3)
apps/web/src/lib/cwd.ts (new) Move cwdBasename; add cwdParent (returns null at / or with no parent); cwd.test.ts covers /root/projects, /root, /, trailing slash
apps/web/src/components/chat/rail-session-row.tsx (new) The shared row device: title, meta, active, word: 'running' | 'open' | null, titleAttr, disabled, faded, onClick; owns data-slot="session-row", aria-current, the edge/tint, and the status word. Both pages render it, so the active language cannot drift again
apps/web/src/pages/AgentSession.tsx Rewrite the rail markup: scroll inset, group header (D3, whole-left-region toggle), row (use RailSessionRow), cwdParent, running/open word rules
apps/web/src/pages/AssistantSession.tsx Flat rail: remove p-2, the lamp gutter and the row borders; keep the 32px text inset; rows via RailSessionRow; busy word; aria-current
apps/web/src/i18n/strings/chat.ts chat.sessionRunning (running / 运行中), chat.sessionOpen (open / 已打开)
scripts/ui-snap.mjs Script unchanged; the /chat+/chat/assistants family baselines were refreshed (--update) — the assistant page IS in the matrix (only the machine session page is not)

No changes to nav.ts, the shell, session data, sorting, grouping, or title sync. #94 (oldest-first rail) is a separate fix.

9. Verification

  1. pnpm --filter @harness-nexus/web run typecheck and run test.
  2. scripts/ui-snap.mjs: the matrix's /chat lands on /chat/assistants, so the ASSISTANT page is captured (the machine session page is not). Only that family moved (desktop shots 0.5069%, just over the 0.5% budget); sub-0.5% drift on the other pages reproduces on an unmodified baseline (time-data drift, unrelated). The family's 8 baselines were refreshed and re-verified green.
  3. Local single-box rig (docs/dev/ui-refactor/17-p8-acceptance.md §5c.3: real server with the memory driver + stub ACP agent; do not use the deployed instance and do not open chat sessions there). Use agent-browser:
    • Machine rail, desktop 1440, Signal light/dark and BAY: active row switch; non-active hover; header hover (+ appears, path fades); collapse by clicking the name and by clicking the path; a click on + creates but does not collapse; measured row 49.4, header 36, edge 2, text inset 32, group line 1, row bottom 0; BAY active rgb(242,244,247), edge rgb(239,91,19), words rgb(20,113,60) / rgb(156,58,11).
    • Assistant rail, desktop and phone drawer: flat list with no group header/line; text inset 32; active switch; a busy row shows 运行中; the current idle row shows no word; BAY paper fill.
    • Status rules: the current idle machine row never shows 已打开; a busy current row shows 运行中.
    • Phone 390, machine drawer: header 44, + 24×24 and always visible, path clear of +, swipe delete, opening closes the drawer.
    • EN/ZH words; empty/loading/error notes.
  4. Node 22.19 quartet (task verify) before any push.

10. Rejected alternatives

  • B — full reverse video on the active row. One-glance strongest, but a bright bar in dark mode, an LCD well in BAY, and it gives up the accent's "current = live" read.
  • Keep the status gutter + lamp. The dot had to be deciphered; the word adds width back to the title and reads at a glance.
  • last:border-b-0 variants (all-row lines, current behavior). The last-row exception is what created the phantom "only the open row has a border" effect. The chosen rhythm uses group lines only.
  • 8px list inset. It floats the list above the rail and detaches the active edge from the pane's physical left edge.
  • BAY orange tint. Muddy grey-pink and a 4.06 word contrast; paper is BAY's own active material.
  • Folder-icon-only collapse. The 14px glyph is a poor hit target, especially on touch. The whole left region (icon + name + path) is the toggle; + remains a sibling so it never triggers a collapse.
  • Showing 已打开 on the current row. Redundant with the edge + tint; the word is reserved for non-current attached rows, where it actually adds information. The assistant rail has no attached concept and only ever shows 运行中.

11. Out of scope

  • Primary nav (spine/plate). No changes.
  • Session data, sorting, grouping, title sync (#94 is separate).
  • Windows path handling: cwdBasename/cwdParent split on / (the existing helper does too); the host-side listing is a separate concern (#43).
  • BAY dark mode (BAY ships light only).

12. Shipped verification (2026-10-29, local bench)

Memory server + the multi-cwd stub agent + agent-browser; every number measured on the rendered app, not the prototype.

Check Result
List flush scroll container padding 0px (both rails)
Group header 36px desktop / 44px in the phone drawer; own 1px bottom border kept
Group separators first group 0px top, groups 2–3 1px top (--border); no closing line
Row 49.4px, 2px reserved left edge, no bottom borders, text inset 32px (machine AND assistant rail)
Active aria-current="true", edge oklch(.55 .11 230), fill signal 8%, title 600; idle current row shows NO word
running word on the CURRENT row while a turn waits on a permission request; --state-ok-ink; clears when answered
open word non-current attached row only; --signal-ink (oklch(.5 .11 230)); gone after the channel is evicted
zh 运行中 / 已打开; header label 工作目录: <cwd>
Dark edge/fill/word all read the dark --signal (oklch(.72 .13 200))
BAY active fill rgb(242,244,247) (paper), edge rgb(239,91,19), open word rgb(156,58,11), tray rgb(233,236,241), group line rgb(170,179,191), Archivo/JetBrains Mono
Collapse clicking the NAME collapses (aria-expanded flips, rows hide, group = 36px); clicking the PATH expands; clicking + opens a new session in that cwd WITHOUT collapsing (row appeared, group stayed open)
+ reveal keyboard focus reveals it (:focus-visible → opacity 1); the hover swap is media-gated — under hover:none the + is always visible, which is the designed fallback
Phone drawer 288px, header 44, + 24×24 always visible, opening a row closes the drawer
Assistant rail flat (0 group heads), rows 49.4/inset 32, active switch carries the same edge+tint, no words (no attached concept); empty note at px-3
Suite web typecheck + 132 tests green; cwd unit tests (7) green
ui-snap only the /chat+/chat/assistants family moved (desktop 0.5069%, over the 0.5% budget); sub-0.5% drift on other pages reproduces on an unmodified baseline (time-data drift); family baselines refreshed, re-run all green
Deployed box web image rebuilt + rolled; container assets contain session-row and the container index.html md5 equals the local build

Review addendum (2026-10-30) — the row's delete geometry

Desktop hover trash (#55 shell, SessionRow): the icon rides the row's VERTICAL CENTER (top-1/2 -translate-y-1/2), keeps the SAME right margin as the folder header's + (12px right-3), and sits inside a slot the row reserves for it — RailSessionRow carries md:pr-8 (32px, mirroring the 32px title LEFT inset), so the title's truncation point never shifts when the trash fades in and never collides with the status word (6px gap by construction). Phone: no reserve and no on-row trash (pr-3 stays; the swipe reveal owns delete). Measured on the §5c.3 rig: trash margin 12px, center delta 0.0px, title insets 32/32 desktop and 32/12 phone.

Review addendum 2 (2026-10-30) — the rail's header band joins the h-12 pin

Both chat rails' header bands (end switcher + refresh) HUGGED their content (py-2.5 + buttons at --control-h-sm), which made the band 52px in Signal and 45px in BAY against the stream column's FIXED h-12 (48px) header — the two "card headers" misaligned, differently per skin. Both bands now pin to h-12 (items-center, no vertical padding): the columns' bottom borders read as one line in every skin. Lesson generalized: a band that must match a fixed band must itself be fixed — never height-composited from the skin-scaled control tokens (--control-h* is content scale, not band scale). Verified Signal/BAY on both chat pages (48↔48, deltas 0.0).

Review addendum 3 (2026-10-30, #104) — status on the meta line; corners stay square

Two review findings against the shipped rail:

  1. The status word competed with the title. At a 288px rail a long title plus running on the same line truncated the thing the row exists to show. The word now sits at the right end of the meta line (relative time · short id), so the title owns the full width. The active row still shows running; open still only appears on non-current attached rows. Rig readout: the accessible row name is now title · meta · running (the word is the last text node), and the title line measures the full 236px content width with no reservation for the word.
  2. Square active fill vs the rounded plate key — no change. The primary nav's current item is an INSET row with the skin radius; the rail's current row is full-bleed. That scope split (menu key vs ledger row) is deliberate. A rounded-r-(--radius-well) variant was tried during review: it kept the edge at x=0 but the half-rounded tint read as a defect rather than a reconciliation, so it was reverted. The row is square in every skin; the rail's language is the edge + tint, not a card.

Clone this wiki locally