Skip to content

Add batch pin writes, update-by-id, bulk delete, and collapsible groups - #946

Merged
selfcontained merged 5 commits into
mainfrom
agt_58b788799838/build-pin-bulk-ops-grouping-affordances
Aug 12, 2026
Merged

Add batch pin writes, update-by-id, bulk delete, and collapsible groups#946
selfcontained merged 5 commits into
mainfrom
agt_58b788799838/build-pin-bulk-ops-grouping-affordances

Conversation

@selfcontained

Copy link
Copy Markdown
Owner

Managing many pins scaled badly for an agent: no batch write, and label was the only match key for an update — so even a pure relabel meant deleting and recreating each pin with every decoration restated. The concrete trigger was 42 tool calls to relabel 21 pins.

Pins are already addressed by id everywhere except the write path — you can list one by id, delete by id, and inject a shortcut's prompt by id (that last one is the whole trust boundary for POST /terminal/inject-pin/:pinId). Only upsertPin couldn't. That asymmetry is what forced delete-and-recreate: changing a label changed the only handle the writer understood.

What's new

dispatch_pin takes an optional id. With one, the pin is matched by id and label becomes an ordinary editable field — a rename is a single-field patch. Without one, behaviour is byte-for-byte what it was, so no existing usage shifts.

New dispatch_pins writes a batch in one transaction. Applying N pins through upsertPin costs N BEGIN/SELECT … FOR UPDATE/COMMIT cycles, N getAgent reads, and N agent.upsert events; batched it's one of each, and a failure can't leave the set half-applied. The 42-call relabel becomes one call.

Mode replace requires a group and rebuilds exactly that group in the order given. There is deliberately no whole-list replace — every destructive batch must name the group it's allowed to clear, so no call can delete a pin the agent forgot to restate. The rebuilt block is anchored where the group already sat, so replacing its contents doesn't make it jump position.

dispatch_delete_pin also takes ids or group for bulk removal.

Group headings collapse, with a member count. Groups over 8 pins start collapsed; an explicit choice always beats that default and persists per agent and group. Keying on agent id rather than name means it survives a session rename.

One bug worth calling out

type is now optional rather than defaulting to "string". The default made a relabel silently demote a shortcut pin to a plain string and strip its icon and variant — which would have broken the exact workflow this PR exists to fix. The unit tests were green when this was happening; it only surfaced from exercising the tools against a dev instance. An omitted type now inherits from the stored pin, and the value is validated against the resolved type rather than the request's, so an update can't sneak a bad value past the validator by omitting the type.

A second thing the tests couldn't have caught: upsertPins wasn't threaded through the MCP route context, so registerBatchPinTool returned early and the tool never registered on a real server. Also found live.

Notes

  • No migration. Migration 0036 already backfilled ids onto every pin, and all three write paths assign one — toPinListing throws without an id, so an id-less pin would already be breaking dispatch_list_pins.
  • Array-level write logic moved to pin-write.ts so the single and batch paths share one definition of matching, merging, and validation; a batch can't accept a shape dispatch_pin would reject.
  • Cross-group ordering stays implicit (a group renders where its first member sits). Controllable via insertion order, and nothing has been bitten by it.
  • Tool surface grows by one. dispatch_pins' description points at dispatch_pin for field semantics rather than restating them, since every agent pays that context cost.

Verification

pnpm run check, pnpm run test (156 server + 58 web files), pnpm run test:e2e (180 passed), pnpm run finalize:web all green. Exercised end-to-end against a dev instance over the real MCP endpoint: batch create, relabel-by-id preserving type/icon/variant/group, group-scoped replace leaving outside pins untouched, replace-without-group rejected, unknown id rejected, bulk delete by group. Collapse verified in-browser at 1440px and 390px, including the sticky-choice-across-reload path and the two mounted sidebar instances staying in sync.

🤖 Generated with Claude Code

