mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-10 23:15:40 +00:00
9df2da2c97
* fix(web): a background children refresh keeps its row nodes, so clicks are not swallowed (BUG-2871)
`loadChildren()` set `loading = true` before every fetch, and the template swaps
the whole list for a spinner while loading. Any `item_created` in the workspace
triggers that refresh through ChildItems' SSE subscription, so with other people
working it fired constantly — and each time every row node was destroyed and
rebuilt, even when the data came back byte-identical.
A click needs mousedown and mouseup on the SAME node. When a refresh landed
between them no click event fired at all: no navigation, no in-pane drill, and
no error anywhere. In CI that presented as the pane-content-link-anchors
ctrl-click popup timing out on `waitForEvent('page')`, intermittently, at three
different lines over three months. For a user it silently drops a click on a
child row — plain click included, which is why this stopped being a test-only
concern once the mechanism was reproduced.
Measured rather than argued, in a real browser:
- the row, its wrapper and its container were all disconnected by a refresh
whose data was identical, while the zone element survived, with the loading
spinner observed in between;
- replacing an anchor's node identity between mousedown and mouseup reproduces
all three CI observations at once — no popup, no drill, no error;
- a control leg (ctrl-click, no interference) opens the popup, and a leg with a
1px pointer move between down and up ALSO opens it, which refuted the first
hypothesis: svelte-dnd-action swallowing the click. The rows do sit in a
`dndzone`, so that story was plausible and wrong, and the experiment is what
said so.
The fix shows the spinner only when there is nothing valid on screen for the
current item — a first load or an item switch. A same-item refresh now leaves
the rendered rows mounted, so an unchanged row keeps its DOM node and a click in
flight survives. The switch case still tears down deliberately: `children` is
not cleared when `itemSlug` changes, so without the spinner the previous item's
rows would sit there looking current until the new load lands.
The regression test asserts the PROPERTY (an unchanged row keeps its node across
a refresh) rather than the symptom, because the symptom is timing-dependent and
the property is not. It carries a non-vacuity guard that a children fetch
actually happened, since "the node survived" would otherwise pass trivially if
no refresh ever ran. Verified against the unfixed component with the binary
rebuilt: it fails with "the child row node was replaced by a refresh".
Claude-Session: https://claude.ai/code/session_01WS9QAnxk1gA3LBha3PvKVm
* fix(web): a failed background refresh must not destroy the rows either (BUG-2871, codex round 1)
Two P2s, both mine.
**The error branch is the same defect through a different door.** My fix stopped
`loading` from tearing the list down on a same-item refresh, but a refresh that
FAILS sets `error`, and the template's `{:else if error}` replaces the child
list exactly as the spinner did. A transient refresh failure could therefore
still destroy the row node under a live pointer and swallow the click. It also
called `onChildrenChange?.([])` while `children` still held the rows, telling the
parent we had none.
A background refresh that fails now keeps the last good rows and stays quiet;
the next refresh retries, and they arrive constantly since any `item_created` in
the workspace triggers one. That failure is then invisible, which is a real
tradeoff and worth naming rather than glossing: the alternative in the code
today was destroying the list under the user's pointer, and the initial-load and
item-switch cases still surface errors normally.
**The regression test could pass on a broken build.** It polled request
initiation and asserted immediately, so it could observe the node still
connected before Svelte had processed the state change that replaces it — a
race-dependent false pass. It now counts RESPONSES, waits for the refresh to
settle, and gives the component two animation frames plus a settle before
asserting.
Verified the hardened test still discriminates — and the first attempt to check
that was WRONG in a way worth recording. I reverted with `git checkout --
ChildItems.svelte` and the counterfactual passed, which looked like the test had
stopped working. The file had been COMMITTED by then, so checkout restored the
fix rather than removing it: I had rebuilt and retested the fixed build and read
it as evidence about the unfixed one. `git show origin/main:<path>` gives the
real thing, and against that the test fails with "the child row node was
replaced by a refresh".
Found by Codex review round 1 (two P2s).
Claude-Session: https://claude.ai/code/session_01WS9QAnxk1gA3LBha3PvKVm
* ci: re-run after pre-existing :194 flake (BUG-2871, run 34413842449)
The E2E row on the previous run failed at `pane-content-link-anchors.spec.ts:194`
— the Relationships leg — while this branch fixes the Children one. Established
as unrelated three ways: the diff touches `ChildItems.svelte` only; a
node-identity probe shows the relationships list KEEPS its nodes across a
background refresh, refuting the obvious sibling hypothesis; and main's E2E
failed at that same `:194` on 2026-09-02 (run 33673588422), a week before this
change existed.
That evidence is not a licence to merge with a red row — the next case will look
just as strong and be wrong — so this re-triggers CI instead.
`gh run rerun --failed` is refused on the previous run ("cannot be rerun; its
workflow file may be broken"), which is why this is an empty commit rather than
a re-run.
Claude-Session: https://claude.ai/code/session_01WS9QAnxk1gA3LBha3PvKVm
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