Skip to content

Pinned sessions and groups - #118

Merged
lvwerra merged 5 commits into
mainfrom
feat/pinned-sessions
Sep 11, 2026
Merged

Pinned sessions and groups#118
lvwerra merged 5 commits into
mainfrom
feat/pinned-sessions

Conversation

@lvwerra

@lvwerra lvwerra commented Sep 3, 2026

Copy link
Copy Markdown
Member

An ungrouped session or a whole group can be pinned. Pinned things sit above a rule at the top of the sidebar, and the idle window stops ageing them out. The pin is a hover-only control beside the archive ×, and becomes an unpin once set.

Screens — every state, plus the reasoning: https://lvwerra-agent-artifacts.static.hf.space/pinned-sessions.html

The exemption is narrower than it sounds

Road What it is Pinning
The idle window (archive.after) Derived. "Nothing has happened here for a month." Changes when the setting changes. Suppressed. This is what "pinned sessions don't get archived after some time" means.
Archiving (archivedAt) Stored. "I am finished with this one." Stops the agent, and the only road that unlocks delete. Untouched. A pinned session can still be archived on purpose — and doing so clears the pin.

Archiving clears the pin because the alternative is a record asserting "keep this in front of me" and "I am done with this" at once. The later statement is the true one. Restoring later brings it back unpinned. There is a test that pinning does not block archiving, and it fails if someone makes it.

What can be pinned

An ungrouped session, or a whole group. A session inside a group cannot be pinned — its group is the thing you pin.

Option Cost
A grouped session cannot be pinned The pin is unavailable on a row that has a group; you pin the group instead, which moves its siblings too chosen
It rises out of its group, tagged with where it came from A group visibly showing one fewer row than it has members, and a row that appears to have left the group it is still in. Built, reviewed, rejected as odd to use. rejected
Shown in both places Two live rows on screen for one session, each with its own state and controls rejected
Stays in place, pinned but unmoved Pinning that does nothing visible rejected

The lifting mechanism is gone, not disabled: liftedSessions(), the append-lifted-rows loop, the from argument on SessionRow, the .from-group tag and its CSS, and the tests that covered them. In their place is one predicate, canPin(ref, groups). The rules module's code went from 51 lines to 44 and traded an exported mechanism for a predicate.

The control is absent, not disabled. Pinning a group is a group-level action, so it lives on the group's header alongside rename, hide-from-overview and delete-group — none of which appear on a member either. A disabled pin on every member would repeat what the header's single pin already says, once per member, and argue back when clicked. On touch, where every row's controls are visible at once, the layout teaches the rule by itself: pins on group headers and ungrouped rows, never on a member.

The two edges that come with it

Dragging into a group clears the pin — the same rule that already governed the boundary above. A dropped row takes the pin state of where it lands, and it has landed somewhere a pin cannot exist. pinAfterDrop answers both boundaries now instead of one. Without this the pin would sit there invisibly and come back the moment the row was dragged out again.

Dragging out of a group leaves it unpinned. A grouped session reads as unpinned wherever the pin is consulted, whatever its stored record says, so on the way out it takes the state of wherever it lands — never a stale true. Dropping it deliberately into the pinned block does pin it, which is the operator asking for that.

A stray pinnedAt on a grouped session is ignored, not rendered. It buys no exemption from the idle window, and it cannot reach the pinned block: order holds top-level refs only, so a grouped session is not in it and no code is left that could draw one there. Verified against a real leftover record from my own earlier testing — the row stayed with its group and had no pin control.

Enforcing this by clearing the pin server-side wherever a session joins a group would mean touching six call sites for a value that is already inert at every point it is read. The reader-side rule is the smaller expression of the same invariant, so that is where it lives.

The other three decisions (unchanged)

Pinning a group exempts its members from the idle window, not just the group's position. Position alone eats itself: a pinned group whose agents each age out empties one row at a time and then disappears entirely, because the sidebar drops a group once all its agents are archived. Members get no pinnedAt of their own — membership carries them — so unpinning the group hands every member straight back.