selfcontainedand others added 5 commits August 12, 2026 09:10
Managing many pins scaled badly: there was no batch write, and `label`
was the only match key for an update — so even a pure relabel meant
deleting and recreating each pin with every decoration restated. The
concrete trigger was 42 tool calls to relabel 21 pins.
Pins are addressed by id everywhere except the write path: you can list,
delete, and inject a shortcut by id, but not update one. That asymmetry
is what forced the delete-and-recreate, since changing a label changed
the only handle the writer understood.
- `dispatch_pin` accepts an optional `id`. With one, the pin is matched
by id and `label` becomes an ordinary editable field, making a rename
a single-field patch. Without one, behaviour is unchanged.
- New `dispatch_pins` writes a batch in one `mutatePins` transaction —
one lock, one write, one `agent.upsert` event instead of N of each.
Mode `replace` requires a `group` and rebuilds exactly that group in
the order given. There is deliberately no whole-list replace: every
destructive batch must name the group it may clear, so a call cannot
delete a pin the agent forgot to restate.
- `dispatch_delete_pin` also takes `ids` or `group`.
- Group headings in the sidebar collapse, with a member count. Groups
over 8 pins start collapsed; an explicit choice always wins over that
default and persists per agent and group.
`type` is now optional rather than defaulting to "string". Defaulting it
made a relabel silently demote a shortcut pin to a plain string and drop
its icon — found by exercising the tools against a dev instance, not by
the tests. An omitted type inherits from the stored pin, and the value is
validated against the resolved type rather than the request's.
Array-level write logic moves to `pin-write.ts` so the single and batch
paths share one definition of matching, merging, and validation.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…gaps
From the frontend-ux and backend-security persona reviews of #946.
- `collapseScope` no longer falls back to a literal "default" bucket.
It is `string | null`; `null` keeps collapse ephemeral rather than
persisting under a shared key, and agent history now passes its
`agentId` so its groups are namespaced like everywhere else.
- The group heading's `aria-labelledby` now targets the name span rather
than the whole button, so the group is announced as "Ready to build"
and not as the collapse action plus its count. The member region stays
mounted with `hidden` so `aria-controls` always resolves; its members
still unmount while collapsed.
- `replacePinGroup` resolved every spec against the original array, so
two entries creating the same new label both looked unmatched and both
were appended — breaking case-insensitive label uniqueness, which the
sidebar and label-addressed updates depend on. Uniqueness is now
asserted over the finished array.
- `dispatch_delete_pin` accepted `group: ""`. Since a missing group
compares equal to "", that deleted every ungrouped pin. Blank group
names are now rejected at the schema (trim + min length) and again in
`removePinGroup`/`replacePinGroup`, so no caller can reach the
destructive path without naming a group.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`type` was made inheritable so a relabel couldn't silently demote a
shortcut pin, but `value` was left required — so a rename still restated
the prompt it wasn't changing, and "rename is a one-field patch" wasn't
actually true. Same gap, same fix.
An omitted `value` now inherits from the pin being updated. It stays
mandatory when creating, since a pin with no value has nothing to
display; that check moves into `toStorablePin`, which is the one place
that knows whether an update or a create is happening.
Validation still runs on the merged pin, so an update that changes only
the type re-checks the value it inherited.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
From the architecture-review persona. Two were real defects.
**Create never stripped shortcut-only fields.** `mergePin` enforced "a
non-shortcut pin carries no shortcut decorations" on the update branch
only; both create branches bypassed it. Before `type` became optional
the handler's isShortcut gate hid this, but an omitted type now lets
icon/variant/confirm/disabled through — so dispatch_pin({label, value,
confirm: true}) stored a string pin with confirm on it, and since
variant/confirm/disabled aren't clearable by empty string the agent
could never remove them. The strip moves out of `mergePin` into
`finalizePin`, which every write path runs.
**`replacePinGroup` didn't file members under the group.** It rebuilt
positions but never wrote `group`, so correctness depended on a
compensating map in `handleUpsertPins` — the primitive couldn't honour
its own name, and every existing test passed `group` on the specs, so
they encoded the caller's behaviour rather than the module's contract.
It now applies the group itself and the handler's map is gone.
Also:
- Hoisted `pinFields` so the two tool schemas share their constraints
instead of duplicating them; only descriptions differ per tool.
- `upsertPins` no longer returns a `pins` field its caller ignored, and
the batch echo is a thin {id, label, group} projection — 50 pins of
full values to convey ordering was the wrong trade.
- `PinGroup` branches above the hooks into Persisted/Ephemeral wrappers
over a presentational view, rather than calling both `useAtom` and
`useState`. Drops the placeholder atom that was never written through.
- Group DOM ids come from `useId`. Both sidebars are always mounted, so
name-derived ids collided and the new `aria-controls` resolved to the
other instance's region.
- Narrowed `PinSpec["type"]` to `PinType`, so the handler's check is
carried by the compiler instead of being re-run downstream.
- Moved two JSDoc blocks back onto the symbols they describe.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Hoisting `pinFields.group` collapsed two rules that only looked alike.
A pin's own group must accept "" — that is the documented way to clear
it, and `CLEARABLE_FIELDS` handles it downstream — but the hoisted field
carried the `.trim().min(1)` needed by the *scoping* params, so clearing
a group became impossible. A regression introduced by the dedup itself.
`pinFields.group` is now `.max(100)` only and used by the per-pin fields;
`pinFields.scopingGroup` keeps the strict rule and is used by the two
places where a blank name would widen a delete into "every ungrouped
pin". Added a test for the clear path, which nothing covered.
Also point `McpRequestContext.upsertPins` at `PinSummary[]`. It still
declared `PinListing[]` after the echo was thinned; the deps thread
through as `unknown`, so the boundary type was quietly wrong rather than
failing the build.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@selfcontained
selfcontained merged commit 188122a into mainAug 12, 2026
1 check passed
@selfcontained
selfcontained deleted the agt_58b788799838/build-pin-bulk-ops-grouping-affordances branch August 12, 2026 20:10
selfcontained added a commit that referenced this pull request Aug 13, 2026
The #945/#946 wave added five tools and changed how three others are
addressed. #945 removed get_agent_history and get_parent_context from
every doc surface in the same commit that introduced
dispatch_review_get_feedback, brain_get_event, brain_get_list_item and
whiteboard_howto — so the lists looked freshly maintained while being
short by five entries.
- docs-pane Repo Tools "Built-in tools": the five new tools, plus pin
update-by-id, dispatch_pins batch writes with merge/replace, delete by
id/ids/group, and reading one pin back whole by id. A closing paragraph
states the list/detail policy response.ts now encodes, so an agent
reading the docs knows a truncated listing has a matching full read.
- dispatch_launch_agent: with a templateId the template's own prompt is
now rendered and filled from templateArgs (#941) — it was previously
used only for worktree settings, and the bullet still described it as
just "or template".
- media.tsx Pins tab: pin groups collapse under a heading with a member
count, groups over eight start collapsed, and the choice persists per
agent and group (#946).
- automations.tsx job-agent lists: the new tools, plus persona_templates
/ persona_upsert / persona_validate and dispatch_archive_agent, which
are in JOB_TOOLS but were never enumerated there.
- README interactive-agents table and the persona-agents list.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for freeto 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

@selfcontained