Pinned sessions and groups

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. A session inside a group cannot be pinned — its group is the thing you pin. Screens are the running branch, not a mock.

What it looks like

Both kinds of pin. The group release pinned whole, and the ungrouped session infra. The group fleet and the ungrouped deploy are unpinned, below the rule. Members stay with their group wherever it sits.
Nothing pinned. No rule, no empty block, no gap. The block is not rendered at all when it would be empty, so there is nothing to leave behind.
390px. The controls are always visible on touch — see question 4.

The controls — hover-only on a pointer, beside the archive ×

Hovering an unpinned row. A plain pin, then the archive × that was already there. Pin comes first: the reversible everyday control before the one that files the agent away.
Hovering a pinned row. The pin is struck through — an unpin. Same control, two states, the way the operator asked.
Hovering a group. The pin joins the group's own strip, ahead of hide / rename / delete.
Hovering a row inside a group. Archive only — no pin, and not a disabled one. Pinning a group is a group-level action, so it lives on the header with rename, hide and delete, none of which appear on a member either.

The exemption is narrow, on purpose

There are two roads into the archived view and they mean different things. Pinning blocks one of them and leaves the other completely alone.

RoadWhat it isPinning
The idle window (archive.after)Derived. "Nothing has happened here for a month." Changes the moment 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 is the only road that unlocks delete.Untouched. A pinned session can still be archived deliberately — and doing so clears the pin.

Archiving clears the pin because the alternative is a record that asserts "keep this in front of me" and "I am done with this" at the same time. The later statement is the true one. Restoring it later brings it back unpinned rather than silently re-pinning something the operator last saw as finished.

The four decisions

1. What can be pinned

An ungrouped session, or a whole group. A session inside a group cannot be pinned at all — decided by the operator after seeing the alternative built.

OptionCost
A grouped session cannot be pinned. Its group is what you pin.The pin is unavailable on a row that has one; you pin the group instead, which moves its siblings toochosen
It rises out of its group to the pinned block, tagged with the group it came fromA group that visibly shows one fewer row than it has members, and a row that appears to have left the group it is still in. This was built, reviewed, and rejected as odd to use.rejected
Shown in both placesTwo live rows on screen for one session, each with its own state and controlsrejected
It stays in place, pinned but unmovedPinning that does nothing visiblerejected

The lifting mechanism this replaced is gone rather than disabled — no code, no affordance, no tests for a behaviour that no longer exists. What remains is one predicate: a ref can be pinned if it is a group, or a session in no group.

The control is absent, not disabled. A disabled pin on every member would repeat what the group 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.

2. Pinning a group

It holds the group's place and exempts every member from the idle window. Position alone is not enough: a pinned group whose agents each age out on their own schedule empties one row at a time and then disappears entirely — the sidebar drops a group once all its agents are archived. Pinning it would have caused exactly what it was asked to prevent.

Members do not get a pinnedAt of their own; membership is what carries them. So unpinning the group hands every member straight back to the ordinary rules, and a member who was also pinned in its own right stays pinned.

3. Ordering — how pin and drag compose

Pinning does not add a second ordering. It partitions the one manual order and keeps each half in it, so there is nothing for the two to disagree about. Drag a row within a block and it reorders exactly as before.

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:

The rule is a boundary the operator can drag across, rather than a line that silently snaps their drop back where it came from. Dropping onto a group is not a neighbour relationship and is left alone: membership decides which block draws it from then on.

4. Hover-only has no answer on touch

It does here, and the codebase had already answered it. Below 720px, .row-actions stops being hover-only and becomes permanently visible — the stylesheet already does this, with a comment saying why it had to for the group controls: "hover-only … a touch device can never satisfy: on a phone they were simply unreachable." The .age column gives up its space to make room. The pin joins that strip and inherits all of it.

MeasuredDesktop (1120px)Touch (390px)
controls at restopacity 0 on every rowopacity 1 on every row
controls on hoveropacity 1 on the hovered row onlyn/a
row overflow at 390pxnone, on any row

One honest caveat: the pin's tap target is 25px, the same as the archive × beside it. That is below the usual 44px guidance — but it is the size these controls already are, and this change does not make it worse. Enlarging them is a separate decision about the whole strip.

The two edges that come with it

Dragging into a group clears the pin. Same rule as the boundary above: a dropped row takes the pin state of where it lands, and it has landed somewhere pins cannot exist. Without this the pin would sit there invisibly and reappear the moment the row was dragged back out.

Dragging out of a group leaves it unpinned. A grouped session reads as unpinned wherever the pin is consulted, whatever its stored record happens to say — so coming 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. Such a record describes a state the rule says cannot exist — written before the session joined a group, or by a path that did not clear it. It buys no exemption from the idle window, and it cannot put the row above the rule: order holds top-level refs only, so a grouped session is not in it and there is no code that could draw one there. Verified against a real stray record left over from testing: the row stayed with its group and had no pin control.

Where the state lives

Stored, next to the archive state, exactly as asked — pinnedAt on the session record and on the group record. Not derived, so it survives a restart and means the same thing on every device. Four routes: POST /api/sessions/:id/pin and /unpin, POST /api/groups/:id/pin and /unpin. One call 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 through update(), which is the shape the tree editor sends — otherwise renaming a group would clear a pin it never knew about. There is a test for exactly that.