Pinning adds no second ordering. It partitions the one manual order and keeps each half in it. Dragging across the rule is covered above.

Hover-only has an answer on touch, and the codebase had already given it. Below 720px .row-actions stops being hover-only and .age gives up its space — with a comment saying hover-only controls are something "a touch device can never satisfy: on a phone they were simply unreachable."

Measured Desktop (1120px) Touch (390px)
controls at rest opacity 0 on every row opacity 1 on every row
controls on hover opacity 1 on the hovered row only n/a
row overflow at 390px none, on any row

One caveat I will not paper over: the pin's tap target is 25px, the same as the archive × beside it. Below the usual 44px guidance. It is the size these controls already are and this change does not make it worse, but enlarging the strip is a separate decision.

Empty state

No block is rendered when nothing is pinned — not an empty one — so no stray rule and no gap. Verified: with everything unpinned the .pinned-block element does not exist.

Where the state lives

Stored beside the archive state: pinnedAt on the session and group records, not derived, surviving a restart. POST /api/sessions/:id/pin|unpin and POST /api/groups/:id/pin|unpin — one per direction rather than a toggle, so a double click or a stale row cannot flip it to the opposite of what was on screen. A group's pin is set through its own function rather than update(), the shape the tree editor sends, which would otherwise clear a pin it never knew about; there is a test for exactly that.

Tests

Both new suites are mutation-tested.

  • web/test/pinned.test.mjs fails when a grouped session becomes pinnable, when a stray grouped pin is honoured, when a drag into a group keeps its pin, and when a pinned group stops carrying its members.
  • server/test/pin.test.mjs fails when archiving leaves the pin set, when pinning blocks archiving, when a group rename wipes the pin, and when a group's pin is not persisted.

Green: typecheck, web 22/22, every server suite except server/test/crons.test.mjs, which is red on clean main and is not mine.

Not merged, not deployed.

Agent Manager and others added 2 commits September 3, 2026 17:33
A session or a whole group can be pinned. Pinned things sit above a rule at the
top of the sidebar, and the idle window stops ageing them out. The pin is a
hover-only control beside the archive x, and becomes an unpin once set.

The exemption is narrower than it sounds. There are two roads into the archived
view: the idle window, which is derived and says "nothing has happened here for
a month", and archiving, which is stored and says "I am finished with this one".
Pinning suppresses the first and leaves the second completely alone — a pinned
session can still be archived on purpose, and doing so clears the pin, because a
record asserting both "keep this in front of me" and "I am done with this" has
stopped meaning anything. Restoring it later brings it back unpinned rather than
silently re-pinning something the operator last saw as finished.

The four decisions, and why:

1. A pinned session inside an UNPINNED group is lifted to the top block, tagged
   with the group it came from. Leaving it in place makes pinning useless in the
   one case you most want it — a single agent inside a long group, far down the
   list — and drawing it in both places puts two live rows on screen for one
   session, each with its own state and controls. Lifting is a view: membership
   is untouched and unpinning drops it home. A session inside a PINNED group is
   not lifted; the group is already at the top carrying it.

2. Pinning a group exempts every member from the idle window, not just the
   group's position. Position alone eats itself: a pinned group whose agents age
   out empties one row at a time and then disappears, since the sidebar drops a
   group once all its agents are archived. Members get no pinnedAt of their own —
   membership carries them — so unpinning the group hands them all straight back,
   and a member pinned in its own right stays pinned.

3. Pinning adds no second ordering. It partitions the one manual order and keeps
   each half in it, so the two cannot disagree about what comes first. Dragging
   ACROSS the rule is the case that would otherwise let them fight, and the
   answer is that a dropped row takes the pin state of whatever it lands beside,
   the same way a row dropped into a group takes that group's membership: a
   pinned row dropped below an unpinned one unpins, and the reverse pins. The
   rule is a boundary you can drag across rather than a line that snaps your drop
   back where it came from.

