Files
pad/web
xarmian bfa90826e9 feat(web): quick actions push to a connected agent session (TASK-2562) (#1103)
* feat(web): quick actions push to a connected agent session (TASK-2562)

PLAN-2558 S4. `resolvePrompt()` already did the templating push needed
(`{ref} {title} {status} {priority} {collection} {content} {fields} {plan}
{phase}`); only the last hop was a clipboard ferry. That hop is now a push,
and the clipboard becomes the fallback rather than the mechanism.

Zero-touch migration, as the task required: quick actions are still
`{label, prompt, scope, icon}` in collection settings. No settings rewrite,
no schema change, no per-action opt-in.

Routing, per the plan's ruling — with zero live sessions, fall back to the
clipboard with an honest toast, never a hard error and never a queue:

  session(s) live   push the collapsed prompt; toast hedges ("delivery isn't
                    confirmed") because a push gets no ack
  zero sessions     copy, "No agent session connected — copied to clipboard
                    instead"
  can't tell        copy. This is where S4 DIVERGES from S3's dialog, which
                    leaves Send enabled on an unreadable presence answer. The
                    dialog is right to: the warning is on screen and the user
                    chooses with it in front of them. A quick action asks
                    nobody, so the tie goes to the lossless branch — copying
                    when we could have pushed costs one paste, pushing into
                    nothing loses the instruction outright.
  no item to        collection-scope actions keep the pre-S4 behavior exactly.
  address           The endpoint is POST .../items/{slug}/push and there is
                    nothing to point it at, so they don't even spend a
                    presence read finding that out.

PRESENCE IS READ WHEN THE MENU OPENS, NOT ON THE CLICK, and that is the one
non-obvious thing in this diff. Both clipboard APIs want the user gesture
that is live during the click handler and gone after a network round-trip
(Safari strictest, Firefox too). Deciding from a read issued on the click
would put an await in front of the very fallback this slice promises. The
cost is a small window — click before the first read lands and presence is
null, which routes to the clipboard with an honest toast — and that is the
right way round.

The menu's footer line now says which way the next click will go, so the
routing is visible before it happens rather than only in the toast after.
A logged-out or workspace-token viewer gets a 401 from /sessions, which is
"can't tell", which is the clipboard — today's behavior, no gating needed.

Push failure splits on the same line CopyItemDialog and the S3 composer draw
(DR-13): a recognised pre-publish refusal means nothing went out, so the copy
is OFFERED as a toast action (a fresh gesture, which is what makes a
clipboard write work this long after the original). An unrecognised failure
leaves the outcome unknown — the handler publishes BEFORE it writes its
response — so nothing is offered, because a paste would be the duplicate the
message is warning about on an endpoint with no idempotency key.
PRE_PUBLISH_ERROR_CODES moved out of PushToAgentDialog into
$lib/push/dispatch so the two surfaces can't drift on it.

Also: the local `copyToClipboard` is replaced by `$lib/utils/clipboard`'s.
The local one returned true from the promise path WITHOUT awaiting it, so a
rejected write reported success and never reached the execCommand fallback —
harmless when copying was a convenience, not harmless now that "we copied
instead" is a load-bearing claim.

Verified live against a throwaway instance (built binary, real browser),
three legs, with the SSE stream as the receipt:

  no session     tagline "No agent session connected — actions copy to your
                 clipboard"; toast matches the ruling; clipboard holds
                 "Implement TASK-9: Ship the thing (status open)"
  one session    tagline "Pushes to your connected agent session"; the
                 connected stream RECEIVED {"kind":"push","item_ref":
                 "TASK-9","summary":"Implement TASK-9: ..."}; clipboard
                 untouched
  presence 503   same live session still connected, /sessions aborted: copies
                 instead, and the stream's push count did NOT increase — the
                 counterfactual, not just the end state

Each of the five behaviours is mutation-tested 1:1 against its test: an
await before the copy fails ONLY the synchronous-gesture test; routing
'unknown' to push fails only the two uncertainty tests; dropping the collapse
fails only the raw-vs-collapsed test; offering a copy on an unconfirmed push
fails only that test; copying on the happy path fails three.

Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd

* fix(web): expire a stale presence answer in the quick-actions menu (codex round 1)

Codex's one finding, and it is real. A FAILED poll already degrades to
'unknown'; a poll that HANGS does not — it simply never writes, so the last
count stayed in place indefinitely while the menu went on offering a push
into a session that may have dropped minutes earlier. That is the one
direction that loses the user's instruction, which is the whole thing this
slice exists to prevent.

A 'known' answer now expires after 30s without a refresh — the server's own
worst-case presence staleness (watchEventsKeepaliveInterval), the same bound
and the same reasoning as PushToAgentDialog's round-2 fix. Requests already
in flight at the moment of expiry are retired (presenceAppliedSeq advances to
presenceSeq) so one issued BEFORE the expiry cannot land after it and restore
the very count we just declared too old to trust.

The expiry is checked in TWO places, and the second is the one worth noting:
the poll tick rewrites the state (so the footer line stops claiming a
connection), but the ROUTING decision reads through `currentPresence()` at
click time. A tick-only expiry leaves a window of up to one whole poll
interval in which the menu still pushes against a count it has already
outlived — and a click is exactly what lands in that window.

Both halves mutation-tested: disabling the expiry fails the two staleness
tests and leaves the control leg (polls still landing → no downgrade) green;
reading raw `presence` at click time instead of `currentPresence()` fails
ONLY the between-ticks test.

npm run check 0 errors · web unit suite 1607 passed.

Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd

* fix(web): the offered copy reports its own outcome (codex round 2)

Round 2's one finding, and it is real. `Copy instead` on a push-failure toast
discarded the `copyToClipboard()` result, and taking the offer dismisses the
toast that carried it — so a failed copy said nothing at all. That silence is
worst on exactly this path: a pre-publish refusal means the instruction was
never sent, so a silently-failed copy leaves it neither sent NOR copied, with
the user believing they rescued it.

The offer now routes through the same `copyAndAnnounce` every other clipboard
path uses, under a new `'offered'` ClipboardReason that renders the plain
"Copied to clipboard" — the user asked for the copy, so there is no absent
push to explain — and the ordinary error on failure.

Mutation-tested: reverting to the discarded-result form fails the new test
and the existing pre-publish test, and nothing else.

npm run check 0 errors · web unit suite 1608 passed.

Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd

* fix(web): flip the quick-actions footer line when the answer comes due (codex round 3)

Round 3's finding, and it is a self-inconsistency round 1 introduced. The
click-time expiry check made the ROUTING correct immediately, but the footer
line still read raw `presence` until the next 10s poll tick — so for up to a
full interval the menu said "Pushes to your connected agent session" while the
next click would copy. That line's entire job is to say what the next click
will do.

A successful read now arms a one-shot timeout at the exact expiry, so the
display flips when the answer comes due rather than when a poll happens to
notice.

`currentPresence()` STAYS, and the redundancy is the point: a timer is a
request, not a guarantee. Browsers throttle timers hard in a backgrounded tab,
so the expiry can fire long after it came due — including after the user has
returned and clicked. The timer keeps the DISPLAY honest; the click-time check
keeps the DECISION correct, and only the decision can lose a message. The poll
tick keeps its expiry check for the same reason.

Both halves mutation-tested, and they fail different tests: dropping
`armExpiry()` fails ONLY the comes-due test; dropping `currentPresence()` at
the click fails ONLY the throttled-timer test. That second test models
throttling by moving the CLOCK without running any timer — which is exactly
what a throttled tab looks like from the component's side, and is not
reachable with advanceTimersByTime.

npm run check 0 errors · web unit suite 1609 passed.

Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd
2026-08-15 10:03:42 -04:00
..
2026-03-26 01:52:36 +00:00
2026-03-26 01:52:36 +00:00
2026-03-26 01:52:36 +00:00
2026-03-26 01:52:36 +00:00
2026-03-26 01:52:36 +00:00

Pad Web UI

SvelteKit 2 + Svelte 5 frontend for Pad, compiled to static files and embedded into the Go binary.

Development

npm install
npm run dev          # Dev server at localhost:5173 (proxies API to localhost:7777)
npm run build        # Production build to build/
npm run check        # Type checking with svelte-check

When developing, run the Go backend separately with make dev from the project root.

Building for Production

Do not build in isolation. Always use make build from the project root — this builds the web frontend, then compiles the Go binary with the build output embedded via //go:embed.

Stack

  • Svelte 5 with runes ($state, $derived, $effect)
  • SvelteKit 2 with adapter-static (SPA mode)
  • Tiptap block editor with markdown round-trip
  • svelte-dnd-action for drag-and-drop in board/list views
  • SSE for real-time updates
  • TypeScript throughout

Structure

src/
  routes/                    SvelteKit pages
    +layout.svelte           App shell (sidebar + main)
    +page.svelte             Landing/redirect
    [workspace]/
      +page.svelte           Dashboard (collections, phases, activity)
      +layout.svelte         SSE connection per workspace
      [collection]/
        +page.svelte         Collection view (board/list)
      [collection]/[item]/
        +page.svelte         Item detail + editor
      conventions/            Purpose-built conventions page
      playbooks/              Purpose-built playbooks page
      settings/               Workspace settings
  lib/
    api/client.ts            HTTP API client
    components/
      layout/                Sidebar, navigation
      editor/                Tiptap editor, raw markdown editor
      fields/                FieldEditor, relation picker
      items/                 ItemCard, ItemDetail
      collections/           BoardView, ListView
      common/                StatusBadge, badges, modals
      search/                CommandPalette
      activity/              ActivityFeed
    stores/                  Svelte 5 reactive stores
      workspace.svelte.ts    Workspace state
      collections.svelte.ts  Collection + item state
      ui.svelte.ts           Sidebar, mobile state
    types/index.ts           TypeScript types and constants
  app.css                    Global styles and design tokens