Skip to content

Pin as many sessions as you want, and they stay where you put them - #80711

Merged
OutThisLife merged 6 commits into
mainfrom
bb/sidebar-pin-sort
Aug 7, 2026
Merged

Pin as many sessions as you want, and they stay where you put them#80711
OutThisLife merged 6 commits into
mainfrom
bb/sidebar-pin-sort

Conversation

@OutThisLife

@OutThisLife OutThisLife commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

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 only title and end_reason, so the pinned field the desktop sends came back 400 — and the client swallows that error. Every pin lived in one app's localStorage, never reached state.db, and the server-side auto-archive sweep was free to hide the chats a pin exists to keep. The SessionDB setters 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 DB
  • vitest run on the touched surface — 63 passing, covering subset reorder, the post-ack revert window, guard cooldown/confirmation, and within-group ranking
  • 200 sessions / 60 old pins / page size 10 against a real state.db: all 60 pins returned, none lost to the window or the client trim
  • tsc --noEmit clean
  • Pin ~30 sessions; confirm all render with no inner scrollbar and survive a restart
  • Drag a session in Recents; confirm the date dividers stay and the row ranks within its own day
  • Pin a session inside a project; confirm it appears in Pinned only, not twice

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.
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

૮ >ﻌ< ა ci review

ran on 45f2320

⚠️ Warnings

OSV vulnerability scan · View job

50 known vulnerabilities found in pinned dependencies.

How to fix:

Review the findings in the Security tab. Update the affected dependencies if a patched version is available.


debug info

CI timings

CI timings · View report · View job

Wall time 5m33s vs 6m14s (-11.0%). 13 job(s) slower, 17 faster, 4 unchanged.

  • JS & TS checks / apps/desktop / check:test:ui: -47.0s
  • JS & TS checks / ui-tui / check: -35.0s
  • Python tests / Run tests slice 1/12: +25.0s
  • Python tests / Run tests slice 3/12: +21.0s
  • Python tests / Run tests slice 10/12: -21.0s

@alt-glitch alt-glitch added type/bug Something isn't working P3 Low — cosmetic, nice to have comp/desktop Electron desktop app (apps/desktop/*) comp/gateway Gateway runner, session dispatch, delivery area/sessions Session lifecycle, resume, persistence, history sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages labels Aug 7, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

This was generated by AI during triage.

Related to #72514 and #70822: this extends the Desktop sidebar pin/order family with persistence, lineage deduplication, and within-date-group ordering rather than duplicating either earlier change.

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/sessions Session lifecycle, resume, persistence, history comp/desktop Electron desktop app (apps/desktop/*) comp/gateway Gateway runner, session dispatch, delivery P3 Low — cosmetic, nice to have sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Desktop sidebar session pin/unpin does not persist: backend PATCH rejects 'pinned' (400, error swallowed)

2 participants