Repository navigation
Conversation
A session's pin and archive states were only reachable through the row's overflow menu, so tidying the list meant opening a menu per row. The row already revealed one hover control; it now reveals the two frequent toggles beside it, ordered overflow, pin, archive. The reveal moves from the overflow button's own class to a shared `.thread-item-action` class, so one rule covers every hover-revealed row control and a fourth needs no new selector. The overflow button keeps `thread-item-more` as a DOM hook for its menu anchor. Each trigger runs the same store action as its menu item and stops the click before it reaches the row that opens the session. The archive trigger swaps its glyph with the state so the row reads without a menu.
The inline pin trigger drew the same outline pin in both states, so the row could not say whether it was already pinned without opening a menu. Filling the glyph with the state answers it in place, matching the way the archive trigger already swaps its own glyph. It keeps the pin glyph rather than switching to the filled star, because the star already marks a pinned project and the two pins must stay distinguishable.
Session rows sat at three different background widths. Project group headers spanned the full width, nested session rows were inset 22px by their body's margin and padding, and the standalone and pinned lists were inset 2px on each side. Hover and selection fills therefore started and ended at different edges depending on which section a row belonged to. Drop the indents so the row, its group header and the standalone lists share one left and one right edge. Content insets are unaffected: the row's own padding and the title's left padding still hold the text off the edge. This also drops the nested-body indent that carried the hierarchy after D297 removed the tree rail, so a session row under a project no longer reads as its child by position alone. The project header's own status tag and the section labels remain the hierarchy signal. The `.temporary` body rule went with it: it existed only to set that indent and the class is no longer rendered anywhere.
The running and selected states both spent a dot on the leading edge, and a running row could only show the running dot: the running branch sits ahead of the selected one, so a selected row that was still working looked the same as one that was merely working. Give the two states the signals they already have room for. A running row sheens its own title, so the run reads inside the row and the sweep has the title's box to travel across. The selected row keeps its own background, which is already a stronger fill than hover. Permission, completed and failed keep their dots unchanged, so those three still share one leading edge. Reduced motion freezes the sheen at its bright half, and a renderer without background-clip shows the run as a still brighter title rather than losing it.
The sweep ran from 160% to -60% on a background twice the title's width. Both ends park most of that background outside the element: at 160% it spans -1.6W to 0.4W, at -60% it spans 0.6W to 2.6W. Only the part of the background that lands on the element fills its glyphs, and the transparent text colour this technique needs left the rest of the title invisible, so a running row lost part of its name for most of the animation. Sweeping 0% to 100% keeps the background covering the element at every frame: 0% spans 0 to 2W, 100% spans -W to W, and the positions between span more than the element needs. The sheen is still clipped to the glyph outlines, so the light crosses the letterforms rather than a box. Reduced motion holds the middle of the sweep, which is the glyph-shaped highlight at its brightest rather than a bare colour.
The gradient made a dim base with one bright band, so the title sat at the dim colour for the whole cycle and only a slice of it ever lit up — the run read as a darkened title rather than a travelling sheen. Invert it. The base stops are `currentColor` and the travelling band is `--ds-text-muted`, so the title keeps the colour the row already gives it and a dim streak travels over the glyph outlines. Reading the base from `currentColor` also keeps the streak honest through the row's hover and selected states, which set their own colour, instead of pinning it at rest. The fill can no longer be emptied through `color`, because the base stops resolve `currentColor` against it. `-webkit-text-fill-color: transparent` hands the glyph fill to the clipped background and leaves `color` alone.
The disclosure chevron said nothing the folder beside it did not, and it pushed every project title 13px further right than the rows without one. Collapsed projects now show a closed folder and expanded projects an open one, so the state reads off the glyph itself and the title keeps one left edge either way. Session rows were less consistent than the project rows above them: the leading icon moved with whatever the row happened to carry — a pin, a Pi badge, nothing — so titles in one list began at three different x positions and the hierarchy read as noise. A fixed-width slot now reserves that column whether or not anything fills it, the transient permission/completed/failed glyph borrows the same box instead of hanging further left than the row's own icon, and the Pi badge, which is text and cannot fit an icon slot, rides after the title as metadata.
The sheen sized its band and its travel as shares of the title, so the same declaration produced a different effect on every row: a 39px session crawled at 22px/s behind a fat bar, a 200px one raced at 112px/s behind a thin one, and a list of running rows never looked like one rule. Pinning the travel to the longest title the sidebar can lay out fixed the speed and the band but bought a dead pause on every shorter row instead. Neither problem is reachable from CSS. The band has to travel `width + band` pixels to cross a row and clear both edges, which ties the duration to the title, while `animation-duration` is resolved before layout and `calc()` cannot read the element's own size. The pause is worse: holding the band a fixed second would need a keyframe split that moves with the title length, and `var()` is not a valid keyframe selector at all — a stylesheet that tries it silently loses that frame. So a hook measures each running row's title and owns the animation, while the stylesheet keeps the part that is genuinely declarative: the gradient clipped to the glyph outlines. The band is mixed from `currentColor` rather than a semantic token, because `--ds-text-muted` resolves as translucent white in dark and an opaque grey in light, and a band built from it disappears against light-theme text.
Pinning was a badge on a row, which said the same thing twice: the row was already sorted to the top of the list, and the star pushed every pinned title 18px right of every unpinned one, so the left edge the list had just been aligned on broke again on exactly the rows that were meant to sit apart. Pinned projects are now drawn into a group of their own above the list, and the badge is gone — being in the group is the marker. The two halves are sliced from the one sorted list rather than filtered at render time, so each keeps the order the chosen sort gave it. The group sits beside the projects section instead of inside it: nested, it read as "projects, which contain a pinned subgroup" and the two labels stacked. Being outside the scroller it now carries its own height bound, and its label-to-first-row inset and last-group air are pinned to match the list below, so the distance to the next section no longer depends on whether the last group is expanded. Cross-bucket dragging comes free from the boundary that reorder already refused to cross: a row dragged over the other half is offering to pin or unpin, decided on release. The zone is read from the list containers rather than from a row, because the half being entered can be empty and would then offer nothing to drop onto.
The hook watched the projects scroller, but pinned and temporary sessions are siblings of that scroller rather than descendants of it. Those two lists were never measured and never animated, so the running sweep appeared to belong to projects — while the stylesheet rule that drew it has been global the whole time. Widening the container is not enough on its own. Rows are keyed by session id, and the same key under a different parent is a different node: pinning a session that is already running moves it out of its project group and into the pinned list, and the running set does not change when it does. The replacement arrived with no width and no animation and quietly never got one, and the node it displaced detached, at which point the resize observer stops reporting for it as well. Child-list watching rebuilds the observed set when that happens, and covers groups added while the sidebar is open. Rebuilding does not mean rebuilding: a row whose width has not changed keeps its animation, because a cancel-and-create to arrive at the same place is a stutter the phase carry-over only hides. Rows that left the list are dropped, so a detached node does not hold a running animation forever.
The pin action filled its outline to say "pinned". At the size the row renders it, the solid head stopped reading as a pin at all — it was a blob with a stem — and next to the archive button, which fills legibly because its two states are two distinct outlines, it looked like a different weight of icon rather than the same one in a state. The icon set has no glyph for the pinned state, only the pin and the pin-off, so the angle carries it instead. Both places that draw a pin take the 45° now, so the glyph reads the same wherever it appears. The leading slot glyph was the odd one out at 11px, sized down for clearance the neighbouring folder never needed: it sits in a 13px box holding a 13px folder with no margin and looks right. Matching it also lifts the stroke from 0.80px to 0.95px, which is what made it blurry. Turning the pin does grow its bounding box to about the full grid, but the slot does not clip and the title is a further 5px away, so it fills its box rather than losing any strokes.
The pinned projects group shipped its label to three locales and no others, and the English catalog — which is where the catalog type is derived from — was never given the key at all. Nothing caught it: the desktop typecheck reads the built package, so it saw a stale dist, and the locale tests assert behaviour rather than catalog shape. The first full build of the i18n sources did, and reported the key as missing from six catalogs. Each locale takes the word it already uses for pinned sessions, since both groups are labelled the same thing. This is why the key has to start in the English catalog: every other locale is type-checked against it, so a partial rollout is a build failure rather than a fallback to English at runtime.
A session title starts at 26px: 2px on the row, 6px of padding, the 13px leading slot, and its 5px gap. The time-group label above it, and the load-more affordance below the list, both sat at 20px. That was correct until the slot was split out of the row. Before it, the row's inset and its title shared one edge at 20px, so a label at 20px lined up with the text it headed by coincidence. Splitting the slot moved the title 6px right and left both labels behind, which put every "yesterday" and "older than two weeks" caption visibly off the column of titles it groups. Both move together because they are the same kind of label over the same list, and the comment records where 26px comes from so the next change to the slot does not silently reintroduce the drift.
Contributor
Author
This was referenced Oct 6, 2026
This branch was previously deployed
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.

