mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-24 11:26:34 +00:00
f268b66344
* feat(web): mount split-pane detail view + row-click open (TASK-2112, TASK-2111)
PLAN-2105 wave 2 — the increment where the Asana-style right-docked detail
pane first becomes visible. Builds on the merged TASK-2106 (<ItemDetail>
embedded extraction) and TASK-2110 (?item= URL plumbing).
TASK-2112 — split layout + mount the pane:
- .collection-page becomes a flex row when ?item= is set: list column
(flex:1, always mounted, wrapped in .list-column) + a right-docked
.item-pane that breaks out of the max-width constraint (mirroring how
board-active drops max-width) and fills .main-content so it scrolls
independently. Board view's wrapper preserves its fixed-height fill.
- Mount <ItemDetail ref={openItemRef} embedded ...> inside {#if openItemRef}
with NO {#key} wrapper — A->B is a PROP UPDATE that reuses the mounted
instance (re-drives loadData/collabKey via ref), open/close is the only
mount/unmount.
- Cross-collection ?item= safety: ItemDetail derives its effective
collection (schema + URLs) from the loaded item.collection_slug when
embedded, refetching the correct collection on mismatch and surfacing a
load failure rather than rendering the route collection's schema.
- Paned row highlighted in list, board AND table (focusedItemId now threaded
into TableView; an effect snaps the cursor to the open pane's item).
- Title precedence: the embedded ItemDetail owns the tab title while
mounted; the collection page gates its own title writer on !openItemRef.
- The 2 EditCollectionModal route-away gotos are parameterized through
onNavigateAway/onGone so an embedded pane doesn't hard-navigate the page.
- loadData is fenced by a monotonic loadGeneration (bumped on unmount) so
overlapping A->B loads can't clobber newer state.
TASK-2111 — opt-in row-click interception:
- Added an onclick to the plain ItemCard + TableView title anchors, href
intact: plain left-click -> preventDefault + onItemOpen; modifier/middle
click falls through to the full-page URL (popout); sub-control clicks are
untouched (they already stopPropagation).
- onItemOpen threads from +page.svelte through ListView/BoardView to
ItemCard and directly to TableView (openItemPane); absent elsewhere
(starred/tags/roles) it defaults to full-page anchor nav.
- Keyboard Enter opens the pane; handlePageKeydown now also bails on
contenteditable / .item-pane so keys typed in the pane editor aren't
captured by list navigation.
- openItemPane pushes history on first open, replaces on re-target so
paging A->B->C doesn't stack entries Back must unwind.
npm run check clean; Codex review CLEAN.
Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
* fix(web): switch-safety hardening for the no-{#key} detail pane (TASK-2112, TASK-2111)
The split-pane switches items via a PROP UPDATE (no {#key} remount), so the
mounted ItemDetail instance is reused across item A→B. Async continuations
written for the full-page route (where the component effectively lived one
item at a time) can now resolve after the pane has re-targeted, letting stale
work land on the wrong item. This closes that whole class (pulls TASK-2117/
2118 substance forward — reachable now that the pane exists).
The four coordinator-named defects:
1. Raw-markdown pending edits were dropped on switch. loadData's reset now
flushNow({keepalive:true}) the dirty raw saver against the OLD item BEFORE
clearPending() — wrapped in untrack() so reading `item` there doesn't make
it a dependency of the route effect (which would duplicate-load).
2. Late post-await item writes (saveTitle / assignment / role / restore /
updateField / stampSourceUrl / relationship add+remove / refreshFromSource /
copy-ref / legacy content save) unconditionally reassigned `item`. All now
capture (targetItem, loadGeneration) before the await and drop the write +
feedback via a shared switchedAway() helper (id + generation → also closes
the A→B→A id-reuse gap). handleCreateLink/handleDeleteLink now refresh via
the CAPTURED slug, not the live itemSlug.
3. Stale collab provider stayed active after its editor was destroyed. collabKey
is now gated on a new itemMatchesRef derived, so it goes null the instant
`ref` changes (tearing the old provider down before the new fetch resolves);
onApplierRequest rejects a destroyed editor / superseded provider / mismatched
item; the force_refresh GET chain bails on collabProvider!==provider.
4. Destructive/ephemeral UI leaked across the switch. loadData now resets
confirmDelete + all open menus/dialogs/drawers + in-flight op flags
(deleting/moving/restoring/refreshing) + add-link state + copied +
backlinksCount, so an armed delete on A can't delete B.
Also: SSE onItemEvent + sync onSync bail on !itemMatchesRef and generation-fence
every post-await guard (incl. the catch handleGone) so a stale archive/delete
can't close the newly-opening pane; the rich→Markdown toggle is generation-fenced
(try AND catch) so B can't mount in rawMode seeded with A's content; delete/move
feedback is fenced; the raw saver (debounced save + flushRawIfPending) carries a
generation check alongside the id check.
Deliberately left id-scoped (by design, not bugs): the tagSavers Map keyed by
item.id (coalesces a navigate-away-and-back into the running per-item saver) and
the collab-snapshot flusher's activeCollabContext capture (intentionally PATCHes
the OLD item's URL on a mid-flight switch — its documented never-cross-write
guarantee).
npm run check clean; Codex review CLEAN.
Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
* fix(web): switch-safety sweep into descendant panels (TASK-2112, TASK-2111)
Second batch of the no-{#key} pane switch-safety hardening. The reused
ItemDetail instance keeps its per-item DESCENDANT panels mounted across an
A→B item switch too, so their in-flight async loads can resolve after the
switch and (a) overwrite the new item's display data or (b) invoke a parent
callback with the OLD item's data. Systematic sweep: every per-item async
continuation in ItemDetail AND every descendant panel it renders now captures
its request identity before the await and drops the post-await state-write /
parent-callback / navigation when the identity no longer matches.
ItemDetail.svelte (the three remaining spots):
- handleVersionRestore — the descendant version card's restore await resolves
in the parent's sync callback; now drops the write unless the restored
item is the one currently shown (A's restore can't render under ?item=B).
- isForegroundCurrent (collab-snapshot flusher) — the UI-feedback gate now
also checks a generation captured at flush-start, so setDirty(false)/
showSaved()/saveStatus/error-toast can't fire for a superseded generation
after A→B→A (the flush's PATCH targeting stays ctx-scoped, unchanged).
- navIfStillCurrent (move) — now uses stillOnSource() (folds in the load
generation) so a superseded move can't navigate after A→B→A.
Descendant panels — fenced by capturing the item-identifying prop(s)
(itemSlug/itemId/wsSlug) before each await and dropping the write/callback on
mismatch:
- ChildItems.loadChildren — no longer overwrites B's children or fires
onChildrenChange (→ parent childItemIds/progress/terminal overrides) with A.
- BacklinksPanel.loadFirstPage + loadMore — no longer overwrite B's backlinks
or push A's count into the parent's mention badge.
- ItemTimeline: loadTimeline, loadMore, probeAttachment, the SSE-debounced
refresh, submitComment, and handleReply/Edit/Delete/Reaction/RemoveReaction
— each drops its entries/error write + follow-up reload on mismatch.
- TimelineVersionCard: confirmRestore (does NOT invoke onRestore if switched
by resolve-time) + ensureResolved (guards its lazy content/error writes).
- CommentEditor.doSubmit — captures wsSlug+itemId before awaiting onSubmit and
only clearContent() when still the same item + editor alive, so switching to
B while A's comment submits can't erase B's freshly-typed draft.
Left id-scoped by design (documented exceptions, verified safe): tagSavers Map
keyed by item.id; the collab-snapshot flusher's activeCollabContext capture;
loadTagSuggestions (workspace-scoped, fenced on ws); FieldEditor (drops its
pending typed value on value-prop change / unmount). TimelineCommentCard,
TimelineActivityCard, and DiffView carry no unfenced per-item continuation.
npm run check clean; independent adversarial Codex pass over the diff CLEAN.
Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
* refactor(web): structural containment via {#key itemSlug} for the split pane (TASK-2112, TASK-2111)
Owner decision: stop chasing per-await switch-safety fences one at a time —
the class doesn't converge that way. Contain it structurally instead. Wrap the
NON-EDITOR body subtree of ItemDetail in {#key itemSlug} so it remounts on
every item switch, which structurally cancels stale async continuations in the
descendant panels. Keep the perf-critical editor/collab machinery persistent.
{#key itemSlug} boundary (keyed on itemSlug — the ref/URL identity — NOT
item.id, so the panels reset and show loading/empty the instant the ref
changes, before B's data resolves):
- INSIDE the key (remount per switch): the QuickActionsMenu, the .fields-panel
(FieldEditor debounces + assignment selects), the relationships / add-link
form / ChildItems / BacklinksPanel / ItemTimeline (comments + composer +
version cards) block, ShareDialog, and EditCollectionModal.
- OUTSIDE the key (persistent, the whole no-{#key} perf premise): the
.content-panel — RawMarkdownEditor, both collab <Editor> instances (each
keeps its OWN existing {#key `${item.id}...`} for the per-item Y.Doc swap),
EditorBubbleMenu, EditorLinkPopover. The collab provider $effect, collabKey,
and the SSE/sync onMount/onDestroy subscriptions are script-level and
untouched — so an A→B switch still spins up no second WebSocket and keeps the
one persistent item SSE subscription.
Bounded residue that {#key} does NOT cover — code that writes into the
PERSISTENT editor after an await — is explicitly fenced:
- EditorBubbleMenu.handleCreate: captures the originating editor before the
create await; on an item switch mid-create it skips the wiki-link insert +
onItemCreated + toast (the item was still created — SSE reconciles the
index) and resets the persistent menu's form directly (hide() no-ops while
`creating` is true).
- ItemDetail Rich-mode button: captures item+generation before
`await flushRawIfPending()` and bails before writing rawMode/rawSeedMarkdown
(flushRawIfPending's re-entrancy waiter can resolve true post-switch).
- ItemDetail Markdown-toggle loop: a generation+id check IMMEDIATELY after each
`await collabFlusher.flush(...)`, before inspecting the result / showing a
recovery toast / continuing the loop.
The batch-1/2/3 root-logic + descendant fences are KEPT as defense-in-depth
(removing them risks regression); the {#key} is an ADDITIONAL structural layer.
npm run check clean; Codex review CLEAN (boundary placement verified: editor/
content-panel carries no added itemSlug key; all keys balanced + on itemSlug;
all three residue fences correct).
Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
* fix(web): parent-side fences for destroyed-child callbacks into the persistent pane (TASK-2112, TASK-2111)
The {#key itemSlug} structural containment resets each panel's OWN state on
switch, but it can't cover the residue: a destroyed A-instance child's in-flight
promise still resolves and fires its callback UP into the persistent parent
(ItemDetail), and the child's own identity check passes (its prop is frozen at
A). So A's result would land on B through 4 parent-callback handlers. The fence
must be parent-side.
At each {#key itemSlug} block, `{@const keyedSlug = itemSlug}` freezes the
render-time ref for that instance's closures; each callback guards
`if (keyedSlug !== itemSlug) return;` — where `itemSlug` in the closure reads
the LIVE component value, so a callback fired from a superseded (A) render is
dropped once the pane has moved to B (the current render's callbacks always
pass). The 4 fully-enumerated handlers:
1. QuickActionsMenu oncollectionupdated — guard before `collection = updated`
(a cross-collection switch would otherwise land A's collection on B).
2. ChildItems onChildrenChange (→ handleChildrenChange: progress / childItemIds
/ terminal-status overrides) — guard before delegating.
3. BacklinksPanel onCountChange — guard before `backlinksCount = n`.
4. EditCollectionModal onupdated — guard as the FIRST statement so a superseded
modal's completed save/archive can't reload / navigate / close B's pane.
The children keep their own request-identity self-fences (defense-in-depth);
this adds the parent-boundary layer they structurally cannot provide. Other
child→parent callbacks are already covered: onRestore→handleVersionRestore
(item.id identity check) and FieldEditor/TagInput/assignment → updateField /
updateAssignedUser / updateAgentRole / updateTags (root-fenced); onmanage is a
click handler (a destroyed menu can't be clicked), not an async continuation.
npm run check clean; Codex review CLEAN.
Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
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