Files
pad/web
xarmian 22fce21132 fix(web): workspace identity recovers after a failed cold load, on the condition rather than a signal (TASK-2200) (#1284)
* fix(web): workspace identity recovers after a failed cold load, on the condition rather than a signal (TASK-2200)

Unit A of TASK-2200 — the shell brick. A cold load while the server is
unreachable left the shell permanently navigation-less: the sidebar builds
its links from `workspaceStore.current`, and when the server came back the
board recovered (its own Retry, plus the items cache) inside a shell with no
links at all. Only F5 fixed it and nothing said so.

WHAT NOTHING RETRIED. `workspaces` and `current` are each acquired exactly
once — `loadAll` from the root layout's per-auth-resolution attempt,
`setCurrent` from an effect keyed on a workspace slug that does not change —
and every other caller of either is a user action: a topbar reorder, the
workspace switcher, the create-workspace modal. `setCurrent` cannot self-heal
either: with an empty array it falls back to a single-workspace fetch and
CATCHES the failure into `current = null`.

The audit that filed this blamed `loadCollections`, which is the half that
does have a recovery path. The permanent half is identity, and it is in a
file the item never cites.

GATED ON THE CONDITION, NOT ON `full_refresh`, and that is the measured part.
The obvious home for recovery is the layout's existing `full_refresh` branch.
It is the wrong one: when the server comes back, `/changes` SUCCEEDS, and
because the cursor was seeded during the outage a quiet workspace answers
with nothing to report — so the result is `caught_up`. The type meaning
"nothing was missed" is exactly the one delivered when everything was. A
`full_refresh` arrives only when `/changes` itself fails, which is the case
where the server is still down and recovery cannot work anyway.
`syncPostOutageResultType.svelte.test.ts` pins that reading, with a control
leg proving `full_refresh` is still emitted when it should be — so if a
future change makes a returning server emit it after all, the gating decision
gets revisited rather than inherited.

That correction also lands on this unit's own recon, which claimed collections
"now recover on their own" via TASK-2921's `full_refresh` subscriber. True
only when `/changes` also fails. So the collection list gets the same
condition gate, using the `collectionsAreFreshFor` predicate that already
exists to tell "this workspace's list" from a stale previous one — and a
genuinely empty workspace stamps its slug on success, so it does not re-fire.

THE ROOT LAYOUT FLAG IS RENAMED, NOT RE-SEMANTICS'D. `workspacesLoaded` said
a load had SUCCEEDED while the code set it before the call and never reset
it, so a rejected `loadAll` read afterwards as a completed one. It is now
`workspacesRequested`, which is what it has always meant. Deliberately still
set before the call and deliberately not reset on failure: setting it only on
success would re-arm an effect whose guard READS `workspaceStore.loading`, so
every failed attempt would flip that dependency and re-run the effect — a hot
retry loop against a server that is down. Recovery belongs where it can be
gated on a condition, which is where it now is. The logged-out-mid-redirect
guard above it is untouched.

Tests: `workspaceRecovery.svelte.test.ts` pins the audit's own sequence
(failed cold load, server returns, recover) plus a CONTROL leg proving an
intact session issues NO request — a recovery that fired unconditionally
would re-list workspaces on every sync result of every healthy session, which
is worse than the defect and would pass any test that only checked the first
leg. Also pinned: a still-down server leaves the condition true so the next
result retries, a `current` naming a DIFFERENT workspace is re-pointed
(presence is not the property), and no second list request stacks on an
in-flight one. Neutering both conditions fails three legs; the control and
the in-flight leg pass either way, which is what they are for.

Wiring pinned in the existing source-pin file, with its limits unchanged, and
one leg asserting the recovery sits OUTSIDE the full_refresh branch — the
placement is the fix, so it is what a pin has to catch.

Gates: svelte-check 0 errors; vitest 2254 passed / 142 files.

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

* fix(web): one collection load, two reasons to want it (codex round 1)

Round 1 caught that a `full_refresh` arriving while the list was also stale
fired `loadCollections` twice — the recovery `if` and the changed-signal `if`
are separate tests of non-exclusive conditions. The store's load-generation
guard drops the older response, so this was a wasted request and a superseded
one rather than a wrong list, but it is a request nobody needed and it is the
accretion shape: two guards where the question is one.

Consolidated into one call with the two reasons named — `collectionsMissing`
(we do not have this workspace's list) and `collectionsChanged` (the server
says the list moved) — rather than adding a third condition to suppress the
duplicate. Answering accretion by removing a branch, per the working rule
this plan's neighbour established.

The wiring pin moves with it: anchoring on `loadCollections(ws)` alone would
now pass with the recovery term deleted and the changed-signal term left
standing, which is exactly the pre-fix state and the one a returning server
does not reach. It anchors on the MISSING term and on the combined condition.

Gates: svelte-check 0 errors; vitest 2254 passed / 142 files.

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

* fix(web): ensure-a-list and fetch-a-fresh-one are different requests (codex round 2)

Round 2: the round-1 consolidation still raced the workspace effect's OWN
in-flight `loadCollections`. A sync result arriving before that request
settles sees `collectionsAreFreshFor(ws) === false` and issues a second one;
the store's generation guard discards the stale RESPONSE but nothing prevents
the duplicate CALL.

Second finding of the shape "duplicate collection request", so this answers
the population rather than the instance (CONVE-18). The population is the ~19
`collectionStore.loadCollections` call sites, and the enumeration is the
useful part: EIGHTEEN of them are reacting to a known change — an SSE rename,
a settings save, a server `collections_changed`, a reorder 404 — and for
those, joining an in-flight request would be WRONG, because that request was
issued before the change they are reacting to and cannot answer them.
Exactly one call site, the new recovery path, is asking "does a list exist".

So the coalescing is a separate method rather than a behaviour of
`loadCollections`: `ensureCollections(ws)` no-ops when the list is already
this workspace's, joins an in-flight load for the SAME workspace, and
otherwise issues a real one. The join slot is per-workspace, because a
workspace switch can leave A's request in flight while B's starts and a joiner
asking about A must not be handed B's promise; and it is released under the
same generation-ownership rule the `loading` flag already uses, so an older
load settling late cannot clear a newer one's slot.

The layout now picks by INTENT — `loadCollections` when the server says the
list changed, `ensureCollections` otherwise — which is one call either way
rather than one call plus a suppression condition.

Tests: `collectionsEnsure.svelte.test.ts`. The load-bearing leg is the
CONTROL — `loadCollections` is NOT coalesced — because moving the join down
into it would look like a tidy simplification and would pass every other
assertion in the file while quietly serving pre-change data to a rename.
Also pinned: no request when already fresh, no join across workspaces, and
the slot released after a FAILED load so a later ensure retries rather than
resolving against the dead request — the unit's own failure mode, one level
down. Removing the join fails the join leg; removing the freshness check
fails the no-op leg.

One fixture bug found and fixed while writing: holding a single `release`
across two `mockImplementation` calls leaves the first promise pending
forever, which times out and reads exactly like the product hanging.

Gates: svelte-check 0 errors; vitest 2259 passed / 143 files.

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

* docs(web): the in-flight slot is one tagged slot, not a per-workspace map (codex round 3)

Round 3 is right about the prose and wrong about the remedy, and both halves
are worth recording.

Right: the comment claimed per-workspace tracking and the code is a single
slot tagged with its workspace. That is a comment describing a structure the
code does not have, which is the kind of thing a successor reuses without
re-deriving.

Wrong: the proposed fix — a per-workspace map of in-flight promises — would
be the defect rather than the cure. `loadCollections` commits only the LATEST
call, so in the sequence round 3 names (alpha, beta, then ensure alpha),
beta's start has already killed alpha's first request: its response is dropped
by the generation guard. A map would let the ensure JOIN that dead request and
resolve its caller against a result that never lands — a quieter version of
the bug this unit exists to fix. Issuing a fresh alpha request is the correct
answer and is what the single slot already produces.

So: the comment now says what the slot is and why a map would be worse, and
the ordering round 3 named is pinned as a test asserting THREE requests — the
behaviour, not the proposal. The workspace tag keeps doing the job it always
did, which is the opposite mistake: without it a joiner asking about alpha
would be handed beta's promise.

Gates: svelte-check 0 errors; vitest 2260 passed / 143 files.

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

* fix(web): recoverIfMissing JOINS an in-flight loadAll instead of skipping it (codex round 4)

Round 4: `recoverIfMissing` skipped `loadAll` when one was already in flight
and went straight to `setCurrent`. With `workspaces` still empty that takes
the single-workspace fallback, and if THAT failed while the in-flight list
succeeded moments later, `current` stayed null and the shell stayed broken.
Recoverable — the next sync result retries — but a wasted round, and it made
the recovery depend on the fallback endpoint in a case where the list was
about to answer.

The thing worth recording is that this is the SAME QUESTION `ensureCollections`
answers three files away — "a request for this is already in flight, do I skip
or join?" — and I answered it by joining there and by skipping here, in one
unit, an hour apart. Reviewer-named instances are a sample; this one had a
sibling I wrote myself. Both now join, and `loadAll` publishes its in-flight
promise the way `loadCollections` already did.

The `!loading` condition is gone rather than repaired: with the join, "is one
in flight" is answered by the promise slot, and a second way to ask the same
question is what let the two sites drift.

The in-flight test leg is rewritten to assert the OUTCOME, not just the
request count — round 4's second point, and the fair one. It now holds the
single-workspace fallback DOWN, which is what discriminates: the old skip path
took that fallback and left `current` null, and no call-count assertion could
see it. Reverting the join to the skip fails that leg.

Gates: svelte-check 0 errors; vitest 2260 passed / 143 files.

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

* fix(web): loadAll's cleanup is owned by the generation that set it (codex round 5)

Round 5: `loadAll` cleared its join slot unconditionally, so an older
overlapping request finishing first clears a NEWER one's slot — after which a
concurrent `recoverIfMissing` starts a third request instead of joining the
load still running. Round 4's race, reintroduced by round 4's own cleanup.

Guarded by a generation counter, which is the instrument `collections.svelte.ts`
already uses for exactly this on its `loading` flag and its own join slot.
`loading` moves under the same guard for the same reason: an older load
flipping it off while a newer one runs is the spinner half of the same
mistake. Promise identity would read more directly but forces a self-reference
the type checker cannot prove is assigned before use.

**Third time in this unit that a rule was applied at one door and not its
sibling** — join-vs-skip in round 4, and now ownership-of-cleanup — and both
siblings were in files I had open. Recording it here rather than only on the
trail, because the pattern is the finding.

NAMED, NOT FIXED: `loadAll` still has no guard on which RESPONSE commits, so
two overlapping calls can leave the OLDER list in `workspaces` if it resolves
last. `collections.svelte.ts` has that guard and this store does not. It is
pre-existing and cannot be reached through the recovery path, which only ever
joins and never issues a competing call — so it is a separate fix with its own
test rather than something to fold in here. Written into the store's own
comment so the next reader finds it at the code rather than in a commit
message.

The new leg had to be rewritten before it was worth anything. Its first draft
resolved the older request SUCCESSFULLY, which populated `workspaces`, sent
the recovery straight past its list branch, and passed against the mutant too —
a fixture that could not fail, caught by running it against unconditional
cleanup rather than by reading it. The older request now FAILS, which is what
keeps the array empty and gives the recovery something to recover.

Gates: svelte-check 0 errors; vitest 2261 passed / 143 files.

Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8
2026-09-07 16:19:53 -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

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
    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