Pin as many sessions as you want, and they stay where you put them - #80711
Merged
Conversation
PATCH /api/sessions/{id} only accepted title and end_reason, so the
`pinned` flag the desktop sends was rejected as an unsupported field —
and the client swallows that error. Pins lived in one app's localStorage
and never reached state.db, which also meant the server-side auto-archive
sweep was free to hide the chats a pin exists to keep.
Accept pinned and archived as booleans, route them to the SessionDB
setters that already existed, and include both in the serialized session
so clients can reconcile against server truth.
The list endpoints deliberately back-fill pinned conversations past their LIMIT, then the client sliced the response back down to that same limit and threw them away — so only pins that happened to land inside the most recent page ever rendered, which reads as a cap on how many sessions you can pin. Keep the back-filled rows when trimming, and discount them from the "window came back full" test that drives Load more. Counting a back-fill as a loaded row invented a page that could never be fetched, leaving a Load more button that refetched the same rows forever.
The guard that stops a stale list page from reverting a fresh pin was released on the PATCH's own ack. A list request issued just before the write is slower than the write, so it lands after the ack still carrying the old value, with no guard left to fence it: the pin flips back and the next reconcile pushes that wrong value to the server, making it durable. Keep the guard until a page actually confirms the value written, with a cooldown so a row that never returns can't fence itself forever, and drop it outright when the write fails — the server never changed, so it stays authoritative. Also reset the mirror bookkeeping on a gateway switch. mirrored/pending are per-backend facts; carrying them across a re-home told us the pins were already pushed to a backend that has never seen them.
Two ways a pin got misfiled. The duplicate: a pin is stored on the durable lineage root, but recents, the messaging slice and the backend project tree are three independent fetches and each can surface the same conversation under either its live tip or its root — so the filter compared one identity against the other, missed, and the session rendered in both Pinned and its project group. Match on every id the pin is reachable under. The lost reorder: a drag only reports the pins whose row is loaded, and setPinnedSessionOrder required that list to match the stored one in length, so a single unresolved pin discarded the whole reorder. Treat it as a permutation of a subset — re-slot the named ids, leave the rest.
Dragging one row switched the entire sidebar into a frozen manual mode with no date dividers at all — permanently, for every session, because the manual order replaced the recency sort outright instead of layering on it. Chronology and ranking are separate concerns: keep the calendar buckets where recency put them and apply the hand-picked order only within a bucket, so a drag ranks a chat among its own day's chats and the dividers survive. Rows move as clusters, so a reorder can't strand a branch child from its parent, and a session the saved order doesn't name keeps the slot recency gave it. Two supporting fixes fall out: dnd-kit now receives the ids it actually renders (it was handed the unrendered session order, so a drop computed its target against a list the user wasn't looking at), and an older page that loads no longer jumps above the hand-picked rows — new ids fold in by position rather than all hoisting to the top.
Contributor
૮ >ﻌ< ა ci reviewran on 45f2320
|
Collaborator
Pinned was capped at half the viewport by its own nested scroller, so past roughly a dozen pins the rest were reachable only by scrolling inside a scroller — a pin you have to go hunting for isn't doing its job. Drop the cap and let the section grow into the sidebar's existing scroll, and stop virtualizing Pinned: virtualization needs a bounded viewport to measure against, which is exactly what's being removed. No count badge, no "show more" — pin as many as you want and they all render. Also back-fill pins on the API-server list route, which was the one list path still windowing purely on recency.
OutThisLife
enabled auto-merge
August 7, 2026 02:30
randlee
pushed a commit
to randlee/hermes-agent
that referenced
this pull request
Aug 11, 2026
…n-sort Pin as many sessions as you want, and they stay where you put them
This was referenced Aug 18, 2026
Closed
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 sidebar's pin and manual-order handling had drifted into a set of separate bugs that all read as "the sidebar rearranged itself, and my pins won't stay." This fixes the logic behind them rather than each symptom.
Pins never actually persisted.
PATCH /api/sessions/{id}accepted onlytitleandend_reason, so thepinnedfield the desktop sends came back 400 — and the client swallows that error. Every pin lived in one app's localStorage, never reachedstate.db, and the server-side auto-archive sweep was free to hide the chats a pin exists to keep. TheSessionDBsetters were already there; nothing called them.There was an effective limit on pins, in two places. The list endpoints deliberately back-fill pinned conversations past their
LIMIT, and the client then sliced the response back down to that same limit — discarding exactly the rows the back-fill went and fetched. On top of that the Pinned section was capped at half the viewport by its own nested scroller, so past roughly a dozen pins the rest were reachable only by scrolling inside a scroller. Both are gone: pin as many sessions as you want and every one of them renders, with no count badge, no "show more", and no special treatment. Verified against a 200-session database with 60 pins deliberately chosen as the oldest rows and a page size of 10 — all 60 come back.Pins showed up twice. A pin is keyed on the durable lineage root, but recents, the messaging slice and the backend project tree are three independent fetches, and each can surface the same conversation under either its live tip or its root. The filter compared one identity against the other, missed, and the session rendered in both Pinned and its project group.
Manual sorting broke chronological grouping. Dragging a single row switched the whole sidebar into a frozen manual mode with no date dividers at all — permanently, for every session — because the hand-picked order replaced the recency sort instead of layering on it. Grouping and ranking are separate concerns now: the calendar buckets stay where recency put them, and the drag order only decides sequence within a bucket. Rows move as clusters so a branch child can't be stranded from its parent, and dnd-kit finally receives the ids it actually renders (it had been handed the unrendered session order, so a drop computed its target index against a list the user wasn't looking at).
A pin/unpin could still revert itself. The guard protecting a fresh toggle was released on the PATCH's own ack, but a list request issued just before the write outlives it — landing after the ack with the old value and no guard left. The pin flipped back, and the next reconcile pushed that wrong value to the server, making it durable. The guard now holds until a page confirms the written value, with a cooldown so a row that never returns can't fence itself forever.
Closes #75468, #80013, #76919, #44009, #51685.
Test plan
pytest tests/gateway/test_session_api.py— 15 passing, including pin/archive round-trip through real HTTP against a real SQLite DBvitest runon the touched surface — 63 passing, covering subset reorder, the post-ack revert window, guard cooldown/confirmation, and within-group rankingstate.db: all 60 pins returned, none lost to the window or the client trimtsc --noEmitclean