conduit · one session list for every project
mobile-first, with the settle lifecycle built in

The project switcher is gone. One list holds every session across every project, grouped by what it wants from you, filtered and scoped from the header, and parked with settle or snooze when it's done. This page is designed phone-first because conduit is a PWA you check from your pocket; the desktop layout is the same system with hover and keyboard added on top, never a different product.

Phone frames are 340 px wide, ~1:1 with a 390 pt device · Companion: the 25 desktop explorations

1The system, one screen at a time

Status tiers are the outer grouping because that is what the product is for: the list answers what wants me right now before it answers anything else. Scope, filter and group all live in the header. Settle and snooze are the only things that collapse a row, and both are reachable three ways: a swipe, a sheet, and a keyboard shortcut.

1 · rootAll sessions

Scope chip sits inside the search field, so the two controls that answer "which sessions" share one 38 px row. Filter chips scroll horizontally and scroll away with the list; search stays pinned.

Tap a row to open. Everything else is a swipe, a long press, or the ⋯ in select mode. New session is a 44 px circle, the touch floor and no more: it is a frequent action, not the loudest thing on screen, and it shrinks to a + in the header on desktop where the pointer is precise.

2 · the floorSnoozed and settled shelves

Scroll past Idle and the two parking shelves sit at the bottom, collapsed by default and remembered per device. Settled rows are 40 px, half-ink, glyph desaturated, and their time is when they settled. Rows the server settled say so: Auto · 3d.

A settled shelf costs seven pixels of chrome until you ask for it, however much is parked in there. It is never counted: see §2.

3 · swipe rightShort swipe offers, full swipe commits

A short swipe right holds a tray: Settle at the outer edge, Unread beside the row. Carry on past halfway and Settle takes the tray and commits on release. On an unread row the tray offers Read instead. A settled row's tray is Un-settle alone, so the gesture never means two things.

Every swipe has a twin in the long-press sheet and a keyboard shortcut. Nothing is gesture-only.

9:41✦ ▮▮▮
Right settles or marks unread, left snoozes (short swipe opens the preset sheet, full swipe uses your default). A row that is blocked on you refuses Settle and Snooze. Its right swipe holds Unread alone.
4 · long pressRow action sheet

Long press is the discovery surface: every verb the row supports, in one place, with its desktop shortcut shown so the two stay learnable together. The header repeats the row so you know what you are acting on.

The header gives the title its own two lines, then puts project, branch, length and last activity on a second row as separate objects, because a title squashed against conduit · main · 27 messages is unreadable at a glance.

The top group is the paired verbs, in the order every menu uses: Settle, Snooze, Pin, Mark unread, with Auto-settle when idle under Settle. Each flips in place. This row is unread, so it offers Mark read. On a settled row Settle flips to Un-settle, and Snooze and Mark unread disappear.

5 · snoozeWake-time presets

Presets are computed from now and show the wall-clock time they resolve to, so you never do the arithmetic. This evening only appears while it is meaningfully before 18:00. Anything that outranks the snooze, an approval, a question, a fresh error, or a turn finishing, wakes the session early.

You can snooze a running session. You can't snooze one that is blocked on you.

6 · undoEvery park is reversible

Settle, snooze, archive and delete all land a toast with a five-second undo, because all four remove a row from where you were looking. Mark unread lands one too, because it moves a row you may not be looking at. The toast sits above the safe area and never covers the last row.

On a phone the toast sits on the screen; on desktop, at the foot of the sidebar. Undo is also ⌘Z on desktop, for the same five seconds.

7 · select modeBulk settle

Long press then Select, or the ⋯ menu, puts the list in selection mode: checkboxes replace status glyphs, and a bottom bar carries the four verbs. This is how you clear a week of finished work without 30 swipes.

Disabled verbs stay visible at 35% rather than disappearing, so the bar never reflows.

8 · searchFull-screen, scoped, tokenised

Tapping search takes the whole screen, because on a phone a search field with a list under it is just a smaller list. Scope and filters become removable tokens above the results, so the state you're in is always spelled out rather than implied by a highlighted icon.

Results are flat and ranked, not tiered: when you search you already know what you want.

9 · filterFilter sheet

Status, project, activity and lifecycle in one sheet with live counts, and a single Reset. Filters are additive with the scope chip and both write to the URL, so a filtered list is a shareable link and the back button undoes a filter.

Settled and snoozed are lifecycle switches here, not statuses, because they cut across every status.

9:41✦ ▮▮▮
FilterReset
Status
Needs you 2Running 2Failed 1Done 1Idle 3
Project
All 18COconduit 8DFdotfiles 4PEperry 5HKhunk 1
Lifecycle
✓Include settled ☾Include snoozed1 ✓ ⇲Include archived
Activity
Any timeTodayThis weekOlder
10 · group & sortGrouping is a header menu

Status tiers are the default because they are the point of the product, but grouping is one tap away: none, project, or time. Sort is separate and deliberately static by default, so rows never move under your thumb while you read.

Group and sort both persist per device and go in the URL.

11 · settled, openedThe chip, and how you leave it

Opening a settled session says so in the bar: a state chip after the title. Sending a message un-settles it, which is the path most people take, so the composer says so. The chip is the explicit way out, for when you want the session back without saying anything yet.

