Files
pad/web
xarmian e5eae5e94e feat(web): persistent dismissible CLI-connect banner on workspace pages (TASK-862) (#284)
* feat(web): persistent dismissible CLI-connect banner on workspace pages (TASK-862)

Final web piece of the web-first onboarding on-ramp from PLAN-859 / IDEA-750.
A slim banner now nudges users to connect their workspace to the CLI on
every workspace page, until they either dismiss it or actually do it.

Server:

- New store method WorkspaceHasCLISource(workspaceID) — backed by
  EXISTS(... WHERE source='cli' AND deleted_at IS NULL), so it's a
  cheap O(1) check that short-circuits on the first match.
- Dashboard payload (GET /workspaces/{ws}/dashboard) gains
  HasCLISource bool (json: has_cli_source).
- Unit tests cover empty workspace, web/skill items don't trip it,
  one cli item flips it on, soft-delete flips it back off, and
  cross-workspace isolation.

Web:

- New <ConnectBanner> Svelte 5 component
  (web/src/lib/components/ConnectBanner.svelte). Self-contained:
  reads dismissed state from localStorage, fetches has_cli_source
  itself, mounts <ConnectWorkspaceModal> internally. Two split
  $effect blocks per CONVE-606 — one for the localStorage sync, one
  for the dashboard fetch — so a workspace change doesn't entangle
  the two reactive lifecycles.
- Banner is hidden while loading (hasCliSource === null) to avoid a
  flash-then-auto-hide on workspaces that already have CLI items.
- Storage key pad-cli-banner-dismissed-${wsSlug} matches the existing
  onboarding-dismissed pattern. Per-browser only; TODO comment in
  source about backing it with a workspace_user_state row if cross-
  device persistence is wanted later.
- Mounted in web/src/routes/[username]/[workspace]/+layout.svelte
  above {@render children()} so it appears on every workspace page
  (dashboard, collection lists, item detail, search, activity, etc.)
  and NOT on console/auth pages (the layout is workspace-scoped).
- DashboardResponse type in web/src/lib/types/index.ts gains
  has_cli_source: boolean.

Smoke-tested against the running server: the field is live in the
dashboard payload and reflects reality (this workspace returns
has_cli_source: true since it has many CLI-sourced items, so the
banner is correctly auto-hidden here).

Test plan:
- go build ./... && go test ./... — all green (incl. new
  TestWorkspaceHasCLISource with 5 sub-cases).
- cd web && npm run build — clean.
- make install — clean, server restarted.
- Svelte MCP autofixer ran on ConnectBanner.svelte — no issues.

Parent: PLAN-859. Driving idea: IDEA-750.

* fix(web/connect-banner): stale-response guard + refetch on modal close (Codex round 1)

Two findings from Codex review on PR #284:

1. Stale-response race: rapid workspace switches could let a slow
   dashboard fetch from workspace A overwrite hasCliSource for
   workspace B after the user navigated. Capture the requested slug
   at fetch time, ignore the response if wsSlug has changed since.

2. Auto-hide didn't work in-session: if a user opened the banner
   modal, copied the command, ran it elsewhere, and closed the modal,
   the banner stayed visible because hasCliSource was stale. Refetch
   when the modal transitions from open → closed (the natural moment
   the user has just connected). Uses $effect.pre with a tracked
   previous value, matching the transition pattern in ShareDialog.

The 'someone ran the CLI from another terminal without ever opening
the modal' edge case is left for a follow-up — would require SSE
item-created subscription, which is heavier than this PR's scope.

* fix(server/items): persist source from auth context on create (Codex round 2)

Codex caught an architectural bug while reviewing the TASK-862 banner
work: items created via the CLI were persisting with source='web'
(the column default) instead of 'cli', because handleCreateItem decoded
ItemCreate from the body — which the CLI doesn't set Source on — and
only consulted actorFromRequest AFTER persisting (for SSE / activity
log emission). Result: TASK-862's has_cli_source dashboard signal
would never flip on for normal CLI usage, so the connect-CLI banner
would never auto-hide for users who actually wired up the CLI.

Fix: in handleCreateItem, backfill input.Source from actorFromRequest
before calling store.CreateItem, but only when the client didn't
explicitly set it (so agents marking themselves as 'skill' still
pass through unchanged).

Test: TestCreateItemSourcePersistedFromAuth covers all three branches
- bearer Authorization header → source=cli (uses bootstrap + a real
  session token in the header since the auth middleware validates
  token format and rejects fake values with 401 before the handler
  runs)
- cookie session, no Authorization → source=web
- explicit source in body wins over auth-derived (e.g. 'skill')

* fix(web/connect-banner): seq counter for same-workspace race (Codex round 3)

Round 3 caught a same-workspace race the slug guard didn't cover: an
in-flight workspace-change fetch that resolves AFTER the modal-close
refetch could overwrite the newer 'true' with the older 'false',
making the banner reappear after the user actually wired up the CLI.

Add a monotonic fetchSeq counter — captured at call time, rechecked
before applying the response. Only the LATEST request's result wins,
regardless of arrival order. The slug guard stays as a second-layer
defense for cross-workspace races.

* fix(web/connect-banner): guard banner keydown to currentTarget (Codex round 4)

Round 4 caught a keyboard-event bubble: pressing Enter or Space on
the dismiss X button also fired the banner-level keydown handler,
so the user would dismiss AND open the modal in one stroke.

Guard the parent handler with `e.target !== e.currentTarget` so it
only reacts to keydown that originated on the banner itself. Tabbing
to the dismiss button + Enter now ONLY dismisses.

* fix(store): visibility-filter has_cli_source query (Codex round 5)

Round 5 caught a P2 information leak: WorkspaceHasCLISource scanned
the entire workspace regardless of caller visibility, so a guest
with grants only on web-sourced items could still see has_cli_source
return true (revealing that CLI items exist somewhere they can't see).
That also produced wrong UX — the banner could auto-hide for guests
who couldn't actually use the CLI.

Extend the query to take optional collectionIDs/itemIDs filters
matching the dashboard's existing visibility model: an item counts
when its collection is in collectionIDs OR its id is in itemIDs
(union — guest item-level grants can expose items in otherwise-
hidden collections). Mirrors ListItems' filtering pattern incl. the
"non-nil empty CollectionIDs = no visibility = short-circuit false"
semantics.

Handler now passes dashCollIDs and dashItemIDs to match the rest of
the dashboard payload's filtering. New TestWorkspaceHasCLISourceVisibility
covers the four cases: unfiltered sees all, visible-coll-only hides
CLI items in hidden collections, item-level grant surfaces a hidden
CLI item, and empty visibility short-circuits to false.
2026-04-29 10:05:34 -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
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