4. Hover-only does have an answer on touch, and the codebase had already given
   it: below 720px .row-actions stops being hover-only and .age gives up its
   space, with a comment saying why it had to — hover-only controls "a touch
   device can never satisfy: on a phone they were simply unreachable". The pin
   joins that strip and inherits all of it. Measured at 390px: every row's
   controls visible, nothing overflowing. The tap target is 25px, the same as the
   archive x beside it; that is below the usual guidance and this change does not
   make it worse, but enlarging the strip is a separate decision.

The empty state renders no block at all rather than an empty one, so there is no
stray rule and no gap when nothing is pinned.

Server-side the state is stored beside the archive state: pinnedAt on the session
and on the group record, with a route per direction rather than a toggle so a
double click cannot flip it to the opposite of what was on screen. A group's pin
is set through its own function rather than through update(), which is the shape
the tree editor sends and would otherwise clear a pin it never knew about.

The rules live in web/src/lib/pinned.ts rather than spread across three
components, because every one of them is a judgement with a cheaper wrong answer
that would pass a casual read. Both suites are mutation-tested: the web one fails
when a pinned group stops carrying its members, when lifting duplicates a row,
when nested pins stop rising, and when the manual order is not preserved; the
server one fails when archiving leaves the pin set, when pinning blocks archiving,
when a rename wipes a group's pin, and when a group's pin is not persisted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Operator, after seeing the alternative built: a session inside a group should
not be pinnable at all, because the UX of lifting one out of its group is odd.
They are right, and this is a deletion rather than a new branch of logic.

Gone: liftedSessions(), the append-lifted-rows loop in partitionByPin, the
`from` argument on SessionRow, the `.from-group` tag that told a lifted row
which group it came from, the CSS for it, and the tests that covered all of it.
In its place is one predicate — canPin(ref, groups) — true for a group or a
session in no group. The rules module's code went from 51 lines to 44 and lost
an exported mechanism in exchange for a predicate.

The three edges:

The pin control on a grouped row is ABSENT, not disabled. Pinning a group is a
group-level action, so it lives on the group's header alongside rename,
hide-from-overview and delete-group — none of which appear on a member either. A
disabled pin on every member would repeat what the header's single pin already
says, once per member, and argue back when clicked. On touch, where every row's
controls are visible at once, the layout teaches the rule by itself: pins on
group headers and ungrouped rows, never on a member.

Dragging into a group clears the pin, by the same rule that already governed the
boundary above — a dropped row takes the pin state of where it lands, and it has
landed somewhere a pin cannot exist. pinAfterDrop answers both boundaries now
instead of one. Without this the pin would sit there invisibly and come back the
moment the row was dragged out again.

A stray pinnedAt on a grouped session is ignored rather than honoured, wherever
the pin is read: it buys no exemption from the idle window, and a grouped
session reads as unpinned for drag purposes so a leftover value cannot resurface
on the way out. It cannot reach the pinned block at all — `order` holds
top-level refs only, so a grouped session is not in it and there is no code left
that could draw one there. Checked against a real leftover record from earlier
testing: the row stayed with its group and had no pin control.

Enforcing this by clearing the pin server-side wherever a session joins a group
would mean touching six call sites for a value that is already inert at every
point it is read. The reader-side rule is the smaller expression of the same
invariant, so that is where it lives.

