Repository navigation
design session rail
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/webonly — CSS tokens, BAY skin,AgentSession.tsx,AssistantSession.tsx, a sharedRailSessionRow, a smalllib/cwd.tshelper, chat strings. No server/sdk/core/shared changes.
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.
- Active row: 2px
--signalleft 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.
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
titlecarries 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/projectsshows/root,/rootshows 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 inkit/well.tsx) keeps it visible; a coarse pointer gets a 24×24 hit area, and the toggle reservespr-7so 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.openis only shown on a non-active attached row — on the current row the edge + tint already say it.runningshows 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 idat 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 andcursor-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.
- 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
--borderline 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-2topx-3because the container no longer supplies the inset.
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 showsopen. 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 sharedRailSessionRow, so the two cannot drift.
| 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.
| 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 |
| 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).
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.csshtml.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.
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);
}Both pages render their rail in the same phone drawer. The machine rail allows two device deltas:
- 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. -
+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 askit/well.tsx); the hit area grows on coarse pointers ([@media(pointer:coarse)]:p-1.5); the toggle reservespr-7so 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.
- 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 intitle. The 14px icon is not the only hit area. -
+: a sibling button (never nested in the toggle), labeled with the existingchat.newSessionHerecopy (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.
| 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.
-
pnpm --filter @harness-nexus/web run typecheckandrun test. -
scripts/ui-snap.mjs: the matrix's/chatlands 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. - 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 activergb(242,244,247), edgergb(239,91,19), wordsrgb(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.
- Machine rail, desktop 1440, Signal light/dark and BAY: active row switch;
non-active hover; header hover (
- Node 22.19 quartet (
task verify) before any push.
- 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-0variants (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运行中.
- Primary nav (spine/plate). No changes.
- Session data, sorting, grouping, title sync (#94 is separate).
- Windows path handling:
cwdBasename/cwdParentsplit on/(the existing helper does too); the host-side listing is a separate concern (#43). - BAY dark mode (BAY ships light only).
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 |
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.
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).
Two review findings against the shipped rail:
-
The status word competed with the title. At a 288px rail a long title
plus
runningon 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 showsrunning;openstill only appears on non-current attached rows. Rig readout: the accessible row name is nowtitle · 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. -
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.