There is no banner above the composer. The chip covers Settled, Auto-settled, Snoozed, Woke and Unread. A snoozed session's placeholder reads Message to wake…. Un-settling returns the session to the top of its tier and pauses auto-settle until there is new activity.

9:41✦ ▮▮▮
‹COWire PTY resize✓Auto-settled⋯
Message to un-settle…
11b · the chip, tappedThe popover is the whole story

When and why it settled, the verb that flips it, and the setting that did it, in one small menu anchored to the chip. The verb follows the chip: Un-settle, Wake now, or Mark read.

Auto-settle when idle is always here on a settled session, not a one-time offer. It is the same checkbox as the long-press sheet.

9:41✦ ▮▮▮
‹COWire PTY resize✓Auto-settled⋯
Message to un-settle…
11c · marked unread, from insideThe phone takes you back to the list

Mark unread in the session's ⋯, or ⌘⇧U, marks it and pops to the list. The toast lands there, next to the row it changed. The row is Done, unread: the same row fresh output makes.

Desktop keeps the session open instead, because the list is already on screen. See §5.

12 · wokeA snooze that ended

A woken session comes back in the slot it left, because the sort is static and moving it would be a second surprise. The Woke pill is the entire signal and survives until you visit. If it woke early, the pill says why.

Pills use saturated text on a tinted ground, never a light fill.

13 · the rewardNothing wants you

The empty state is the product's whole argument: settle exists so this screen is reachable. It names what is still running so "nothing wants you" never reads as "nothing is happening", and it offers the shelf you just filled.

Reached by settling, not by hiding. No auto-collapse, no "smart" truncation.

9:41✦ ▮▮▮
Sessions 18⇅⋯
All projects ▾⌕Search
Running2
Migrate zsh config to nushellDFdotfiles · main4m Refactor relay stackCOconduit · main9m
✓
Nothing needs you Two sessions are still working. You'll get a push when one of them wants an answer.
▸ Snoozed1
▸ Settled
+

2Scope, without the rail

The icon rail was a desktop habit. On a 340 px screen it costs 13% of the width permanently, carries no legible counts, labels projects with two-letter squares instead of names, and quietly rebuilds the container we just deleted. Seven replacements, judged on the same phone, and the decision is A with C living inside it.

Decided: A + C, one field, two ways in

The chip and the token are the same state rendered twice. Tap the chip and you get a picker; type project: and you get the identical token. Whichever you use, the result is a removable chip in the field and ?p=conduit in the URL, so the two can never disagree and nobody has to learn a syntax to use the product.

A+C · at restThe chip is the whole control

One 38 px row answers both "which sessions" questions. The chip is a word with a real target, not a glyph, and tapping it opens the picker with counts and live status.

Nothing here hints at syntax, because at rest nobody needs it.

9:41✦ ▮▮▮
A+C · focusedThe same field compiles what you type

Focus the field and the chip becomes the first token. Tapping a project in the picker writes project:conduit; typing it writes the same chip. Suggestions are grouped so the syntax is taught by use, never required.

Backspace on an empty field removes the last token, which is the same thing as tapping its ✕.

9:41✦ ▮▮▮
‹
COconduit ✕needs you ✕auth

Why the pair, and not either alone

  • A alone can't express "conduit, needs me, on feat/*, since Monday". You end up bolting a second filter language onto a picker.
  • C alone can't answer "which projects exist" at rest, and on touch it opens a keyboard to do the single most common thing.
  • Together the chip is the discoverable 80%, the token is the expressive 20%, and both write the same URL. Chips are the compiled form of tokens, which is the only way the two stay honest.
  • On desktop the same field takes / to focus and ⌘P to jump straight to the scope token.
One control, one state, two literacies.

The seven, for the record

option A · recommendedScope chip inside the search field

Scope and search are the same question asked twice, so they share one row. The chip reads as a word, not a glyph, and expands to a searchable picker with counts and live status dots.

9:41✦ ▮▮▮
Sessions 21⇅⋯
COconduit ▾⌕Search
The base of the pair

One row of chrome for both controls. The chip is a 34 px target with a readable name, it survives twelve projects because the picker scrolls and searches, and it collapses to the same combobox on desktop with ⌘P. Scope lands in the URL as ?p=conduit, so back undoes it.

Cost: one extra tap versus always-visible chips. Worth it: switching scope is rare, reading the list is constant.
option BProject chip scroller

The rail rotated 90°. Chips carry names and counts, one tap switches, and a dot marks projects that want you.

9:41✦ ▮▮▮
Sessions 18⇅⋯
⌕Search sessions
All 18COconduit 8 ⚠DFdotfiles 4
Needs you 2Running 2Unread 1
Runner-up

Zero-tap switching and honest counts, but it needs a second chip row for status filters and now 78 px of header is chips. Beyond five or six projects the useful ones live off-screen with no hint they exist, which is the rail's flaw with extra steps.

Use it if most people run three or four projects. Degrade to option A above six.
option CScope as a search token

There is no scope control. You type project:conduit, or tap a suggestion, and the scope becomes a removable token in the field.

Keep as a power layer

The most expressive option and the only one that composes freely, but on touch the keyboard eats half the screen and nobody discovers a syntax they were never shown. It also can't answer "which projects exist" at rest.

Verdict: ship it inside option A's search, not instead of it. Tokens are what the chips compile down to.
option DThe title is the scope