Sidebar rows had drifted apart in ways that made a list of them hard to scan: each kind of row decided its own left inset, pinned state was marked by a glyph that shifted the title it belonged to, and the running indicator only animated inside project groups.
What changed
One leading glyph per row. A project row used a chevron for disclosure and a folder beside it, so it was the only row that started 13px further right than everything else. Disclosure now rides the folder itself — a closed folder when collapsed, an open one when expanded — which says the same thing with one glyph and keeps the title on a single left edge.
A reserved leading slot on session rows. The leading icon used to move with whatever the row carried: a pin, a Pi badge, or nothing, so titles in one list began at three different positions. A fixed-width slot now reserves that column whether or not anything fills it. The transient permission/completed/failed glyph borrows the same box instead of hanging further left than the row's own icon, and the Pi badge — text, so it cannot fit an icon slot — rides after the title as metadata.
Running state as a sweep across the title. The leading dot that marked a running session is gone; the title itself now takes the pass. The band is measured from the row's own width rather than expressed as a share of it, so a three-word session and a thirty-word one get the same thickness and the same speed instead of reading as two different effects. The dip is mixed from
currentColorrather than a semantic token, because--ds-text-mutedresolves as translucent white in dark and an opaque grey in light, and a band built from it disappears against light-theme text.Pinned projects in a group of their own. Pinning was a badge on a row, which said the same thing twice — the row was already sorted to the top — and pushed every pinned title 18px right of every unpinned one. Pinned projects are now drawn into a group above the list and the badge is gone; being in the group is the marker. Dragging a project across that boundary pins or unpins it, decided from the list containers rather than from a row, because the half being entered can be empty and would then offer nothing to drop onto.
Pinned state marked by angle, not by fill. The pin action filled its outline to say "pinned", which at row size stopped reading as a pin. There is no pinned glyph in the icon set, so the angle carries the state instead.
Verification
pnpm build:js— cleanpnpm --filter @pi-desktop/desktop test— 3022 passedpnpm test:e2e— 23/23 (2 live-model suites skipped, no API key)pnpm test:e2e:session-completion— 6/6pnpm test:e2e:theme-surfaces— pass, including light and custom themespnpm test:e2e:layout— 192/192Known gaps
Cross-bucket dragging (pin/unpin by drag) has unit and source coverage but has not been exercised by a scripted end-to-end run, and the spacing rhythm between time-group captions and the rows under them was adjusted by inspection rather than measured.