Skip to content

UI overhaul: session previews, group spawning, and a Linear-ward design pass - #8

Merged
karngyan merged 3 commits into
mainfrom
worktree-linear-overhaul
Aug 9, 2026
Merged

UI overhaul: session previews, group spawning, and a Linear-ward design pass#8
karngyan merged 3 commits into
mainfrom
worktree-linear-overhaul

Conversation

@karngyan

@karngyan karngyan commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Five screens had each grown their own spelling of the same thing, on top of shadcn defaults nobody chose. This is one pass over all of it, plus the two things the sessions list was missing.

Session previews

Hovering a row shows the last fourteen lines it drew.

A new peek control message returns the tail of a session's scrollback without attaching to it — no ref, no stream, no subscriber left behind, and no touch of lastActive. That last one matters more than it sounds: the default ordering is by that stamp, so a preview that counted as activity would sort the row under your pointer to the top of the list beneath it.

The bytes come back raw, escape sequences and all, because rendering is the client's job and the client already owns an emulator. It does not use that emulator — a canvas and a WebGL context per row is not what a hover card is for. Instead web/src/sessions/preview.ts is a small VT interpreter: a grid, a cursor, and the sequences that decide where text lands. That is the difference between a preview and a mess — a strip-the-escapes reader renders a redrawing TUI as a dozen stacked copies of itself.

Nothing is asked for until the pointer has rested (400ms), so crossing twenty rows costs twenty nothings. peek is the one request on the client with a deadline of its own, because a daemon older than the page answers it with an uncorrelated error{bad_message} that reaches no asker.

Group spawning

Every heading names a property its rows share, so the + on it makes one of those:

grouped by + does
machine spawns on that machine
tag spawns, then applies the tag once the session exists
directory spawns in that directory
state — Running a plain new session
state — Exited nothing; the control is not offered

spawnFromGroup is a pure function, so the rule is arguable in a unit test rather than by clicking headings.

Daemon restarts and Claude Code

A revived session's seam now names the command that picks the interrupted conversation back up:

── daemon restarted · previous shell ended here ──
pick the Claude session back up with claude --resume 6f1c2b90-…

Only when there is evidence of one — a transcript in that project's directory written during the session's own life, which is the whole filter and the reason this is safe to print. Printed, never run: the id is a heuristic, and typing into somebody's shell on their behalf is a different kind of act from restoring what they were looking at.

The design pass

Four decisions, each visible on every screen:

  • 13px controls, set once as a token, so a menu item and the button beside it cannot disagree. At 14px the label was the loudest thing in a toolbar of short verbs.
  • 8px radius (6px for buttons), down from 10px. On a 28px control a 10px corner is more than a third of the height.
  • An elevation scaleshadow-low/medium/high plus an inset lip on the one filled button. Overlays used to be bordered with the same hairline the list is ruled with, so they read as pasted on rather than over.
  • Focus as an offset outline, not a 3px translucent ring, which glowed and bled between the halves of the split button.

Screen by screen:

  • Devices was a four-column table, three of whose columns were one short string, with a header row naming them — and on a phone it scrolled sideways. It is now rows in the sessions list's own language, shedding "paired when" first and keeping "last seen", since that is the one you check before revoking.
  • Remote access was four states in four different containers (two dashed Empty blocks, two Cards), which read as four screens sharing a heading. It is now one state panel plus a named Integrations section — the shape has to survive a second integration.
  • Settings was a heading over a sentence promising settings would arrive. It now names the six things planned, each marked Coming soon. No dimmed toggles: a control that looks operable and is not is worse than no control.

README

Short, and honest about what flue is — a session manager for every machine you own, not a terminal in a browser tab. The dev-loop prose moved to docs/DEVELOPMENT.md, which already covered it.

Two bugs found in this work

  • The group + rendered without an accessible name for groups that refuse one: a button a pointer could press and a screen reader could not announce. The test that "covered" it queried by name, so it passed while the button sat there. It now counts.
  • The preview card refetched on every fleet poll, because the fleet hands back fresh row objects every three seconds and the row was in the effect's dependencies. On screen: a card that blinked while you looked at it.