iOS Mail's pattern. No separate control at all: the screen title states the scope and taps open the picker.

Close second

The biggest target of the seven and beautifully quiet, and scoping frees the row's second line to show branch and model instead of the project name. But it pushes search behind an icon, and a 22 px title that changes identity is a strange thing to dock into a desktop sidebar.

Verdict: the right call if we ever ship a project-first mobile mode. Not the default when the point is one list.
option ENavigation drawer

Edge-swipe or hamburger reveals projects, saved filters and settings, Gmail-style.

No

It scales to fifty projects and everyone already knows it, which is exactly the trap: a drawer is a container, and a container is what we set out to remove. Within a week the drawer holds saved views and the list stops being one list.

Verdict: rejected on product grounds, not ergonomics.
option FSwipe between projects

Horizontally paged tabs, All first, one project per page, with a thin indicator.

9:41✦ ▮▮▮
Sessions⌕⋯
All conduit dotfiles perry
Horizontal paging owns the same gesture the row needs for settle and snooze. One of the two has to lose.
No

Fast and native-feeling right up until it collides head-on with row swipe, which is the primary verb of the whole lifecycle. Tab bars also cap out around five projects and hide the count that matters.

Verdict: disqualified by the gesture conflict alone.
option GNo scope control at all

Group by project when you want project structure; otherwise search. Scope is emergent, not a mode.

9:41✦ ▮▮▮
Sessions 18⇅⋯
⌕Search sessions
▾ CO conduit8 ⚠
⚠Fix auth token refreshfeat/authApprove Refactor relay stackmain9m
▸ DF dotfiles4 ?
▸ PE perry5
Fallback

The least chrome of any option and the collapsed headers keep every project's urgency visible. But "show me only conduit for the next ten minutes" becomes a round trip through the group menu, and status tiers, the thing the product is actually for, get demoted to a second-level grouping.

Verdict: this is option A with the chip set to All. Keep it as a grouping, not as the answer to scope.
Nothing counts the sets that grow without bound. This is a data-model rule before it is a design one. A number next to Settled or Archived can only come from one of two places: a scan of every session that ever existed, or a client that is holding them all. Today the sidebar does the second — it subscribes to the whole session set and filters in the browser — and that is exactly what stops working once a person has been using conduit for a year. Drop those two counts and the subscription can be bounded to the live set, which is bounded by how much work one person can actually have in flight. The settled and archived shelves then load only when opened, a page at a time, and there is no aggregate to maintain or invalidate on every settle. The product argument points the same way: settled is where work goes to stop asking, the set only grows, it never falls on its own, and nobody acts differently on seeing 41 rather than 44. Want to know how many? Open the shelf and scroll, which is the same cost as caring. So the shelf header is a caret and a word, the scope picker’s Settled row has no tail, Include settled and Include archived are checkboxes with no total, and nothing anywhere sums either. Everything that is counted answers how much is waiting for me and is bounded by construction: Needs you 2, Running 2, Done unread 1, Snoozed 1 (it wakes up on its own, so the count is a promise). All 18 means eighteen sessions you can act on across four projects, not the forty-odd rows in the database. Chips, scope rows and group headers all count the same way, so the number you see is always the number of rows you get.
Net: option A ships. Option B is a config we can turn on for people with few projects, option C lives inside A's search field as the power syntax, option G is one entry in the group-by menu. The rail does not come back in any of them.

3The row, rebuilt for a thumb

The old row assumed a pointer: its actions lived on hover and its height was tuned for a 272 px sidebar. This one is designed at 52 px on a phone and subtracts to 52 px on desktop, where hover and keyboard add affordances that touch reaches by gesture and sheet instead. No action exists on one platform only.

①② title · flags③ verb or time
⚠Fix auth token refreshCOconduit · feat/auth · opus#214Approve
④ provenance⑤ 52px · whole row is the target
① Status glyph, 20 px. Exactly one, by rank: approval ▸ input ▸ error ▸ working ▸ done-unread. A row you mark unread by hand joins done-unread: same ✓, same Done pill, no second dot. Idle has none and the row recedes to 62%. Colour is never the only carrier; the glyph shape differs too.
② Title. 14.5 px, single line, ellipsis at the end because the front of a title is where the meaning is. Unread lifts it to full ink and 600 weight. Fork ⑂ and pin ⚑ sit after it.
③ Trailing verb. The one thing the session wants, as a word: Approve, Reply, Done, Failed, Woke. Actions needed use a filled pill; everything else is plain text.
④ Provenance. Project square, project, branch, model, PR. Truncates from the right and drops fields as width shrinks: PR first, then model, then branch. Project never drops while scope is All.
⑤ Target. The entire 52 px row opens the session, which is the only action reachable by tap. Everything destructive needs a swipe, a long press, or a deliberate second tap in select mode.
Markup: one <a href> per row, never a div with a click handler, so long-press-to-share, middle-click and drag-to-another-window all work for free.

Every state, at phone size

Failed · loud, never red-on-red
!Try session cookiesCOconduit · fix/authFailed
Done, unread · dot clears on open · also what Mark unread makes
✓Add session exportCOconduit · main · sonnetDone
Open · brand inset, project implied
Refactor relay stackCOconduit · main9m
Woke · original slot, pill until visited
☾Audit brew bundleDFdotfiles · mainWoke
Settled · 40px, half ink
COWire PTY resize#2123d
Settled, pressed · full ink, PR colours
COWire PTY resize#2123d
Short swipe · tray held
Full swipe · Settle takes the tray
Unread row · tray offers Read
Settled · tray is Un-settle alone
Blocked on you · tray is Unread alone
Mid-swipe · snooze
Desktop hover · actions replace the time
Investigate flaky e2ePEperry · main · sonnet●✓☾⚑⋯
Desktop settled hover · un-settle
COWire PTY resize#212↶⋯

Touch rules the row obeys

  • 52 px row, comfortably over Apple’s 44 pt floor, and the whole row is the target rather than the title. Settled and snoozed rows sit at 40 px: a deliberate density trade in a collapsed shelf you visit rarely, still well clear of the 24 px WCAG minimum.
  • No control smaller than 34 px inside a row. That is why there are no per-row buttons on mobile at all: they would be 24 px or they would eat the title.
  • Swipe thresholds: 25% reveals and holds the tray, 55% commits on release. Right holds Settle at the outer edge and Unread next to the row; a full swipe settles. Below 25% it springs back, so a scroll that drifts sideways never settles anything.
  • Every gesture has a sheet twin and a keyboard twin. Gesture-only actions are treated as unshipped.
  • Undo on every removal, 5 seconds, same toast on both platforms.

What the row refuses to do

  • No avatars, no message previews, no progress bars. The last assistant line is a hover card on desktop and a peek on long press, never a third line that shifts on every token.
  • No relative time that ticks. Times update on a 60 s interval and never re-sort the list.
  • No colour-only status. Each glyph has its own shape for the 8% who won't see the hue.
  • No row-level animation on arrival. New rows appear; they don't slide, because the list is something you read while it changes.

Accessibility

  • Row is a link with an aria-label that reads status first: “Needs approval, Fix auth token refresh, conduit, 2 minutes ago”.
  • One aria-live="polite" region for the whole list, announcing only tier-count changes, so a working session doesn't chatter.
  • Swipe actions are exposed as an action menu to VoiceOver and as <button>s to keyboard users.
  • Focus ring is the brand inset, identical to the open state, so keyboard position is never ambiguous.
  • prefers-reduced-motion drops the swipe spring and the spinner's dash rotation.

4Every interaction, on every input

The rule the table enforces: three ways, always. Anything you can do to a session is reachable by a gesture, by a visible control, and by a key. If a row only has an action on hover it does not exist on a phone, and if it only has a swipe it does not exist to a keyboard.