Decisions 2, 3 and 4 stand unchanged, as does clearing the pin on a manual
archive. The test suite gained the new rule and lost the old one; it still fails
under every mutation, now including a grouped session becoming pinnable, a stray
grouped pin being honoured, and a drag into a group keeping its pin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@lvwerra lvwerra left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: changes requested.

  1. [Medium — correctness] A hidden member pin becomes active again when the member leaves its group. web/src/components/Sidebar.tsx:463-492 makes a grouped session read as unpinned, but carryPin() only changes the dragged ref. This misses the ordinary asymmetric pairing path: pin session A, then drag unpinned session B onto A to create a group. carryPin({ landsInGroup: true }) examines B, so A keeps its stored pinnedAt while becoming a member. Delete the group, or pull A out beside an unpinned row, and A is loose again; partitionByPin() now reads that old value and lifts A into the pinned block. I reproduced the full state transition against the real server and then saw Chromium render the released A under .pinned-block. The direct session-pin endpoint can create the same stray state while a session is grouped.

    This is the exact impossible-state case the settled design and the comment at Sidebar.tsx:463-466 say must never reappear. The assertion at web/test/pinned.test.mjs:190-196 does not catch it; in fact, its expected null for a grouped session dropped beside an unpinned row is why carryPin() emits no unpin and the stored value survives. Please cover the resulting persisted state/final partition, not only the helper's view of the row while it is still grouped.

  2. [Low — test coverage] The feature suite does not guard two load-bearing integration points claimed in the PR body. In a disposable checkout I disabled the pinnedIds skip in App.tsx (so pinned sessions/groups age out again) and independently removed canPin(...) from the actual session-button render condition (so grouped members get a pin control). node test/pinned.test.mjs and the typechecked production build both still passed. The pure helpers are tested well, but there is no test showing that the archive verdict consumes them or that Sidebar uses the pinnability result. Those regressions need tests that fail when the wiring—not merely the helper—is reverted.

Queued-merge interactions:

  • #145 merges mechanically, but its API facade contract is silently bypassed by all four new routes at server/src/index.js:2483-2510, which register with app.post rather than #145's api.post. On the combined tree I sent a nonempty text/plain body to a pin route: it returned 200 and mutated state, while an existing bodyless command routed through the facade returned the #145 policy's 415. The combined #145 boundary/API/pin suites all remained green, so its route inventory does not catch newly added raw registrations. When these branches are integrated, the pin routes need to join that facade and its inventory.
  • #139 currently has a content conflict with this branch in App.tsx, around the archive verdict. The resolution must retain both its unread work and this branch's pinnedSessionIds computation/skip; taking #139's side would silently remove the ageing exemption. The effective merge with current origin/main (ef08e84) is otherwise clean and builds.

Verification: branch exercised with a real local server and headless Chromium at 1200px and 390px; touch actions were visible, page overflow was zero, and no-pin state rendered no .pinned-block. web: 22/22 discovered suites, render suite, and production build pass. server: pin suite 20/20 plus all suites after the known cron failure pass; the aggregate stopped only at the acknowledged crons.test.mjs #4 failure. The synthesized current-main merge also builds and passes both focused pin suites.

Agent Manager and others added 2 commits September 10, 2026 20:04
Review found that making a grouped session merely READ as unpinned was not
enough. The suppression is conditional on the session being grouped, so a
stray `pinnedAt` comes back to life the moment it leaves — and it can leave
several ways: dragged out, the group deleted, an anchor move, a tree edit
that drops it. Guarding the read side covered none of those exits.

The value now never exists. Membership is written in exactly two places —
groups.attach() and groups.update() — so both clear any pin on the sessions
they take in, which covers every route that groups a session (pair, into,
adjacent-to-a-nested-anchor, spawn-into-group, the tree editor). The other
way a member could acquire one was the API: POST /api/sessions/:id/pin now
refuses with 409 for a grouped session. The sidebar never asks, since it
leaves the control off a grouped row; this answers agents and direct callers.

Records written before this carry strays already, so groups.init() clears
them on load rather than leaving a pin that springs back on the way out.

