Sessions UI revamp: all-machines Linear-style table - #7
Merged
Conversation
Linear-style all-machines sessions table: fleet client aggregation, naming/tagging/pinning with daemon-side JSON persistence, live cwd, grouping/sorting/search/bulk/saved views, shared PageHeader. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A session now carries what a human decided about it as well as what its shell says. Name is the label nothing inside the session may touch, so it outranks the OSC-scraped title; CreatedAt is the sort key output cannot disturb, unlike LastActive; tags are normalised once, at the edge, so nothing downstream has to wonder whether " prod" and "prod" are the same. Edits arrive as a MetaPatch — nil means "leave this alone" — because two tabs on one session is the ordinary case, and a whole-record write would silently undo whatever the other one changed since its last read. The wire golden fixture and its TypeScript mirror move with the struct: the round-trip test pins Info's JSON shape, so a field added here is a field the fixture has to show. The `sessions` entry now carries one record with metadata and one with the empty values a fresh spawn has. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three assertions were vacuous, in the same way each time: they checked an invariant against a session whose starting state already satisfied it. The partial-update test patched one field of a session whose tags were empty and whose pin was false, so "left alone" and "cleared" produced the same snapshot — an ApplyMeta that zeroed every field it was not given passed it. It now seeds all three fields first, then patches each in turn in a direction the seed did not already hold. The LastActive invariant was asserted against a frozen clock, which is the one clock that cannot tell "untouched" from "stamped just now". It advances a second per reading now. The concurrency test's "the reader saw a patch" guard could lose a scheduling race — reliably at GOMAXPROCS=1, 2 runs in 100 — and blame the writers for it. It takes one last reading before the guard. Also: normalizeTags clips its result. Info hands the same slice header to every reader, so a consumer appending to a snapshot with spare capacity would write into the array the others are reading. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Info now asks the kernel where the child's cwd is on every snapshot, so a cd typed into a session moves the directory the sessions table shows. Linux reads /proc/pid/cwd; darwin calls proc_info raw (no cgo), with the ABI constants read off this machine's SDK headers and proven by a real-PTY test. A failed read keeps the last known value, and nothing is stored once the session has exited — after the reap the pid may name a stranger. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Names and tags are the part of a session a user typed, so they have to survive the case a snapshot cannot: a daemon killed rather than stopped wrote no snapshot at all. Every UpdateMeta now flushes an <id>.meta.json beside the snapshots — same atomic write, same 0600-in-0700 modes — and a boot pass hands them back to the sessions that came back while sweeping the records of those that did not. A write failure is logged and the in-memory edit stands: durability degrades, the function does not. The two files share a directory and ".meta.json" ends in ".json", so LoadAndClearSnapshots — which deletes what it reads — now skips them explicitly. Without that guard the next daemon start would silently consume every name and tag on the machine. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A shutdown snapshot now carries name, tags, pin and CreatedAt alongside the identity, so a graceful restart brings sessions back whole without consulting anything else on disk. CreatedAt travels because it is what a stable ordering rests on; a restart that reset it would reshuffle every list at once. start() takes the record a session inherits as an Info rather than a widening list of positional arguments. Spawn passes the empty one, and a zero CreatedAt is read as "begins now" — which is both the Spawn case and a snapshot written before the field existed. Also corrects the rationale on the metadata files, which claimed to survive a SIGKILL. Nothing revives after a kill, so the boot sweep deletes every record they left; what they actually buy is durability from the moment of an edit and independence from the snapshot. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An `update` carries an edit to what a human owns on a session — name, tags, pinned — addressed by id, since naming a session is not something you have to be attached to do. Every field is a pointer, and Tags a pointer to a slice, because the message is partial: absent means "leave this alone", and `tags: []` means "the user removed the last one". A plain []string spells both of those nil, which would make clearing a session's tags an edit the daemon silently drops. The shape mirrors session.MetaPatch field for field so the daemon can pass the patch through rather than rebuild it — a translation being exactly where that distinction would go missing. `sessions` gets the MarshalJSON DeviceList already has: a nil slice is written as [] rather than null. "The daemon is running nothing" is reached by building the zero value, so the null would have gone in front of a `SessionInfo[]` consumer on a fresh machine — the first list anyone sees. Closes that follow-up. The TypeScript fixture suite now fails on the three new golden entries, which is where the protocol.ts mirror picks up. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The update case hands wire.Update's pointers straight to session.MetaPatch
and answers the requester with a fresh sessions, sharing the list-building
with the list case through sendSessions. An unknown id is error{not_found}.
The reply goes to the asking connection alone — nothing broadcasts — so the
spec's claim that an edit is broadcast to every connection is corrected to
say what the daemon does: other clients converge on their next list poll.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds UpdateMsg to the client protocol and to the ClientMessage union, mirroring wire.Update. The partial semantics are the whole point, so the doc comment says which absences mean what: an absent field is unchanged, `tags: []` clears the tags and `name: ''` clears the name. The golden fixture gains a fourth update case, updateClearName, pinning that an empty string and a false both survive the round trip in either codec — the two values a stray omitempty eats without a word. Both sides were checked by breaking the fixture and watching each suite fail. SessionInfo already carried name/tags/pinned/createdAt from an earlier task; only the manifest and the decode assertions were owed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
FlueClient.update spreads the caller's patch whole rather than copying field by field: `name: ''`, `tags: []` and `pinned: false` are edits a user makes by hand and all three are falsy, so a truthiness test would send an update carrying nothing — the last tag impossible to remove, with no error to say so. Absence is the other half of the rule and comes free from the same spread. Dropped rather than held while the socket is down, unlike `list`: a rename replayed from behind the backoff would land on whatever the metadata had become meanwhile, undoing another view's edit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
One client per machine, merged into a single stamped sessions list: FleetClient owns status folding, dedup of the loopback daemon's relay twin, the learned-origin remote build, and the staggered 3s poll. fleetSources is the production builder, constructing relay clients exactly as relayBoot does. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
FleetProvider supersedes FlueClientProvider at the router root: it builds the tab's fleet synchronously from the one machine the tab rides (relay boot's client, an inherited test client, or a fresh loopback client) and lets FleetClient's welcome-driven expansion add the remotes. The fleet's local client is handed into the exported FlueClientContext, so every useFlueClient consumer works unchanged and the tab still opens one socket per machine. The terminal route resolves /d/$deviceId through useFleet().clientFor: 'local' behaves exactly as before, another id reaches that machine's client without touching its lifecycle, and a machine the fleet does not hold gets the missing-session treatment with "Machine not paired on this browser". Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The route read clientFor(deviceId) once at render, but remote sources are adopted only after the local welcome names the relay — so a direct load of /d/<remote>/s/<id> sat on the not-paired pill for ever. Resolved through useSyncExternalStore over onFleet instead: the snapshot is the slot's client, so the terminal mounts the moment the machine is adopted and the pill stays only for machines the fleet truly does not hold. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
dropdown-menu, popover, select, checkbox and dialog from the radix-nova registry, for the sessions table to build on. The generated `dark:` utilities are stripped: this app has no theme toggle, dark follows prefers-color-scheme, and the semantic tokens these files ride already flip there. Two of them — `dark:bg-input/30` on the checkbox and the select trigger — were a real fill rather than a duplicate, so both controls are now delimited by their border alone in dark. The overlay keeps `bg-black/10` because sheet.tsx's already does and a scrim has no token. Radix's Select calls scrollIntoView on the item it focuses when it opens, unguarded, and jsdom has no such method — so mounting one threw before any assertion ran. Stubbed beside the ResizeObserver and matchMedia stubs, behind a `globalThis.Element` check: this setup file runs for the three node-environment build suites too, which have no DOM and died on the bare name. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Restores nine `dark:` utilities I stripped from checkbox, select and dropdown-menu. What this app bans is the `.dark` class variant, which would make them dead selectors; the utility itself resolves through prefers-color-scheme and is what button.tsx, input.tsx and badge.tsx already do. `dark:data-checked:bg-primary` on the checkbox is not the duplicate it looks like. shadcn's `data-checked` expands to `&:where([data-state="checked"])`, and `:where()` adds no specificity — so it ties with `dark:bg-input/30` at (0,1,0) and compiled source order decides which background wins. The dark-scoped copy settles it, and a checked box stays teal rather than translucent input fill. Also drops the dead `position === "popper" && ""` branch on SelectViewport; its sizing moved to data-[position=popper]: variants, so the conditional only ever contributed an empty string. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
One component for the header every management screen opens with: a breadcrumb trail, a right-aligned actions slot, and an optional second row. Sessions, Devices and the Settings placeholder move onto it; the h1 class string that was copied across all three now lives in one file. The last crumb *is* the h1 rather than a trail item repeating a title beside it — every screen today carries exactly one crumb, so printing both would say the same word twice on the common path. The heading therefore carries aria-current="page" and sits inside the landmark. The shell's own bar is untouched: routes own their headers, as before. router.test.tsx asks for the shell's nav by the absence of a name, since a screen now has two navigation landmarks. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Everything the rebuilt sessions screen has to decide about its rows — what a session is called, which ones a search admits, what order they read in, where the headings fall — as pure functions over FleetSession. No React and no storage in view.ts, so the whole of it is pinned by unit tests rather than by rendering a screen and squinting at it. The components to come take Group[] and do no arithmetic on it. The pipeline runs one way: search, drop the ended if the view says so, order, then cut into groups. Grouping last is what makes "pinned first" hold inside every group without a second sort, and what lets a filter empty a machine's group out of existence rather than leaving a heading over nothing. orderSessions inherits session-table.tsx's ordered() contract and extends it. The daemon stamps lastActive on every byte in either direction, so ordering by it raw would reshuffle rows between polls — the same defect the Go map's random range order has, by a subtler route. So lastActive is read to the nearest 30s, and everything a bucket cannot separate falls to cwd then id, cwd being the one field a running session never rewrites. Tags are the one grouping where the row count grows: a session tagged api and ops appears under both, because picking one to file it under is a choice no rule makes correctly. The untagged gather last, under a key that carries no prefix and so cannot collide with a tag of any name. views-store validates every read the way relay/machines.ts does, one row at a time — a tab whose columns came back mangled costs its owner that tab, not the four beside it. Saving over an existing name replaces it where it sits rather than appending: these are tabs, and one that jumped to the far end of the strip would move the target out from under the pointer that just clicked it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
One list, headed runs: the rewritten SessionTable renders the view model's Group[] verbatim — folding, selection, column choice and every ordering rule live with the caller. Rows gain a hover-revealed checkbox keyed machineId/id, a pin star, tag chips, a middle-cut directory, relative time cells, and a five-action row menu; group headings carry a fold toggle, a running/exited tally, and an optional spawn control. ago() moves from the devices screen into lib/time so both screens tell time in the same words. The sessions route adapts through an interim ungrouped view until the fleet rewire owns the arrangement. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review round 1. Two guards, one for each way the sessions list could quietly betray its contracts: A group's rows now have an order test whose fixture defeats any comparator in either direction — a displayName sort slipped into the row render survived all 747 tests, and now survives none. The data-checked:/data-open: shorthands only reach the data-state attributes Radix actually writes through the custom variants that shadcn/tailwind.css registers and styles.css imports. Review claimed the wiring absent; the compiled sheet proves it present, and the build guard now asserts it on the output, so removing that import fails a test instead of silently emptying every checked checkbox. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both surfaces treat "nothing" as an answer rather than as an error: an empty rename clears the name and lets the OSC title speak again, and a tag set emptied of every chip clears the tags. Neither disables Save, because refusing to submit nothing would make both operations unreachable from the UI. Editing state lives inside the dialog content Radix unmounts on close, so one dialog can serve every row without an effect resyncing props — an abandoned edit cannot follow the reader to the next session. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Typing a tag and reaching straight for Save dropped it: only chips already committed by Enter or by a suggestion click were submitted, and the field's contents went out with the dialog. Trim and dedupe now live in one function that all three routes into the set share, Save included. Also pins two contracts that survived deletion in the previous round — the tag field takes focus on open rather than the first chip, which is a remove button, and a submit reaches onSubmit before onClose. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The ⚙ panel and the search field the sessions header will mount. Each control in the panel reports a whole new ViewConfig with exactly its own field replaced; the search field holds keystrokes for 150ms so the list is re-cut once per word rather than once per letter, and drops a pending report on unmount rather than firing it at whatever mounts next. Two carried review notes settled here, both in view.ts. DEFAULT_VIEW and its column list are frozen: it is one object shared by every tab that never saved an arrangement, and a push against it would rewrite the default for every screen. And the search haystack gains `cmd` and `title` — the row prints the command under the name, so it was text on screen a search could not find, and a title shadowed by a typed name is still how people remember which session is which. The column labels move to view.ts beside the keys they name, since the heading row and the chip row must never call a column two things. The name chip is offered as permanently on: the sessions list prints that column whether or not `columns` asks for it, so a chip that toggled it would be a control the list ignores. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The bar shown once rows are checked: "N selected", Tag, Pin, a destructive Close, and an X that drops the selection. Close asks first, and the asking lives in here because a confirm that names the count must be rendered by something that knows the count. onClose fires from the confirm's own button and nowhere else; Cancel, Escape, the overlay and the corner X all leave the selection exactly as it was. Confirming does not clear it either — the caller closes what was selected and drops it in one step. The confirm's description is the only thing anyRunning changes: processes that are running are killed and the reader is owed that sentence, while a list of already-exited sessions gets the plain fact instead, so the warning that matters keeps its weight. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The bar is pinned to the window, and from md up the sidebar takes --sidebar-width off the left of the content, which the window knows nothing about: it read left of its own list at every desktop width, and just past the breakpoint its left edge slid under the sidebar, where it wins on stacking order. Nothing outside could correct a pinned element it cannot reach, so it now takes a className and merges it last, letting the route push the centre back over the content. The count says itself out loud now, politely. The boxes it counts are elsewhere on the screen and never take focus, so the total was changing in silence. Also pins the corner X: it is named "Close", one tab stop from a button that kills three sessions, and it must stay a dismissal and nothing else. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The strip under the page header: "All" first, then one tab per saved view, a + that keeps the current arrangement under a name, and a per-tab menu that forgets one. Buttons with aria-pressed rather than a tablist. There is one panel here and pressing a tab does not swap it for another, it re-cuts the same rows; a tablist would also promise arrow keys between them, which nothing implements. "All" has no menu and no update button, because it is the absence of a saved view rather than one of them: nothing to delete, and no name to write an edit under. The + is the affordance there, and it is why the update button reads "Update view" only when a named view is active and dirty. A blank name is refused twice: Save disables itself, and the submit handler checks again, since Enter reaches a form without going past its buttons. The store now refuses one too — listViews already dropped a blank-named row on read, so writing it made a tab that lived exactly until the next reload. Delete asks nothing first. A view is a few words about how a list is cut and takes no session with it; the confirm the bulk bar raises before killing processes is the one that has to keep its weight. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
wire.CloseSession gains an optional id beside its ref, so the all-machines sessions list can close a row it never attached to without spending a subscribe, a backlog replay and a detach to earn a handle first. A non-zero ref still wins, so ref semantics never move; an unknown id answers not_found the way update does. No reply on success — the exit announces itself. FlueClient grows closeById (dropped while down, like update), FleetClient routes it with closeOn, and the golden fixture pins both spellings. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The route becomes the state owner the revamp was building toward: useFleet's merged rows cut by the view pipeline, saved views with value-compared dirtiness, a keyed selection that prunes itself, per-machine spawn with the reply adopted on the client that owes it, unreachable machines as retryable bands, and bulk tag, pin and close routed per session to the machine that owns it. The sidebar nav gains its Main label so the two landmarks on a management screen are told apart by name. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Backfill the sha on the nil-sessions follow-up, re-cite the CreateTemp+rename census now that meta.go is another copy of it, stop the protocol spec claiming sessions follows every change to the set when only list and update produce it, and say in the README what the sessions list now does. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three things the whole-branch review found.
Retry was dead. `connect()` returned whenever a socket or a retry timer
existed, and while a machine is unreachable one of the two always is —
so the sessions screen's Retry could not do anything at all, and
recovery only ever came from the backoff itself, up to ten seconds
later. A connect made mid-backoff now stands the timer down and opens
the socket on the spot. The escalation is kept: being asked again says
nothing about whether the machine came back, and a held-down button
must not walk the delay back to its floor.
Uncorrelated refusals were dropped. `update` and `close`-by-id carry no
reqId, so the daemon's only answer to a failed one is a bare
`error{not_found}` that nothing on screen was listening for — the row
stayed put and the reader was told nothing. FleetClient grows an
`onError` passthrough, stamped with the machine, wired beside the
status and sessions listeners so a source adopted mid-session is heard
too; the route routes an uncorrelated not_found into the live region it
already owns.
And `keyOf` was written out twice, in the route and in the SessionTable
that has to agree with it exactly. One exported definition now, beside
the shape it reads.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
No heading row of field names, and no Open button: each row is one flex line — hover checkbox, pin star, state dot, strong name with the command beside it, then tags as badges, a machine chip, the mid-cut directory and the relative stamp ranged right — and the whole row is a real router link to its session, stretched over the row by its ::after box so a Ctrl/Cmd or middle click opens a new tab natively. Interactive children stack above the overlay on z-10 rather than living inside the anchor. Rows answer a hover with a soft rounded tint; group headings sit in a quiet full-width band. The columns preference still decides which pieces a row carries; selection keys, group folding, the pinned rule, the empty state and the per-group spawn control all keep their contracts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The shell's h-14 band held one small button over a screenful of nothing on every management screen. From md up the trigger now sits on the heading's own line, leading the breadcrumbs in PageHeader; the band survives below md only, where it is the sheet's opener and the wordmark's home. Remote access adopts PageHeader for its heading row so the trigger reaches that screen too, and the test router helper gains the SidebarProvider the shell provides in the app. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
While a session is open the tab is called after it: the typed name leads with the live OSC 0/2 title beside it, and the working directory stands in when no program has set one. The attached carries the title's first word; the sessions poll — already flowing for the cwd this component tracks — carries every later one, name and directory included. The prior document.title still comes back on unmount, and an all-empty answer leaves it untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review follow-through. The row link's stretched overlay is what a browser hit-tests, so every title-holder — the state dot, the directory, both stamps, the folded tags — now stands above it on z-10 beside the checkbox and the ⋯ trigger, and a class-pinning test holds all of them there; jsdom does no hit-testing, so the class is the observable contract. Tags cap at three with a +n badge carrying the rest in its tooltip, since the overflow-x wrapper died with the old layout and a wide row must not push the pane sideways. The machine chip survives every width — the headline use is a phone reading a desktop's fleet — and the directory is shed first instead. Also rewords the COLUMN_LABELS note that still described a heading row, and adds the name-only and all-empty tab title branch tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Change the grouping, reload, and the list snapped back to machine. The current ViewConfig and the pressed tab now ride localStorage under flue.view.current — written on every change, read once on mount, validated whole against the unions exactly as the saved views are: a corrupt record, a storage the browser will not open, or a tab whose view was deleted elsewhere all land on the default under All, with the arrangement kept in that last case. Writes are best effort and silent; the dirty flag stays a value-compare against the active saved view. The route's save-view-as handler is renamed out of the way of the store's saveCurrent, which it shadowed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The sessions list becomes flue's flagship surface: every paired machine's sessions in one Linear-style table, with naming, tagging, pinning, grouping, sorting, search, bulk actions, and saved views.
Daemon
name,tags,pinned,createdAtonsession.Info, applied via a new partialupdatewire message and persisted crash-safe as per-session meta files (<id>.meta.json, flushed on every edit, adopted at boot, reaped with the session). Shutdown snapshots carry the fields through a revive./procreadlink on Linux,proc_pidinfoon Darwin, cgo-free), socdshows up within a poll.closemessage, so the list can close sessions without an attachment.sessionsframes marshal an empty list as[](closes the FOLLOW-UPS null entry).Web
FleetClient: one connection per paired machine (loopback + relay slots), merged session list stamped with machine identity, per-machine status, staggered polling, loopback-wins dedup./d/$machineId/s/$sessionIdis now real.PageHeaderwith breadcrumbs; sidebar and breadcrumb navigation landmarks properly named.Test plan
make test: 12 Go packages (-race), 871 web tests, 90 relay tests — all green.name:"",tags:[],pinned:false) pinned end to end.🤖 Generated with Claude Code