ActionTouchPointerKeyAlso
Open sessionTap rowClick row↑ ↓ then ↵, or ⌘1–9Push notification, search result
New sessionFAB, bottom right+ in header⌘NEmpty state, ⌘K
Search titlesTap the field, goes full screenClick the field/ or ⌘FURL ?q=
Search conversations“Search inside conversations” under resultsSame, or ⌘K⌘KFull-text across projects
Scope to a projectScope chip → picker sheetChip → popover, type to filter⌘Pproject: token, URL ?p=
Filter by statusChip row, or the filter sheetChip row⌘⌥1–5is: tokens, URL ?f=
Group & sort⇅ in header → sheet⇅ menu⌘⌥GURL ?g=, per-device default
SettleFull swipe right, or short swipe → Settle, or long press → SettleHover ✓, replaces the times or ⌘⇧SBulk bar, drag onto the Settled header, auto after 3 days unless you turn off Auto-settle when idle. Settling keeps unread.
Un-settleSwipe right on a settled row; the tray is Un-settle aloneHover ↶s or ⌘⇧SState chip in the session bar, or send a message. Pauses auto-settle until new activity. An unread session comes back unread.
SnoozeSwipe left → presets; full swipe uses your defaultHover ☾ → presetszLong-press sheet, bulk bar
Wake nowSwipe a snoozed rowHover ☾✕zState chip, or send a message. Automatic on approval, question, fresh error, or turn end
Pin / unpinLong press → PinHover ⚑p or ⌘⇧PDrag into the Pinned shelf
Mark unreadShort swipe right → Unread, or long pressHover ●, ⋯ or right-clicku or ⌘⇧USession ⋯. A phone goes back to the list; desktop stays and holds it unread. Offered on rows that need you, not on settled or snoozed rows.
Mark readShort swipe right → Read, or long pressHover ○, ⋯ or right-clicku or ⌘⇧UThe state chip's popover, while a session is held unread. Opening the session does it too.
Auto-settle when idleLong press → checkbox under Settle⋯ or right-click → checkboxNone, it is a settingState chip popover on a settled session. On by default, per session.
RenameLong press → RenameDouble-click the titler on a focused rowRegenerate title in the same sheet
Fork from hereLong press → Fork⋯ menufFrom a message in the transcript
ArchiveLong press → Archive⋯ menueLeaves the list entirely; lives in Settings → Archived
DeleteLong press → Delete, in red⋯ menu⌫Confirm, then a 5 s undo
Select severalLong press → Select, then tap rowsClick a checkbox, ⇧-click for a rangex toggles, ⇧↑↓ extendsBulk bar carries settle, snooze, pin, archive
UndoToast, 5 secondsToast, 5 seconds⌘ZCovers settle, snooze, mark unread, archive, delete, bulk
Collapse a shelfTap the section headerClick the header← → on the headerRemembered per device
Reorder manuallyLong press → dragDrag the row⌥↑ ⌥↓Only in manual sort; verb badge names the drop
RefreshPull down—⌘RThe list is live; this is for reassurance
Back outEdge swipe, or ‹ SessionsClose in the session header; ‹ All sessions when the list is collapsedesc or ⌘[Browser back too, as a second route: filter, scope and group all write history

Where settle can be reached

  • Full swipe right on the row, or a short swipe and the tray.
  • Long-press sheet, first item.
  • s with the list or transcript focused, ⌘⇧S from anywhere.
  • Hover ✓ on desktop, in the time's slot.
  • Bulk bar, for a selection.
  • Drag onto the Settled header, desktop only.
  • The server, after 3 idle days or a merged branch.
Seven surfaces for one verb is not redundancy, it is the difference between a feature people use and a feature people are told about.

Single keys, when the list or transcript has focus

  • j k or ↑ ↓ move · ↵ opens
  • s settle / un-settle · z snooze / wake
  • p pin · u unread / read · e archive · x select
  • f fork · / search · g then s jumps to the settled shelf
  • ⌘⇧U and ⌘⇧S work from anywhere, including the composer.
  • ? opens the full sheet, which is also how anyone discovers this list
Gmail's alphabet, because the people who want it already have it in their fingers.

What has no touch equivalent, and why that's fine

  • ⌘1–9 jump. A phone has no modifier to hold; the equivalent is that the top nine rows are already one tap away.
  • Hover peek of the last line. Long press shows the same peek above the sheet.
  • Drag between shelves. Long-press drag does it on touch, in the same arrange mode as manual reorder.
Nothing in this column removes an action, only a shortcut to one.

5Desktop is the same system, wider

Same rows, same tiers, same shelves. The sidebar is 300 px instead of full-bleed, rows lose 6 px of height because a pointer is precise, and three things switch on: hover actions in the trailing slot, the ⌘-held jump pills, and drag between shelves. Nothing appears here that a phone cannot reach another way, and nothing here is reachable only through the browser chrome: the session has a visible Close, the list has a visible Back.

Settling and snoozing with a pointer

Four routes, in order of how often they are used: the hover strip, the right-click menu, the keyboard, and drag. Every one of them carries the same verbs as the phone's swipe and long-press sheet, so learning either platform teaches the other.

1.  Hover strip · the common case

Five icons replace the timestamp on hover: mark unread, settle, snooze, pin, more. On an unread row the first is ○, Mark read. Each carries its shortcut in the tooltip, which is how the keyboard gets learned: the ● tooltip is where u is taught. The PR badge never moves, because it is information rather than chrome.

2.  Snooze opens in place

Hovering ☾ opens the presets under the cursor with their resolved wall-clock time. Clicking ☾ without waiting applies your default and shows the undo toast, so the fast path never costs a menu.

3.  Right-click is the full set

Identical order and identical wording to the phone's long-press sheet. The paired verbs lead: Settle, Snooze, Pin, Mark unread, with the auto-settle checkbox under Settle. A session that is blocked on you shows Settle and Snooze greyed with the reason on hover, rather than silently doing nothing. It still offers Mark unread.

4.  Keyboard and multi-select

Click a row to open, ⇧click for a range, ⌘click to add one. j / k move a focus ring without opening anything, then s settles, z snoozes, p pins, u marks unread and x selects. Every bulk action produces one undo toast, and ⌘Z reverses the whole batch.

Drag is the fourth route, and the only optional one

  • Drag a row onto the Settled or Snoozed header to park it; the header expands under the cursor after 500 ms so you can see where it lands.
  • Drag onto Pinned to pin and position in one motion, which is the one thing the keyboard cannot express as cheaply.
  • Dropping on a project in the scope picker does nothing: sessions do not move between projects, so the target refuses rather than silently failing.
  • Drag is never the only way to do anything, because touch has no equivalent that survives a scrolling list.

Leaving a session without the browser chrome

  • Close sits in the session header with esc on it. It deselects and returns the pane to the empty state, and it is a real button because an in-app action should never require the browser's UI.
  • ⇤ collapses the list for focus. With the list hidden the header's first control becomes ‹ All sessions, so there is always a labelled way back.
  • Browser back still works, and always has: the session, the scope, the filters and the group are all in the URL. Back is the second route, not the first.
  • ⌘[ and ⌘] walk history inside the app, matching the browser without reaching for it.
If the only way out is the browser's back button, the app is a document, not a product.

What hover may own

  • The trailing slot only. Actions replace the time and the PR badge stays, because the PR is information and the time is not, once you're reaching for a button.
  • The last-line peek card, after 400 ms.
  • The ⌘-held jump pill, which overlays rather than reflows.
Hover never owns the only path to anything.

Responsive ladder

  • < 560 px full-bleed list, sheets, swipe, FAB. The session view is a separate route, not a pane.
  • 560–900 px list stays full-bleed but rows drop to 48 px and hover actions appear if the device reports a fine pointer.
  • > 900 px 300 px sidebar beside the session, both live, ⌘1–9 active.
  • > 1400 px sidebar may be dragged to 380 px and gains a column for branch and model.
Capability is detected with pointer: fine and hover: hover, never inferred from width.

PWA specifics

  • List is the start URL, so a cold launch lands on “what wants me”.
  • Push notification per approval and per question, tapping opens that session directly.
  • Badge count on the app icon = sessions that need you, never total unread.
  • env(safe-area-inset-bottom) on the FAB, bulk bar, toast and composer.
  • Virtualised past 50 rows; the settled shelf pages at 10 + 25.
  • Offline: the list renders from the cached projection and rows that can't act say so rather than failing on tap.

6Pills, buttons and the contrast rule

The old page had amber text on a near-white pill. That was not a palette choice, it was a bug: the drag badge's .verb rule painted a light background, and a more specific row rule then repainted only the text with a status colour. Two rules, each sensible alone, colliding into 1.6:1 contrast. The fix is a real system, not a one-off patch.

What it looked like

Approve1.7:1 Reply2.1:1 Done1.9:1
  • Light fill, light text, unreadable in sun and unreadable at 12 px.
  • Worse on a phone, where the screen is dimmer relative to ambient light and the pill is the thing your thumb aims at.
Every one of these fails WCAG AA for small text by a factor of two or more.

What replaces it, three tiers

Approve11.8:1 Reply9.4:1 ✓ Done Woke 3 selected
  • Loud — saturated fill, near-black ink (#0c0d10). Identical in both themes, because a fill is not a surface. Only for “this session is waiting on you”. At most two on screen.
  • Quiet — coloured text, no fill. Status you should notice but not act on.
  • Tint — surface fill, hairline border, secondary ink. Chips, counts, metadata, anything neutral.
A pill is loud, quiet or tint. There is no fourth option and no ad-hoc inline colour.

The rules that keep it from breaking again

  • Fill and ink are set together. A rule may never change one without the other. That single rule would have prevented the bug outright.
  • Status colours are ink-first. Amber, indigo, red and green are defined for use on a surface, so each theme owns its own value for them (§7). Using one as a background switches to the fill token and flips the ink to #0c0d10 automatically via the .loud modifier, in both themes.
  • No status colour is ever a light fill with its own hue as text.
  • Minimum 4.5:1 for anything under 16 px, checked in CI against the token pairs, not eyeballed.
  • Indigo was lightened from #7c7cff to #9b9bff so quiet indigo text clears AA on the dark surface. Light mode darkens the same token to #5344d4 instead: one name, two values, never a second class name.
Touch targets follow the same discipline. A pill that is also a button gets 44 pt of hit area even when it draws at 22 px, via padding or a pseudo-element. A pill that is only a label is never tappable, so it never invites a tap that does nothing.

7One light theme, one dark theme

The design-system migration deletes every custom theme. What ships is two: one light, one dark, both built from the same token names so no component ever branches on which one is active. This page has the switch in its header, because the only honest way to review a light mode is to put the whole document in it.

Both panes above are the same markup with one attribute different, rendered live and side by side regardless of which theme the page is in. That is the test: if a component needs a second class name to look right in light mode, the token set is wrong, not the component.

The rule: a token is a surface or it is a fill

  • Surfaces and the ink on them move. --bg, --surface, --text, the borders, and every status ink token get a value per theme. Amber text has to be dark on white and light on near-black; that is the whole job.
  • Saturated fills do not move. --amber-f, --indigo-f, --brand-f and the project colours are the same bytes in both themes, and --onfill stays #0c0d10 on top of them. An Approve pill is the same object in both themes, which is why it stays the loudest thing on the screen in both.
  • Nothing else exists. No --theme-accent-3, no per-user hue, no .dark class to remember to add. Two blocks of custom properties, roughly thirty lines, and the rest of the stylesheet never mentions a theme.
One name, two values. A component that reads var(--amber) is already correct in both.

Surfaces and ink

tokendarklight
--bg0d0e11f5f6f9
--surface141619ffffff
--alt1b1e24eef0f5
--border20242be2e6ec
--border22d323bccd2dc
--texteceef214171c
--text2a8afbb4b525e
--dim79818f6c7481

Light mode is not dark mode inverted. --bg is the slightly grey f5f6f9 and --surface is pure white, which is the opposite ordering to dark mode, where the page is darker than the panels. Cards have to sit above the page in both, and on light that means lighter, not darker.

Status ink, per theme

tokendarklight
--amberf4b7408a5500
--blue5aa9f50f62c0
--green3fd69a0c7a51
--redf4607ac11d40
--indigo9b9bff5344d4
--violetc0a8ff6a3ed4
--brandff2d7bc9004f

The brand splits in two. #ff2d7b on white is 3.3:1, so it cannot be text there. --brand is the ink and darkens to #c9004f; --brand-f is the fill and stays #ff2d7b in both. The FAB is the same pink in light mode; the word “Reset” is not.

The four things light mode actually breaks

  • Shadows. rgba(0,0,0,.5) under a sheet reads as depth on near-black and as dirt on white. Two tokens, --sh and --shx, drop to about a seventh of the opacity and pick up a blue cast (rgba(19,26,37,.14)) so the shadow belongs to the same grey family as the borders.
  • Glass. The floating island and the floating button are rgba(24,25,29,.9) over a blur. In light they become rgba(255,255,255,.88). Same blur, same border token, opposite base — hard-coding either one is the bug.
  • Scrim. A modal scrim is not black at lower opacity; on light it is a cool grey at .34, because a true black wash makes the page under a sheet look switched off rather than backgrounded.
  • Deliberate inversions. The undo toast and the keyboard tooltip are meant to be the opposite of the page. They ride --inv / --inv-ink, which swap between themes rather than shifting. Miss this and the toast quietly becomes invisible in one of the two.
All four are the same failure: a colour written as a literal instead of a token.

How a person gets each one

  • Default is the OS. First load reads prefers-color-scheme, and the page tracks OS changes live until someone picks a side explicitly.
  • The choice is sticky and local. Picking light or dark writes one key and stops the OS from overriding it. Nothing is stored server-side, so a device can differ from another device, which is what people actually want between a phone in the sun and a desktop at night.
  • No flash. The theme is set from a tiny script in <head> before first paint, not by a component after hydration. A dark-mode user loading a light flash is a bug report every time.
  • color-scheme is set with the tokens, so scrollbars, form controls and the browser's own UI match without any extra CSS.
Why two and not seven. Custom themes looked free and were not: every one of them is a surface nobody tests, a contrast pair nobody checks, and a support question nobody can reproduce. Two themes means the contrast matrix is small enough to check in CI on every token pair, and every screenshot in this document is reviewable in both with one tap. Anyone who wants a third is really asking for a bug we cannot see.

8Density, four ways

Today's sidebar is small and works. The first draft above over-corrected: 60 px rows look generous on a design page and waste a third of a phone screen in real use. Here are the same sessions at four heights, with how many rows each fits on a 390 × 844 device once the status bar, header, search field, chip row and home indicator are taken out, leaving 629 px of list.

40 pxSingle line

Meta collapses into the title line as a project glyph, and the trailing slot keeps the verb. Nearest to what conduit does today.

15 rows on a 390×844 screen

Costs: no branch, no model, no PR. You can see everything and identify nothing.

44 pxDense

Two lines at 13.5 / 11 px. Exactly the touch floor, so every row is still a legal target with nothing to spare.

14 rows on a 390×844 screen

Costs: the meta line is at the edge of comfortable reading, and swipe feels twitchy at this height.

52 pxDefault

Recommended. Two lines at 14 / 11.5 px, 8 px over the touch floor, and the status glyph gets its own 20 px column without crowding.

12 rows on a 390×844 screen

Gains: swipe has room to read as a gesture rather than a slip, and the trailing pill never collides with the title.

60 pxRoomy

The first draft. Handsome in isolation, and you lose two sessions a screen for padding you did not ask for.

10 rows on a 390×844 screen

Costs: on a 5.4″ phone the settled shelf falls below the fold on a list of twelve.

What ships signed off

  • 52 px default, and a Dense toggle in Settings that switches the list to 44 px. Two options, not a slider.
  • Settled and snoozed rows sit at 40 px in both modes. A shelf you visit rarely can afford to be tight.
  • Section headers drop from 34 to 26 px and lose their top margin when Dense is on, which is where most of the saving actually comes from.
  • The 40 px single-line variant is not offered. Once you drop the meta line you cannot tell two sessions in the same project apart, which is the exact problem a cross-project list creates.

Where the pixels went

  • Row padding 10 → 7 px, title 14.5 → 14 px, meta 12 → 11.5 px, meta gap 3 → 2 px.
  • Nothing was removed and no font dropped below 11.5 px, which is the smallest size this palette still clears AA at.
  • The status glyph column stayed at 20 px. It is the one thing you scan first and the last thing that should shrink.
Two rows per screen recovered, no information lost.

10What we actually build

The recommendation, in the shape the earlier exploration landed on, now mobile-first: D5's shelved container, with the rail replaced by a scope chip inside the search field, D1's header search, D9's ⌘K for deep search, and the rebuilt row. Group-by is a header menu. Status tiers stay the outer grouping, because reacting to what wants you is what the product is for.

2026-09-27 · Mark unread and Un-settle, decided. Picked from unread-and-unsettle-designs.html and written into §1, §3, §4, §5, §9 and this section. A hand-marked row is Done, unread, with nothing new to learn. A short swipe right holds a tray of Settle and Unread. The hover strip is ● ✓ ☾ ⚑ ⋯. An open session that is settled, snoozed or held unread shows a state chip after its title, and the banner above the composer is gone. Settling keeps unread. s settles everywhere.

Backend

  • SessionInfo gains projectSlug, projectTitle, status, branch, model, prNumber, prState.
  • Lifecycle fields, ported from t3code: settledOverride, settledAt, unsettledAt, snoozedUntil, snoozedAt, pinnedAt, pinOrderKey, activeOrderKey, archivedAt.
  • A cross-project listSessions on the daemon, not per-relay, reading the projection across every registered project's store.
  • A settlement sweep in the daemon, running with no browser open: settle after N idle days (default 3, 1–90, null = never) or when the branch's PR merges or closes.
  • Guards, never settle while: awaiting approval or a question, starting or running, a background process is alive, a turn is queued inside a 2-minute grace, manually un-settled with no new activity since, snoozed with time remaining, unread, or Auto-settle when idle is off for the session.
  • Unread stays read_at: opening a session sets it, session.unread clears it. A new MarkSessionRead RPC serves Undo and Mark read by hand. No new column: a hand-marked row is Done, unread. Settling does not touch read_at, so an unread session un-settles unread.
  • Wake triggers: approval, question, a fresh error, or a turn completing after the snooze began.
  • Push per approval and per question; app badge = sessions needing you.

Frontend

  • One sidebar component, one row component, both driven by a derived tier + shelf projection.
  • Mobile is a route, not a drawer (§9 V1): / is the list, /s/<id> is the session, back is back.
  • The session bar is three bands, open by default (§9, bar 18): band one is ‹ Sessions with an amber badge, the meta line and ⋯; band two is the title at 14.5 px with its ▾; band three is the view switcher. Desktop is the same bar on one line and never collapses.
  • It only collapses while you are pinned to the bottom (§9, bar 18): one 46 px row of back, the full title, ⌄ and ⋯, with no mode glyphs. The state is atBottom (within ~24 px), not scroll direction, so it cannot jitter or strand you half-collapsed; scrolling up at all restores the bar and ⌄ forces it open until you next return to the bottom. The ⋯ menu leads with the same four views, so modes are never more than one tap away.
  • Nothing counts settled sessions (§2): shelf headers, scope pickers, filter rows and badges count only what is waiting for you. Settled has no count on any surface, which also means no unbounded aggregate to maintain or invalidate, and the shelf can load lazily when it is opened.
  • Terminal, diff and files are views, not buttons (§9, bar 7): a segmented switcher carries them with per-view badges, so the next view costs nothing. Verbs live behind the title's ▾ (bar 8), which is the same menu as desktop right-click. Layout rule: project and branch are placed first, the title is the only string allowed to ellipse (bar 14), and it may not ellipse below a 110 px floor.
  • On desktop, tabs are also panes (§9, bar 17): ⊞ after the switcher, or ⌘⇧D, lights a second tab and splits the content area. Each pane gets a 26 px header with its own close, the split is remembered per session, and below roughly 420 px of pane width it folds back to tabs on its own. The bar does not change: side by side is a property of the content, not a second chrome.
  • 52 px rows by default, 40 px for settled, with a Dense toggle at 44 px (§8). Desktop drops to 46 px because the pointer is precise.
  • Scope is one field with two literacies (§2): the chip opens a picker, typing project: writes the same token, both compile to ?p=.
  • Nothing depends on browser chrome. The desktop session header owns Close (esc) and a list toggle that becomes ‹ All sessions when the list is hidden; browser back and ⌘[ keep working as the second route.
  • Settle and snooze with a pointer (§5): the hover strip ● ✓ ☾ ⚑ ⋯, right-click menu, s / z / p / u, and drag to a shelf. u and s work with the list or transcript focused; ⌘⇧U and ⌘⇧S work from anywhere, including the composer. Same verbs as the phone's swipe and sheet, same wording.
  • One verb order everywhere (§1, §5): Settle, Snooze, Pin, Mark unread, with Auto-settle when idle under Settle. Sheet, right-click, session ⋯ and tray all follow it. The verb flips with the state: Mark read on an unread row, Un-settle on a settled one.
  • The swipe tray (§1, §3): a short swipe right (25–55%) holds Settle at the outer edge and Unread next to the row; a full swipe settles. Settled rows get Un-settle alone; blocked rows get Unread alone.
  • Mark unread from inside a session (§1, §9): a phone goes back to the list and shows the toast there. Desktop stays and holds the session unread until you open another session or send a message. The hold lives in sessionStorage, per tab, so it survives a reload and never reaches the URL.
  • A state chip, not a banner (§9): after the title, for Settled, Auto-settled, Snoozed, Woke and Unread. Its popover holds the flip verb, when and why it settled, and the auto-settle checkbox. Collapsed, it is a glyph. The composer placeholder reads Message to un-settle… or Message to wake…. The Unread chip in the filter row counts live rows only.
  • URL is the state: /s/<id>?p=conduit&f=needs&g=project&q=auth. Back works, links share, refresh restores.
  • Rows are <a href>. Swipe is a pointer-event overlay that yields to the link on a tap and to the scroller on a vertical drag.
  • Two themes, not seven (§7): light and dark from one token set, default from prefers-color-scheme, set before first paint, persisted per device. Custom themes are deleted, not migrated.
  • The sidebar subscribes to the live set, not every session (§2): nothing sums settled or archived, because a count over an unbounded set means either a full scan or a client holding every session that ever existed. Those two shelves page in when opened. Every remaining count is bounded by construction, and equals the number of rows the control produces.
  • Virtualised past 50 rows; settled shelf paginates 10 then 25.
  • Sort is static while you look at it. Rows never move under a thumb; changes land on navigation or on an explicit refresh.
  • One aria-live="polite" region for the whole list.

Order of work

  • 1. Cross-project list + the new row. Ship with existing behaviour, no lifecycle. Immediately useful.
  • 2. Scope chip, status chips, group-by menu, URL state.
  • 3. Manual settle, pin, Mark unread and read, undo toast, shelves, swipe with its tray, the long-press sheet, and the state chip.
  • 4. Snooze with presets and wake triggers.
  • 5. Server-side auto-settle and the PR watcher, with the chip popover's Auto-settle when idle checkbox.
  • 6. ⌘K deep search, ⌘1–9, single-key shortcuts, select mode.
Each step is shippable alone. Nothing here needs the project switcher to survive.
The one-sentence version. Every session you own, in one list, sorted by what it needs from you; anything finished quietly leaves on its own, anything you want back later comes back on a schedule you chose, and every verb is reachable by thumb, by pointer and by key.