The reviewer's own repro is covered: pin A, drop unpinned B onto A to pair
them (the sidebar's carry-pin examines B, never A), then delete the group —
A comes back loose and unpinned. Each entry and each exit is asserted
separately, plus the load-time reconcile from hand-written state.

None of the read-side guards changed: they are now belt-and-braces rather
than load-bearing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Second review finding: lib/pinned.ts is unit-tested, but `canPin(...)` could
be deleted from the row's render condition and every suite plus the
typechecked build stayed green — while a grouped session grew a pin control,
the impossible state the design exists to prevent.

Renders the real Sidebar over a tree with an ungrouped session, a group, and
a member carrying a stray pin, then reads the DOM: the loose row offers a
pin, the member offers none, the group's header does, and the member's stray
value opens no pinned block. Reverting canPin() out of the condition fails
the second check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@lvwerra

lvwerra commented Sep 10, 2026

Copy link
Copy Markdown
Member Author

Both findings addressed in 8edd53e and 6acd807.

1. Hidden member pin reactivating on the way out — fixed at the source

You were right and my earlier reasoning was wrong. I argued a stray pinnedAt on a grouped session was inert everywhere it is read, so clearing it server-side wasn't worth it. It is inert only while the session is grouped — every read that suppresses it is conditioned on exactly that, so all of them stop applying at once when the session leaves. Guarding the read side protected no exit at all.

So rather than patch the exit you found, the value no longer exists. Membership is written in exactly two places, groups.attach() and groups.update(), and both now clear any pin on the sessions they take in. That is a genuine choke point — every route that groups a session goes through one of them: pair, into, before/after against a nested anchor, spawn-into-group, and the tree editor's whole-list PUT.

You also noted the direct endpoint can create the same state. That's the other half, so POST /api/sessions/:id/pin now answers 409 for a grouped session. The sidebar never asks — it leaves the control off a member row — so this is for agents and direct callers.

Third: records written before this already carry strays, so groups.init() clears them on load. Otherwise the bug simply persists for anyone who has already hit it.

Going through the exits you listed, none needs its own guard now — dragged out, group deleted, group dissolved, anchor move, tree edit all start from a session that has no pin to resurrect.

Tests (server/test/pin.test.mjs, +12 checks against the real server). Entries and exits are asserted separately:

  • your exact repro — pin A, drop unpinned B onto A so the carry-pin never examines A, then delete the group and confirm A comes back loose and unpinned;
  • pinning a member via the API is refused and writes nothing;
  • a tree edit that adds a pinned session clears it, and pulling it back out to the top level keeps it clear;
  • move … into, and the nested-anchor form;
  • a stray written directly into sessions.json is cleared on the next boot, without disturbing membership.

I reverted each of the four parts in turn: dropping the attach clear fails 4 checks (including both exit assertions), the update clear fails 2, the load reconcile 1, the 409 guard 2.

I did not touch any of the read-side guards or carryPin. They're now belt-and-braces rather than load-bearing, and the group-only behaviour is unchanged.

2. Untested wiring

Half done, and I want to flag the half I left.

Sidebar → canPin: done. web/test/pinnedWiring.render.test.mjs renders the real Sidebar over a tree containing an ungrouped session, a group, and a member carrying a stray pin, then reads the DOM: the loose row offers a pin, the member offers none, the group header does, and the member's stray value opens no .pinned-block. I confirmed your mutation — deleting canPin(ref, tree.groups) from the render condition — fails it. It runs in the default npm test (23 suites now), not behind test:render, since a guard nobody runs isn't one.

Archive verdict → pinnedSessionIds: not done, deliberately. The decision lives inline in a useMemo in App.tsx, so the only ways to cover it are mounting App (1282 lines, 18 effects, every pane and * as api imported — the stub surface would be larger than the test and flaky) or extracting the quiet computation into a lib function and unit-testing it. Extraction is the right answer and it's small. What stopped me is your own note that #139 conflicts with this branch in App.tsx around exactly the archive verdict: moving those lines now changes the shape of a resolution someone else has to do, over code that is already the delicate part of that merge. I'd rather do it right after #139 lands than hand the integrator a moved target. Say the word if you'd prefer it now and I'll do it in a follow-up commit.

Queued-merge notes

Untouched, as they're integration-time work: the four pin routes still use app.post rather than #145's api.post facade — worth adding to that inventory when the branches meet, and note my new 409 lives on one of them — and the #139 App.tsx conflict still needs both its unread work and this branch's pinnedSessionIds skip.

Status

server: 24 suites pass individually; crons.test.mjs #4 fails identically on main (the aggregate runner stops there, which hides the rest). web: 23/23 plus the typechecked production build. Not merged, not deployed.

lvwerra added a commit that referenced this pull request Sep 11, 2026
@lvwerra
lvwerra merged commit 244c076 into main Sep 11, 2026
@lvwerra
lvwerra deleted the feat/pinned-sessions branch September 11, 2026 13:07
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