Verified

make test — Go, 952 web tests, 90 relay tests. make lint clean. Driven end to end in a real browser against a real daemon: previews render live terminal output, both themes, every screen.

🤖 Generated with Claude Code

…gn pass

The app had grown five screens that each spelled their own version of the
same thing, on top of shadcn defaults that were never chosen. This is one
pass over all of it, plus the two features the sessions list was missing.

**Previews.** A new `peek` control message returns the tail of a session's
scrollback without attaching to it — no ref, no stream, no subscriber, and
crucially no touch of `lastActive`, since the default ordering is by that
stamp and a preview that counted as activity would sort the row under the
pointer to the top of the list beneath it. The web side renders those raw
bytes through a small VT interpreter (`sessions/preview.ts`) rather than
stripping escapes: a strip-the-escapes reader shows a redrawing TUI as a
dozen stacked copies of itself. The card hangs off each row on hover, opens
after a rest so a pointer crossing twenty rows costs nothing, and refreshes
while it is open.

**Group spawning.** Every heading names a property its rows share, so the `+`
on it makes one of *those*: on a machine's heading it spawns there, on a
tag's it applies the tag once the session exists, on a directory's it spawns
in that directory. `spawnFromGroup` is a pure function so the rule is
arguable in a unit test, and it is what decides that "Exited" has nothing to
offer.

**Daemon restarts.** A revived session's seam now names the command that
picks the interrupted Claude Code conversation back up, when there is
evidence of one — a transcript in the project directory written during the
session's own life. Printed, never run: the id is a heuristic and typing
into somebody's shell on their behalf is a different kind of act.

**Design.** 13px controls on a token, 8px radius (6px for buttons), an
elevation scale that makes overlays read as over rather than pasted on, and
focus as an offset outline instead of a 3px translucent halo. The Devices
table became rows in the sessions list's own language; Remote access became
one state panel and a named Integrations section; Settings became a list of
what is planned rather than a promise that something will arrive.

**README** is now short and points at flue.sh and docs/ for the rest.

Two bugs found and fixed in this work: the group `+` rendered without an
accessible name for groups that refuse one (a button a pointer could press
and a screen reader could not announce), and the preview card refetched on
every fleet poll because the row object's identity changed — a card that
blinked while you looked at it.
TestPeekDoesNotCountAsActivityInTheSession took its baseline the moment the
echo became findable in the ring, which can be one chunk of several. Every
chunk read back from the pty moves LastActive, so the pump finishing its
delivery a millisecond later looked exactly like a peek that had touched the
stamp — a race that never fired locally and fired on CI's first run.

The property is unchanged and worth keeping; the measurement now waits for
the session to actually go quiet before it starts. 40 runs under -race.
The mobile pass I owed and had not done. Three real faults, all on Sessions
— the other screens carry one small action each and were already fine.

**The header overlapped itself.** `justify-between` on a row that cannot wrap
does not push overflowing actions off the end; it prints them through the
heading. At 390px the search field, the display menu and the split button ran
straight over the word "Sessions" and then off the right edge of the
viewport. The row now wraps, and the actions take a line of their own with
the search growing into it — a toolbar rather than a pile. From `sm` up
nothing moves.

**The preview card had no business on a touch screen.** A finger held on a
row long enough to count as a hover opened a card over the rows beneath it,
and the tap that followed then fought the card instead of opening the
session — at the cost of a round trip per row. `(pointer: coarse)` stands the
whole thing down: the question is about the pointer, not the screen, so a
narrow window on a laptop keeps its previews and a large tablet does not get
them.

**The machine chip ate the name.** On a browser paired with one machine it is
the same word repeated down the screen; on a phone it and the timestamp took
half the row, truncating a session to "karn@karn:…" with the useful half
gone. A fleet of one drops it — keyed on how many machines this browser
holds, not on how many are answering right now, so a laptop going to sleep
does not make a column come and go. The preference is untouched: pair a
second machine and the chip is back, still ticked where it never stopped
being.
@karngyan
karngyan merged commit 56eff64 into main Aug 9, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant