mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-11 13:28:57 +00:00
v0.11.0
1087 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
faf9b3734a |
feat(web): default new collections to Board — schema-aware (IDEA-2274, IDEA-2287) (#1015)
* feat(web): default new collections to Board view (IDEA-2274) Board becomes the baseline default view for new collections; existing collections keep their stored default_view (no migration). - Frontend fallback (settingsDefaults, collection-page defaultMode, shareView coerce, initial viewMode) -> board - Create/Edit collection modals default -> board - Backend template seeds (defaults.go, templates*.go) list -> board for ideas/plans/docs/hiring/interviewing collections (tasks was already board) - CLI `pad collection create` and MCP mapCollectionCreate defaults -> board - Curated create-modal presets with deliberate list curation (Meeting Notes, Decisions, OKRs) intentionally left as list - Pin the three list-keyboard-nav pane E2E tests to ?view=list Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): board default reaches public share page + ItemDetail fallback (Codex round 1) Codex review found the public share route (s/[token]) derives its owner default view via a separate `?? 'list'` fallback that bypassed the coerceSettings change, so settings-less/legacy collections rendered List on public share pages. Align it (and the pre-init selectedBase) to board. Also align ItemDetail's inline CollectionSettings fallback (default_view is unused there, but keep it consistent with settingsDefaults). Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(collections): group Contacts board by relationship, not status (Codex round 2) Contacts has no `status` field, so defaulting it to Board grouped by the default `status` rendered every card in a single Uncategorized lane. Set BoardGroupBy=relationship so the board shows real lanes. All other board-defaulted seed collections have a status field or an explicit board_group_by (verified: Companies/Conventions/Playbooks/Docs have status). Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): always serialize ?view= so a List URL survives a board default (Codex round 3) buildCollectionUrlParams treated List as the implicit URL view and omitted it. With Board now a possible collection default (IDEA-2274), a List selection on a board-default collection produced a URL that, when copied or opened without the sender's localStorage, resolved back to Board. Always serialize the view mode; add a covering unit test. Verified the pane E2E suite (URL-equality assertions) stays green. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912rav0.11.0 |
||
|
|
14f624dd42 |
feat(web): show abbreviated item age on board & list cards (IDEA-2286) (#1014)
Add an item's age (created_at) to the shared ItemCard, right-justified in
the .card-meta row so it sits opposite the status — visible at a glance on
both Board (compact) and List views. TableView renders its own rows and is
unaffected.
Reuses the shared relativeTime() the item-detail header already uses
("3h ago", "5d ago", then a short date) rather than a bespoke format.
A dedicated .meta-spacer (not margin-left:auto on both assignee and age)
keeps the right cluster deterministic — two competing auto margins would
split the free space and strand the assignee mid-row. Absolute timestamp
on hover via a title tooltip.
Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
|
||
|
|
1693a0d264 |
feat(web): show an Uncategorized lane on the Board for items with no group value (IDEA-2275) (#1013)
The kanban board only bucketed items into the group field's known select
options, silently dropping any item whose value was empty, missing, or a
stale/removed option — those items were invisible on the board and could
only be found in other views.
Add a pinned "Uncategorized" lane (leftmost) that collects every such item,
rendered only when uncategorized items exist. Extract the bucketing into a
pure, unit-tested helper (bucketByColumn) that routes empty/unknown-value
items into an UNCATEGORIZED ('') lane instead of dropping them.
- Lane is pinned leftmost and kept OUT of the persisted, drag-reorderable
column order (can't be reordered into the middle or written to saved order).
- Droppable like any other lane: dragging a card in sets the group field to
'' (server-safe clear, reversible); menu-driven horizontal moves work in/out
of the lane via the render-order adjacency.
- Header drops the drag handle and the "+" add affordance (creating an
explicitly-uncategorized item makes no sense) but keeps the bulk "⋯" menu
for triage; dashed muted accent distinguishes it from real status columns.
- Keyboard nav follows the render order so the lane is navigable.
Verified live: Ideas board grouped by impact shows Uncategorized(202) leftmost
with Low/Medium/High, no console errors.
Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
|
||
|
|
ccf7dafe9e |
fix(web): inert the collapsed sidebar so off-screen nav leaves the a11y tree (BUG-2282) (#1011)
The mobile sidebar drawer collapses via translateX + pointer-events:none but stayed in the accessibility tree and tab order, so a screen-reader virtual cursor and keyboard Tab still reached its off-screen nav links. Bind `inert` to the same !sidebarOpen condition that drives the collapse class + the existing pointer-events:none rule, so a collapsed drawer leaves both the a11y tree and the focus order — covering the mobile drawer and the latent desktop width:0 collapse. The re-open control lives in TopBar (outside the aside) so nothing is trapped; swipe-to-open is a window handler, unaffected. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra |
||
|
|
e2ec876be9 |
fix(web): make itemMatchesRef workspace-aware in ItemDetail (IDEA-2135) (#1008)
The no-{#key} switch-boundary predicate compared only ref/slug identity,
never workspace. On a reused embedded ItemDetail instance, navigating
ws1?item=TASK-1 -> ws2?item=TASK-1 (both workspaces owning TASK-1) kept
the predicate true across the switch, leaving collabKey pinned to ws1's
item.id and rawMode carried over until ws2's loadData resolved.
Stamp the wsSlug each item is loaded under (loadedItemWsSlug, lock-stepped
with item adoption inside the myItemGen===itemGen gate) and fold
loadedItemWsSlug === wsSlug into itemMatchesRef. scrollReady, collabKey,
resolvedIdentity, and the rawMode-reset gate all derive from it, so they
tighten together and stay consistent. Single-workspace usage is unchanged
(the arm is always true there).
TASK-2283.
Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
|
||
|
|
8c710e1db9 |
fix(web): stop paneOverlay ref-count effect self-looping on mobile (BUG-2284) (#1009)
PR #1007 (TASK-2131) added a PaneHost `$effect` that calls `paneOverlay.enter()`/`leave()` to inert the app-shell chrome behind the mobile detail-pane overlay. `enter()`'s `overlayCount += 1` READS `overlayCount` inside that tracked effect scope, so the effect took a reactive dependency on the very signal it writes: enter() dirtied the effect → it re-ran → enter()d again → `effect_update_depth_exceeded`. Svelte aborts the flush, stranding the rest of the subtree's reactivity — `paneMintForRoute` stopped recomputing, so the mobile pane (and its Back chevron) rendered EMPTY. The E2E `pane-controller` mobile-overlay tests caught it; #1007's own manual check verified the ARIA attributes but not that item content still rendered. Fix: `untrack` the count read in enter()/leave() so a write from an effect never establishes a self-dependency (the write still notifies the layout reader). The ref-count mutators are written from effects by design, so the untrack belongs in the store. Also fixes the second collision from the same #1007 change: the pane is now `role="dialog"` on mobile, so pane-controller.spec.ts:771's `[role="dialog"]` + text locator matched BOTH the pane and the BottomSheet (strict-mode violation). Target the sheet by accessible name ("Quick actions") instead — the pane's is "Item detail". The effect_update_depth_exceeded runaway only manifests under the real browser scheduler (not jsdom/vitest), so the E2E overlay tests own the loop regression; the unit tests lock the ref-count semantics. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra |
||
|
|
b76abdd66b |
fix(web): full ARIA-modal isolation for the mobile detail pane (TASK-2131) (#1007)
* feat(web): full ARIA-modal isolation for the mobile detail pane (TASK-2131) Follow-up to TASK-2122. The mobile full-screen detail-pane overlay had a JS focus trap + an inert list column, but the app-shell chrome behind it stayed in the a11y tree and the pane was still just an <aside>. Complete the modal: - The mobile `.item-pane` becomes role="dialog" aria-modal="true"; the desktop split stays a bare <aside> (complementary landmark, non-modal). - MobileContextBar + BottomNav (rendered in the workspace +layout, ABOVE the pane host) are marked `inert` while a mobile overlay is up, so they leave the focus order AND the screen-reader tree. A JS trap can't constrain an SR virtual cursor and aria-modal is unevenly honored, so the background chrome must physically drop out. The chrome is a layout sibling the host can't reach by prop, so PaneHost hoists "a mobile overlay is active" into a small ref-counted store (paneOverlay.svelte.ts) the layout reads — one-way writer/reader split per CONVE-1688. Ref-counted so an overlapping route-change remount can't clear the signal early. The layout carries `inert` on display:contents wrappers (cascades to the fixed chrome, adds no box). Verified in a real browser (Playwright): mobile → dialog role + aria-modal + inert descendants unfocusable; desktop → bare aside + chrome interactive. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): exclude the pane's dialog role from foreign-modal guards (TASK-2131) The new mobile role="dialog" on the pane collided with three places that treat ANY [role="dialog"] as a *foreign* modal that owns its own ESC / focus — regressions the pane's <aside>-not-a-dialog invariant had been silently relying on: - paneFocus.ts PANE_EXEMPT_SURFACE_SELECTOR: any in-pane element matched closest('[role="dialog"]') → the whole pane read as an "exempt surface", killing the mobile Tab trap and confusing the focus-follows classifier. - The collection + item-page ESC guards querySelector('[role="dialog"]') → the pane matched itself → ESC was swallowed instead of closing/popping the pane on mobile. Fix: exclude the pane via [role="dialog"]:not(.item-pane) at all three sites (a genuinely nested dialog/menu opened FROM the pane still matches). Adds inExemptSurface unit coverage for the pane-not-exempt case. Caught by the independent Codex review pass. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): complete mobile modal isolation — banners + print (TASK-2131) Two more members of the same [role="dialog"] collision class, from the independent Codex pass: - Print: app.css @media print hides [role="dialog"] to strip overlays. The mobile pane now matches, so printing at <=768px with the pane open dropped the whole item from the printout. Exclude via :not(.item-pane) — the pane is the content being printed, not a transient overlay. - Banners: VerifyEmailBanner + ConnectBanner rendered OUTSIDE the inert wrappers, so their controls (Resend / Connect) stayed reachable by an SR virtual cursor behind the aria-modal pane — the same gap the inert of MobileContextBar/BottomNav closes. Fold them into the top inert wrapper so ALL app-shell siblings behind the overlay leave the a11y tree; only the pane (in children()) stays interactive. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra |
||
|
|
316e25a6ca |
fix(web): trap focus in BottomSheet so ESC/Tab hit the sheet, not the layer under it (BUG-2130) (#1006)
* fix(web): trap focus in BottomSheet so ESC/Tab hit the sheet, not the layer under it (BUG-2130) BottomSheet is a role="dialog" aria-modal mobile sheet but, unlike the native-<dialog> Modal.svelte, it never moved focus into itself or trapped Tab. Two consequences, app-wide (most visible over the mobile split-pane): - ESC closed the wrong layer: focus stayed on the trigger outside the sheet, so a window-level ESC handler underneath (e.g. the collection page's pane-close) fired first and closed THAT instead of the sheet. - Tab escaped the sheet into the obscured content behind it. Fix in the shared component, mirroring Modal.svelte's behavior: - Move focus onto the panel (tabindex=-1) on open; restore focus to the trigger on close and on teardown-while-open. - Trap Tab/Shift+Tab within the sheet, reusing the pane's already-tested trap math (paneFocusables + nextTrapTarget from paneFocus.ts) so the two focus traps can't drift. The focus effect reads only `open`/`sheetEl` and writes the non-reactive `previouslyFocused`, so it can't self-invalidate (CONVE-1688). Surgical over a native-<dialog> rebuild: 11 consumers make the blast radius large, and the bug is scoped to the shared component. Converging BottomSheet onto the Modal primitive is a separate, larger refactor. Adds BottomSheet.svelte.test.ts (focus-in, Tab/Shift+Tab wrap, Escape, backdrop, focus-restore). Verified: full web suite (471) green, svelte-check clean, Codex CLEAN, and a real mobile-browser drive (focus-in, Tab + Shift+Tab trapped, ESC closes only the sheet with the item pane surviving, focus restored to the trigger). Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): only the frontmost BottomSheet handles Escape/Tab (nested sheets) Codex PR review caught an adjacent facet of the same layer-isolation bug: every open BottomSheet registers a window-level Escape/Tab handler, so when one sheet opens another (Quick Actions sheet → the mobile emoji picker's sheet, both role="dialog" BottomSheets, the inner DOM-nested in the outer), a single Escape fired both handlers and closed BOTH layers. Gate each sheet's handler on being the frontmost (innermost) open sheet: a nested child sheet renders inside our content, so a sheet that CONTAINS another open `.bs-sheet` is not frontmost and stays out. Order-independent by design — a defaultPrevented/stopPropagation check can't work here because the outer sheet's window listener is registered first and fires before the inner's. Verified at runtime (mobile): open Quick Actions → New quick action → the emoji-picker button opens a nested sheet; one Escape now closes only the picker (Quick Actions survives), a second closes Quick Actions. Adds a nested-sheet unit test. Full web suite 472 green, svelte-check clean. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): generalize BottomSheet frontmost gate to sibling sheets too Follow-up to the nested-sheet fix: replace the descendant-only guard with a document-wide frontmost check so the "only the topmost sheet handles Escape/Tab" rule also holds for sibling sheets (two open overlays where neither DOM-contains the other). A sheet that contains a deeper open sheet is never frontmost; among the remaining leaf sheets the last in document order paints on top at the shared z-index, so it wins. Recomputed per keydown, so order-independent. Two full-screen overlays can't both be reached by the user today (opening one covers every other trigger), so this hardens a currently-unreachable topology rather than fixing a live repro — but it makes the invariant total and closes the Codex review's remaining finding. The single-sheet path short-circuits to frontmost=true, so the verified primary behavior is unchanged (re-verified at runtime: single-sheet focus-in/trap/Escape/restore + nested one-layer-per-Esc both still green). Adds a sibling-topology unit test. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra |
||
|
|
06d7e429e5 |
fix(web): keep the inline "New quick action" form open on click (BUG-2281) (#1005)
Clicking the QuickActionsMenu footer "+ New quick action" flipped
showCreateForm=true, unmounting the footer's {:else} branch (the very
button clicked). Svelte 5 flushSyncs after a delegated event handler, so
by the time that click bubbled on to the <svelte:window> click-outside
handler the button was detached — target.closest('.quick-actions-menu')
returned null, and handleWindowClick treated it as an outside click and
closed the whole menu, wiping the create form the instant it opened. The
create-form Cancel button had the same detach-then-close quirk (closed
the menu instead of returning to the action list).
handleTriggerClick already guards this with e.stopPropagation();
handleOpenCreateForm and the Cancel handler did not. Add the same guard
to both. Adds a Playwright regression test (mutation-tested: fails on the
pre-fix code, passes after) — the inline form is exercised in a real
Chromium event pipeline for the first time (jsdom doesn't reproduce the
mid-bubble detach, and the capstone spec only asserted the button was
visible, never clicked it).
Also documents BUG-2280 in ItemDetail.svelte: the QuickActionsMenu
oncollectionupdated callback's `{@const keyedSlug = itemSlug}` fence was a
Svelte-5 no-op, but the callback is already switch-safe by two independent
layers (the child-side collection-id guard reads the LIVE parent
collection and drops a cross-collection callback; loadData's identity
clause forces the correct collection regardless). Replaces the dead no-op
fence with a comment explaining why it's safe and warning against
re-adding a no-op snapshot fence (the literal BUG-2129 trap). No
behavior change in ItemDetail.
BUG-2281: real, fixed. BUG-2280: investigated, not a live bug (wontfix).
Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
|
||
|
|
96daff2e7c |
chore(deps): bump low-risk dev tooling + render libs (safe subset of #1002) (#1004)
Extracts the genuinely low-risk bumps from the grouped Dependabot PR #1002, which as a whole can't be merged (it's stale — reverts the BUG-2278 advisory overrides — and bundles a coordinated @tiptap/* 3.22.5->3.28.0 bump that needs schema-version verification plus a svelte 5.55->5.56 runtime bump that needs focus-suite revalidation). Safe subset (dev tooling + rendering libs only; no collab/runtime/build-compiler surface): @playwright/test 1.59.1->1.61.1, marked 18.0.3->18.0.7, svelte-check 4.4.7->4.7.3, mermaid 11.14.0->11.16.0, layercake 10.0.2->10.0.3, svelte-dnd-action 0.9.69->0.9.74. Deliberately EXCLUDED (verified unchanged): @tiptap/*, svelte, @sveltejs/vite-plugin-svelte, yjs, and the kit/vite/rolldown toolchain — those need their own validated PRs. Gates: audit 0 prod vulns, check:tiptap-pins OK, npm ci in sync, build, check (0 errors), test (464) all green. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra |
||
|
|
34233a2a25 |
fix(e2e): de-flake pane j/k re-target test by opening the first row (BUG-2279) (#1003)
pane-controller.spec.ts:160 flaked ~50% (fails once, passes on retry). Root cause via instrumentation: the test opened a NAMED seeded row and pressed `j` (down) expecting the pane to re-target to a different item. But the two seeds share a same-second created_at, so their list order is a non-deterministic tie-break (BUG-2270) — the named row could land LAST, where `j` clamps at the final index (Math.min(idx+1, len-1)) and the cursor doesn't move. The pane-follow then correctly finds the focused row is already the paned item and skips (no re-target), so `openItemParam` stays put and the assertion fails. Not a product bug — the follow logic behaves correctly. Fix is test-only: open the FIRST rendered row instead of a named one, so `j` always has a row beneath it to move to, regardless of seed tie-break order. Verified: :160 now 10/10 stable in isolation (was ~50-60% flaky); full pane-controller.spec.ts 21 passed. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra |
||
|
|
add3ffabfb |
feat(web): adopt vite 8.1 / sveltekit 2.70 toolchain + fix pane pop focus (#1001)
Completes the deferred upgrade from BUG-2278. The advisory-fix PR #997 had dragged this toolchain in via `npm audit fix`; #999 reverted it because it regressed the pane-focus E2E suite. Root-caused (see BUG-2278 residual): the trigger is @sveltejs/kit 2.66.0 (PR #15452), which blurs the active element to <body> BEFORE the component update during navigation. On the pane's popstate pop path (handlePaneBack -> history.go(-1), which can't carry keepFocus), that early blur (focusout only, no focusin) makes Kit's end-of-nav reset_focus() body.focus() a no-op emitting no focusin — starving PaneHost's focusin-only backstop, so focus strands on <body>. (Drill path uses goto({keepFocus:true}) and is unaffected — which is why only the 5 pop/ESC focus tests failed. vite/rolldown/svelte are not implicated.) Fix: re-assert focusPaneRegion() after the popstate settles (next frame, so it runs after Kit's microtask-scheduled reset_focus), removing the dependency on an incidental focusin(body). ~12 lines in paneHostController.ts; no-op when the pane closed or focus already landed in-pane. Toolchain: vite 8.0.11->8.1.5, @sveltejs/kit 2.59.1->2.70.1, rolldown rc.18->1.1.5 (lockfile only; package.json caret ranges + advisory overrides unchanged). Advisory deps stay at their patched versions (audit 0 prod vulns). Verified on the bumped toolchain: the 5 previously-failing pane-focus tests pass, full pane e2e 41 passed (the one flaky test, :160, is a PRE-EXISTING flake on main that flakes on the reverted toolchain too and passes on retry), web check (0 errors), test (464), build, tiptap-pins, npm ci all green. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra |
||
|
|
dd79e837f6 |
fix(ci): narrow BUG-2278 web fix to advisory deps, revert toolchain slide (#999)
The broad `npm audit fix` from the previous commit ( |
||
|
|
0f48bcebfa |
fix(ci): bump advisory deps to restore green CI (BUG-2278) (#997)
Two newly-published upstream advisories postdate the last green main run and were failing the Go and Web CI jobs on every PR. Both are DoS-class in parsing/text deps; no product code change. Go job (govulncheck binary mode): - GO-2026-5970: infinite loop on invalid input in golang.org/x/text. Bump golang.org/x/text v0.38.0 -> v0.39.0 via `go get` + `go mod tidy`. go mod tidy pulls the coordinated x/* release train it requires (crypto/term/mod/net/sys/tools). govulncheck -mode binary: 0 called. Web job (npm audit --audit-level=high --omit=dev): - linkify-it <=5.0.1 (high, GHSA-v245-v573-v5vm) + dompurify + markdown-it. `npm audit fix` (lockfile-only). Fixes the 3 advisories (audit now reports 0 vulns). As semver-compatible collateral within existing caret ranges it also refreshed the build toolchain (vite 8.0.11->8.1.5, @sveltejs/kit 2.59.1->2.70.1, rolldown rc.18->1.1.5). Tiptap exact-pins held (check:tiptap-pins green). - Also tighten the existing linkify-it security override floor ^5.0.1 -> ^5.0.2 so it expresses the patched minimum for THIS advisory rather than relying on npm's latest-in-range resolution. Lockfile was already at 5.0.2, so npm ci stays in sync (verified). Gates: go vet, go build, govulncheck -mode binary, go test ./... all green; web npm ci, check:tiptap-pins, audit, build, check, test (464) all green. Independent Codex review: CLEAN. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra |
||
|
|
51cd6e84e4 |
fix(collab): op-id durable fence for restore-rollback vs applier-ack race (BUG-2276 residual 2)
Closes the restore-rollback vs applier-ack clobber race with a durable operation-id correlation instead of a timing heuristic. The client brackets its setContent with an applier_apply_start{request_id} control frame; the server decides whether the external write persisted by reading the per-conn op-log high-water UNDER the same appendMu that sets the restore freeze (finalize-at-freeze — no drain, so a blocked write can't stall the restore; no timing window). Edges handled: unanchored conns are never elected; gate admission spans registration; legacy (pre-bracket) clients negotiate capability and an unconfirmable legacy round-trip returns a retryable 409 applier_ambiguous (fail-safe, never a clobber); the applier callback is synchronous-by-type so nothing can split the bracket. Normal acks stay on a lock-free, latency-identical fast path.
Confirming Codex (high effort): redesigned from a timing grace after review; 3 rounds on the op-id design (2 P1 -> 3 P1+P2 -> CLEAN/converging). E2E + Go(PostgreSQL) green; go test -race clean 8x. Go/Web CI red only on the pre-existing dependency advisories (BUG-2278).
https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
|
||
|
|
e601f2b368 |
fix(collab): reconcile Postgres commit-ack-loss on version restore instead of treating it as rollback (BUG-2276 residual 1)
On Postgres, a version-restore commit that durably lands but whose ack is lost surfaced as an error and wrongly resumed peers on a stale Y.Doc. ForceRefreshRoom now runs a Postgres-only reconcile after a commit error: two durable signals (content == restored version AND last_restore_seq advanced past a lock-captured baseline) must agree → LANDED (publish fences + reseed, return the restored item + SSE); both false → rolled back (unfreeze); disagree/read-error → UNCERTAIN (invalidate in-memory fences so durable state governs, then plain-close sockets so peers reconnect + re-evaluate). SQLite path unchanged. Confirming Codex (high effort): 3 rounds — false-404, frozen-forever, archive-nil, stale-baseline, stale-in-memory-fence-clobber all closed; real Postgres end-to-end ack-loss + SSE test. make test-pg green. Residual 2 (applier-ack rollback race) follows separately. Go CI red only on the pre-existing govulncheck advisory (BUG-2278). https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra |
||
|
|
9d18f12893 |
fix(collab): flush live editor into items.content before version restore (BUG-2271)
Before a version restore, the initiating client flushes its live collab editor markdown into items.content (the collab server is a dumb relay and can't render the Y.Doc), so the server-side 'Restored from…' undo-point captures in-flight edits instead of losing them when the restore prunes the op-log. Best-effort: a genuinely-failed pre-restore flush warns the user (non-silent) and the restore still proceeds. Narrow reconnect/cursor-0 window documented as an accepted residual. Confirming Codex (high effort): 2 rounds — silent-flush-fail + spurious-warning + E2E false-pass all closed; deterministic request-ordering E2E green. Web CI red only on the pre-existing npm advisory (BUG-2278). https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra |
||
|
|
3c4eee54ce |
fix(web): retarget URL/breadcrumb on collection rename across tabs (BUG-2272)
On a remote collection rename, retarget the full-page item view's URL + breadcrumb to the new slug (non-embedded only, query preserved) and make the collection route robust to chained/replayed renames. The rename-nav decision is a pure, unit-tested helper (resolveRenameNavTarget) scoped to stable collection identity, so a reused slug can't misdirect and a chained B→C in the goto→reload window isn't dropped. Confirming Codex (high effort): 3 rounds — reused-slug misdirection + a transient it briefly introduced both closed; final logic verified + 8 deterministic tests + E2E green. Web CI red only on the pre-existing npm advisory (BUG-2278). https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra |
||
|
|
37ec77d110 |
fix(store): monotonic tie-breaker for same-second item version ordering (BUG-2270)
Adds a per-item monotonic `version_seq` column (dual migrations: SQLite 076 / Postgres 054, backfilled via ROW_NUMBER) so version-history RECONSTRUCTION resolves same-second versions deterministically instead of by the random-UUID PK. Reconstruction paths (shouldCreateItemVersion, ListItemVersions/Resolved, export) order by version_seq; the timeline keyset path (ListItemVersionsBeforeTime) keeps its id-consistent cursor. Confirming Codex (high effort): found + fixed one keyset-pagination P2 (order/cursor key mismatch). make test-pg green (migration verified against Postgres). Go CI job red only on the pre-existing govulncheck advisory tracked in BUG-2278. https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra |
||
|
|
1f12b26f33 |
fix(web): item field edits use fields_patch + optimistic concurrency (BUG-2273) (#991)
Adopt the IDEA-1480 / MCP v0.14 item-level merge + optimistic-concurrency contract in the web editor's per-field save. `updateField` now sends a single-key `fields_patch` + `expected_updated_at` instead of a full `fields` blob, with a bounded refetch-and-retry on 409 `update_conflict`. Fixes concurrent-field-edit clobber and the schema-migration-race value restore. Includes the BUG-2129 E2E test update to the new wire shape. Confirming Codex pass: CLEAN. E2E green on rerun. (Web/Go CI red only on pre-existing dependency advisories tracked in BUG-2278.) https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra |
||
|
|
40f88052cd |
fix(collab): version restore via prune+reseed (BUG-2264) (#990)
Version restore didn't reconcile the live Y.Doc: peers kept editing a Y.Doc built on pre-restore ops, and their next collab-snapshot flush clobbered the restored items.content. Reworked restore to prune+reseed — the restored content becomes canonical and every peer converges on it (unflushed edits are discarded, which is exactly restore semantics), replacing the earlier applier/epoch/watermark routing. handleRestoreItemVersion drives RoomManager.ForceRefreshRoom under the per-item lock. Hardened across Codex xhigh review rounds: - Atomicity: pre-prune MAX(op-log), the items.content write, the "Restored from…" version, the op-log wipe, AND both durable restore boundaries all run in ONE store transaction. A failed commit rolls back all of it — no divergent state, no fail-open boundary. - Unambiguous commit signal: UpdateItem reads the updated row WITHIN the tx (getItemTx) before commit, so a read failure can't make a committed update look failed and the returned seq is this restore's. - Restore freeze: conns are paused via a dedicated rc.frozen flag (NOT canWrite) so the auth-revalidation loop can't thaw the freeze mid-restore or promote a viewer; pickApplier + the applier-ack handler reject frozen conns so a concurrent external PATCH can't falsely succeed. - Stale-flush boundary: pre-prune MAX+1 fences in-flight snapshot cursors under the same item lock. - force_refresh fan-out deadlock: per-conn timer-close so a wedged writeLoop can't hang the fan-out + item lock. - Stale-SEED clobber: the client announces the item.seq it seeded from (?content_seq=) on every (re)connect; Join force_refreshes any seed that predates the last restore. Residual #1 (restart-durability) CLOSED durably, for BOTH stale vectors — the in-memory fences didn't survive a restart, so a surviving cursor-0 pre-restore browser tab wasn't fenced on reconnect. Two nullable per-item columns (migration 075 SQLite / pg 053), both stamped in the restore's own tx (atomic with the content write + op-log prune): * items.last_restore_seq — the content generation. Join's stale-seed fence reads it (via store.ItemLastRestoreSeq) when the in-memory fast-path misses (after a restart); if that read errors, Join fails CLOSED via a RETRYABLE plain close (not a force_refresh, which would discard the Y.Doc and spin an unbounded refresh loop) so the client reconnects with backoff, Y.Doc intact. * items.restore_boundary_op_id — the op-log-id boundary. The collab-snapshot flush gate reads it (via store.ItemRestoreBoundaryOpID) when the in-memory RestoreBoundary misses (after a restart), failing closed (409) on a read error, so a surviving tab's stale HTTP flush is fenced too. No SCHEMA_VERSION bump — durable columns are not a Y.Doc node-spec change. Deferred to BUG-2276: (a) a Postgres commit whose ack is lost is treated as rolled-back (needs commit-outcome reconciliation; SQLite unaffected); (b) a restore rollback racing an in-flight external-applier ack can drop the ack and retry/fall back (needs the applier flow serialised under itemLock at a 30s-stall cost). NOTE(BUG-2270): ForceVersion can mint same-second version rows; the item_versions ordering tie-breaker is tracked separately. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra |
||
|
|
1dbe04399a |
fix(store): optimistic concurrency + sibling broadcast for collection settings (BUG-2265) (#989)
* fix(store): optimistic concurrency for collection settings writes (BUG-2265) Collection-level settings (e.g. quick_actions) were written by reconstructing the whole settings JSON from a caller's local Collection snapshot, and UpdateCollection replaced the column with no concurrency check. Two ItemDetails in the same collection (full-page pane host master + pane) hold independent snapshots and clobbered each other. Mirror the item optimistic-concurrency pattern (IDEA-1480): add CollectionUpdate.ExpectedUpdatedAt; when set, UpdateCollection re-reads updated_at atomically under the workspace write lock (SQLite BEGIN IMMEDIATE / Postgres advisory xact lock) and returns CollectionUpdateConflictError on a mismatch. Empty token keeps the legacy last-write-wins path unchanged for CLI/MCP/API callers. No DB migration — reuses collections.updated_at. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * feat(server): collection.updated broadcast + 409 conflict mapping (BUG-2265) - handleUpdateCollection boundary-validates expected_updated_at (400 on a malformed token) and maps store.CollectionUpdateConflictError to the shared update_conflict envelope (HTTP 409) — byte-identical wire shape to the item path, via the extracted writeUpdateConflictEnvelope helper. - Add the collection_updated EventBus type and publish it after a successful update so sibling ItemDetails / collection pages refresh their independent Collection snapshot proactively, shrinking the 409 window. Routed by Collection (slug) through the existing SSE visibility filter. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): 409-aware collection settings writes + sibling refresh (BUG-2265) - CollectionUpdate carries expected_updated_at; add isUpdateConflictError. - QuickActionsMenu sends the token and, on a 409, refetches the collection, re-appends the new action onto the FRESH settings, and retries once — no silent loss, no user-visible error. - EditCollectionModal captures the token at open-time (edge-gated seed so a concurrent broadcast can't wipe in-progress edits) and shows a non-destructive "changed elsewhere, reload" message on 409 rather than auto-merging a full-form edit. - Subscribe to collection_updated over SSE: ItemDetail and the collection page refresh their own Collection snapshot (gen/slug-fenced against the persistent pane host's no-remount switch), so siblings converge before the next save. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(store): harden collection optimistic concurrency + web fetch ordering (BUG-2265, Codex round 1) Address Codex review findings: - [P1] same-second clobber: now() is one-second precision, so two guarded writes in the same second kept an identical token. The accepted write now advances updated_at strictly past the token (only when now() hasn't already moved on), making a stale-token replay deterministically conflict. Add a same-second regression test. - [P1] tokenless-writer race on Postgres: the advisory lock only serialized writers that also took it. Replace it with a `FOR UPDATE` row lock on the in-tx re-read (Postgres) — SQLite's BEGIN IMMEDIATE already serializes every writer — so a concurrent tokenless UpdateCollection can't slip between the re-read and the UPDATE. - [P2] rename broadcast: only publish collection_updated when the slug is unchanged. A rename's old-slug event would make siblings refetch a dead slug (404) and a new-slug event can't reach old-slug visibility snapshots; renames are handled by the existing navigation path. - [P2] out-of-order refreshes: ItemDetail and the collection page now use a dedicated monotonic refresh counter so two rapid collection_updated fetches can't resolve out of order and clobber newer state (loadSeq/loadGeneration only bump on route/item loads). Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(store): make collection updated_at strictly monotonic for ALL writes (BUG-2265, Codex round 2) The previous same-second advance ran only on guarded updates, so a tokenless UpdateCollection could write the current second over a forced expected+1s, regressing the concurrency token and letting a stale guarded client clobber newer data (Codex P1). Route every collection update through one small transaction that re-reads updated_at (FOR UPDATE on Postgres; SQLite BEGIN IMMEDIATE covers it) and derives the new timestamp atomically: strictly advance past the row's current value when now() hasn't already moved on. This makes updated_at a reliable concurrency token for guarded AND tokenless writers. Add a tokenless-monotonic regression test. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix: close remaining collection-concurrency gaps (BUG-2265, Codex round 3) - [P1] Board column reordering (handleGroupReorder) rebuilt the full schema from a stale local snapshot and wrote it with no token — a lost-update path identical to the bug being fixed. Now sends expected_updated_at and, on 409, refetches, re-applies the reorder onto the fresh schema, and retries once. - [P2] The workspace settings page seeded EditCollectionModal from a page-load-time collections list, so a change that predated editing produced a false 409. It now refreshes the list on collection_updated (seq-guarded). - [P2] collection.updated is now delivered to item-grant-only SSE subscribers for collections they can see — it's itemless but leak-free (only the slug), so guests' ItemDetail schema/settings snapshots converge too. Filter test extended. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): switch-safety + conflict-merge fixes for collection writes (BUG-2265, Codex round 4) - Board column reorder now ABORTS on a 409 (with a "reorder again" toast) instead of replaying a stale option order onto the fresh field, which would silently drop a concurrent option add/remove/rename. Reordering is cosmetic; never worth clobbering a real schema edit. Also captures ws/slug/base before the await and fences the write against a route switch. - QuickActionsMenu captures workspace + collection identity BEFORE the first await, so a mid-save navigation can't make the 409 refetch/retry target the wrong collection (no guaranteed remount). - Settings-page SSE refresh captures the workspace and drops the result if the workspace changed while fetching, so a slow refresh for workspace A can't overwrite workspace B's freshly loaded collection list. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(store): sub-second collection updated_at token, no future drift (BUG-2265, confirming pass #6) The same-second monotonic advance manufactured whole-second FUTURE updated_at values; sustained >1 write/sec on one collection drifted arbitrarily ahead of wall-clock. collections.updated_at is TEXT on both dialects and never compared lexically (only via time.Equal + display), so switch the update write to sub-second nowNano(): same-second collisions become near-impossible, so the token advances naturally. Keep a strict-monotonicity guard but step by a single NANOSECOND on the (now near-impossible) coarse-clock/step-back collision, so any drift is bounded to nanoseconds. Dual-dialect; covered by make test-pg. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(server): sanitize + always-broadcast collection event (BUG-2265, confirming pass #2,#3) - #2 (P1): collection_updated is delivered to item-grant guests, but the event carried ActorName/Source, leaking the owner's identity + edit source. Strip them — publishCollectionEvent now emits workspace + slug (+ new_slug) only. - #3 (P2): always broadcast (including on rename), routed by the OLD slug and carrying the NEW slug via a new Event.NewSlug field, so remote tabs on the old slug can re-target instead of silently 404ing on their next action. Tests: assert no actor/source leak on a settings update; assert a rename routes by old slug + carries new_slug. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): decisive switch-safety + rename handling for collection writes (BUG-2265, confirming pass #1,#3,#4,#5) - #1 (P1): EditCollectionModal captures the target collection id/slug/name/ws + updated_at when the form is SEEDED, and handleSave/handleArchive now operate on that captured identity (not the live props). The seed effect re-seeds when the collection IDENTITY changes (not on a same-id broadcast refresh), so a reused route can't leave A's form saving/deleting to B. - #3 (P2): on a rename event the collection route navigates to the new slug (preserving the pane query) and ItemDetail refetches by new_slug; the SSE event type carries new_slug. - #4 (P2): the reorder-conflict path refetches the collection (reseeds the token) before prompting, so a missed SSE event doesn't make every retry 409 forever. - #5 (P2): QuickActionsMenu only invokes oncollectionupdated when the live workspace/slug still match the captured identity, so a reused route can't assign an old response to the newly-navigated page. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(server): nano token round-trip, rename visibility, publish-before-migration (BUG-2265, confirming pass 2) Address the round-2 confirming-pass findings (server-only): 1. (P2) The shared update_conflict envelope formatted actual_updated_at with second precision (time.RFC3339), truncating the now sub-second collection token so the client's retry token never matched — a permanent 409 loop. Format with time.RFC3339Nano. Item tokens are zero-nanosecond, so RFC3339Nano emits no fractional part — the item 409 wire shape is byte-identical and the item path still compares via time.Equal. Added a test that the returned token round-trips as a usable retry token. 2. (P2) Rename events are routed by the OLD slug, but a subscriber that revalidated after the rename only has the NEW slug in visibleSlugSet, so the visibility check dropped the event before the new_slug branch. Accept a rename when EITHER the old slug or the (authorized) NewSlug is visible; downstream item-grant gating uses whichever slug is visible. Filter test extended. 3. (P2) The collection_updated event was published only after field migrations succeeded, but UpdateCollection already committed (updated_at advanced). On a migration failure clients got a 500 and no refresh, leaving siblings with stale tokens that 409 blindly. Publish on the commit (before the migration), so siblings always resync regardless of migration outcome. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix: atomic collection update+migration; modal same-id rename retarget (BUG-2265, confirming pass 3) Address the round-3 confirming-pass findings; defer cross-tab rename RE-NAVIGATION to BUG-2272 (placeholder) per coordinator. 1. (P1) Migration atomicity. UpdateCollection committed the schema + concurrency token BEFORE MigrateItemFieldValues ran, so a migration failure returned 500 with the row already changed → the retry was guaranteed to 409 and item values were left inconsistent with the committed schema. Made the two ATOMIC: extracted applyFieldMigrationsTx and run it inside UpdateCollection's own transaction (after taking the workspace seq lock), so a migration failure rolls back the schema AND the token — nothing changes, the retry works. The handler now passes migrations through instead of running them separately, and publishes the event only after the fully-atomic commit. store/tx work → make test-pg run green. 2. (P2) EditCollectionModal same-id rename. The round-1 identity capture ignores same-id prop refreshes (to preserve edits), but a concurrent RENAME changes the slug (not the id), so handleSave/handleArchive PATCHed a dead slug → 404 before the token could 409. On a same-id prop change whose slug changed, the seed effect now retargets the endpoint slug + re-captures the token WITHOUT reseeding the form (in-progress edits preserved). Deferred (BUG-2272, TODO comments added, already broken on main — no regression): - ItemDetail full-page item URL/collSlug not retargeted after a remote rename. - Collection route chained-rename events during SSE replay landing on a dead intermediate slug. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): keep seeded token on same-id rename retarget (BUG-2265) On the EditCollectionModal same-id rename branch, retarget the endpoint (slug/name/ws) only — drop the token re-capture. Re-capturing let a later handleSave succeed against the renamed collection and apply the modal's stale pre-rename full form, silently REVERTING the concurrent rename (the exact stale-snapshot clobber BUG-2265 prevents). Keeping the seeded token means a concurrent rename correctly yields a 409 → the non-destructive "collection changed, reload" message. Slug-retarget without token-recapture gives both: no 404 (right URL) and no clobber (409 fires). Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix: lock-order deadlock + unified collection-snapshot fences (BUG-2265, confirming pass 4) 1. (P1) DEADLOCK regression. UpdateCollection's atomic migration path took the collection-row FOR UPDATE lock and THEN the workspace seq lock, but item creation takes them in the reverse order (workspace advisory lock first, then the collection-row FK lock on INSERT) — a concurrent item-create + schema-migration ABBA-deadlocks on Postgres. Fix: acquire the workspace seq lock BEFORE the collection-row FOR UPDATE (matching item-create's order). Every store path that locks both now takes them workspace-seq → collection-row (tryCreateItem, UpdateItem, MigrateItemFieldValues, UpdateCollection). Added a concurrency regression test (item-create racing schema-migration); make test-pg green. 2+3. (P2) Cross-generation fence gap. The SSE collection refresh and route/item loads used SEPARATE counters, so a stale in-flight load could complete after a fresh SSE refresh and revert the collection + its concurrency token. Unified to a SINGLE monotonic collection-snapshot generation in BOTH the collection route and ItemDetail — every collection-snapshot write (loadCollection/loadData, the SSE refresh, reorder, and the quick-action/edit-modal callbacks) bumps it on start and gates its assignment on "still latest". ItemDetail's load keeps a switch-escape so a stale refresh for the OLD collection can't block loading a NEW one. Settings page unified the same way over its collections-list writes. 4. (P2) Settings page fed a stale editingCollection to the edit modal after a remote rename (its prop never changed → the same-id-rename retarget never fired → 404). The unified refresh now re-points editingCollection at the refreshed object for the same id, so the modal's retarget fires. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix: emit item-changes signal on field migration; QuickActions retry-by-id (BUG-2265, confirming pass 5) 1. (P1) A collection update that runs a field migration mutates item `fields` JSON and advances item `seq`, but only collection_updated was published — open item views refreshed collection METADATA and returned without reconciling the migrated items, so clients kept stale field JSON under the new schema and a later full-fields item update could UNDO the migration (a clobber). UpdateCollection now returns the migrated-item count; when > 0 the handler ALSO emits the existing bulk item-mutation signal (items_bulk_updated, Op=migrate) so open views reconcile via /items-changes. Fires only when the migration touched >= 1 item — a pure settings/quick-actions update emits nothing extra. No store SQL/locking change (Go signature + count plumbing only); make test / make test-pg both green. 2. (P2) QuickActionsMenu's 409 retry GET-by-slug 404s if the competing update renamed the collection. Resolve the fresh collection by STABLE id (list + find by id) before re-appending + retrying, mirroring EditCollectionModal's identity approach; the result-propagation guard is now id-based too so a rename doesn't spuriously drop it. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix: uniform sweep of item-grant delivery, rename routing, and 409/404 retries (BUG-2265, confirming pass 6) One pattern-sweep instead of per-site patches. Audited every event this PR publishes and every client retry path, applying three patterns uniformly: PATTERN A (item-grant SSE reconcile) + B (old-slug rename routing): instead of a SEPARATE items_bulk_updated migration event (which carries op/count for items an item-grant subscriber can't see and isn't rename-routed), FOLD a SANITIZED `items_changed` bool onto collection_updated — already item-grant-delivered (round 3) and already old-slug-routed with new_slug (round 2). On it the client triggers a /items-changes deltaSync (server-filtered to the caller's grants) and ItemDetail refetches its open item, so item-grant EDITORS reconcile migrated field JSON — closing the clobber where a stale full-fields update would UNDO the migration. Leak surface: "a collection you can see items in changed [+ renamed + had item changes]" — a bool, no per-item data. Removed the round-5 items_bulk_updated publish. The pre-existing items_bulk_updated (archive/move) is untouched and correctly stays suppressed for item-grant users. PATTERN C (409 AND 404 in retries): a competing RENAME can 404 a slug-targeted write before it can 409, bypassing recovery. Added isNotFoundError / isConflictOrNotFound helpers; every write/retry path now treats BOTH: QuickActions save resolves-by-id and retries on either; board reorder reseeds-by-id and aborts on either; EditCollectionModal save shows the reload prompt and archive resolves-by-id and retries on either. Tests: server asserts collection_updated sets items_changed on migration (not on settings-only) and stays sanitized; the SSE-filter test asserts the migration variant reaches item-grant subscribers for a visible collection; web unit tests assert 404/409 classification and a real component-driven not_found -> resolve- by-id -> retry in QuickActionsMenu. make test / make test-pg / npm run test all green. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix: stable collection-ID identity for collection events + request-based items_changed (BUG-2265, confirming pass 7) 1. (P1) Collection events were identified only by MUTABLE, reusable slugs, and events replay — so a stale rename event's old slug, once re-owned by a DIFFERENT collection, could pass a slug-based match and misroute a client (navigate away / load the wrong schema) or leak the new slug. Fix at the ROOT: carry the STABLE CollectionID on collection_updated (Event.CollectionID) and match by ID everywhere: - Server visibility: sseEventVisibleFor matches collection_updated on a new visibleCollIDSet (built from the same VisibleCollectionIDs), not the slug — so an event for a collection the subscriber can't see by ID is dropped even if its (reused) slug is in visibleSlugSet. Filter test proves the slug-reuse drop. - Clients: ItemDetail and the collection route match `event.collection_id === <their collection>.id`, not slug. Slug(s)/new_slug stay only for the rename-navigation URL. Settings refreshes its whole list (already id-safe). 2. (P1) items_changed was keyed off the affected-ROW count, delivered to item-grant subscribers → a subscriber whose own items were unaffected could infer that HIDDEN items matched the migrated value. Now keyed off whether a field MIGRATION WAS REQUESTED (len(input.Migrations) > 0), independent of row count — leaks nothing about hidden item values. Reverted round-5's UpdateCollection count-return (no longer needed). Test: a migration matching ZERO items still sets items_changed. Deferred with markers: - NOTE(BUG-2273) at ItemDetail's reconcile-skip AND updateField: the web editor's full-fields field write lacks item-level OCC (never adopted IDEA-1480/v0.14), so the migration reconcile is best-effort. - TODO(BUG-2272) at the reorder 404 reseed: it refreshes `collection` but not the route `collSlug` (renavigation, deferred). Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix: archive OCC (no destructive wrong-target) + settings load fence (BUG-2265, confirming pass 8) 1. (P1) EditCollectionModal handleArchive resolved the target by stable id but the server DELETE re-resolves by the MUTABLE slug — a rename that re-owned that slug before the delete landed would archive the WRONG collection. Close the TOCTOU with an expected_updated_at OCC on the delete, mirroring the update OCC: DeleteCollection re-reads updated_at under a lock (FOR UPDATE on Postgres) and 409s on mismatch; the handler validates the token + maps the 409; the client sends it as a query param; handleArchive passes the seeded token (and the fresh token on the resolve-by-id retry). A reused slug or a concurrently-changed target now yields a clean 409 → the reload message, never a wrong-collection archive. Server test: stale token 409s (and the collection survives); current token 204s; malformed 400s; no token 204s. 2. (P2) settings load(): the generation was bumped AFTER awaiting setCurrent, so a slow load for workspace A could resume after B's load and clobber B's name/context/collections/members. Capture a dedicated loadGen at load() ENTRY (before any await) and fence EVERY continuation on it; the collections write additionally respects collectionsGen so it can't revert a fresher SSE refresh. Using a dedicated loadGen (not the SSE-shared collectionsGen) means an SSE collections-refresh mid-load doesn't drop the name/members writes. Deferred: TODO(BUG-2272) at the collection route's rename-navigation site — the global collectionStore (sidebar/pickers) isn't refreshed and the workspace layout ignores collection_updated, so the sidebar keeps the dead slug. Layout- level renavigation, deferred. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): dedicated item-snapshot fence + id-based rename comparisons in ItemDetail (BUG-2265, confirming pass 9) One comprehensive ItemDetail async-snapshot fence sweep so this file's item/ collection fencing is uniform and ID-based. 1. (P1) The migration item-refetch and loadData shared loadGeneration, so the refetch could apply migrated fields and then a stale loadData response overwrite them (a later full-fields edit then undoes the migration). Added a DEDICATED itemGen (separate from loadGeneration and collectionGen), bumped at the start of BOTH loadData's item load AND the migration refetch, and gated BOTH `item = ` writes on "still latest itemGen" — neither can stale-overwrite the other. Swept the other PASSIVE item snapshot-refreshes onto itemGen too (SSE item_updated/archived/restored, onSync deleted/incremental/full, the collab refresh) so they're ordered against each other and the migration/load. 2. (P2) A settings update that follows a rename before the rename fetch completes requested the OLD slug and bumped collectionGen, cancelling the valid rename fetch. Fetch slug is now `event.new_slug || event.collection || slug`. 3. (P2) The loadData collection fence-escapes compared the stale load's SLUG vs the freshly-renamed snapshot's slug (they differ on a rename → escape let the stale result overwrite). They now compare stable collection IDs; the SSE refresh's post-fetch identity check is id-based too. Audit (site -> generation -> id?): every PASSIVE snapshot-refresh (loadData item+collection, migration refetch, SSE x3, onSync x3, collab) bumps the correct dedicated gen (item->itemGen, collection->collectionGen) and compares identity by id. The DELIBERATE user/action writes (title/field/tag/assignee/role/content/ link/version/restore saves) keep loadGeneration + item-id switch-safety; their item-snapshot concurrency vs the migration refetch is the deferred item-OCC gap (BUG-2273, best-effort) — reordering them last-started-wins is orthogonal to that. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra |
||
|
|
d14bceb3e2 |
fix(web): render the Rich/Markdown mode toggle on the peeking side too (BUG-2263 follow-up) (#988)
The invisible-freeze work (PR #987) left ONE surface still gated on `!peeking`: the editor's Rich⇄Markdown mode toggle. It was hidden on the passive preview because it's a provider-LIFECYCLE control — switching to Markdown nulls collabKey and DESTROYS the retained collab provider, which retain-alive (D2) forbids WHILE peeking. But under focus-follows-editing a click on the toggle fires the host's pointerdown-capture activator FIRST, flipping activePane to that side (peeking=false) before the click's onclick runs. So by the time the flip executes, the side is already ACTIVE and tearing down its own provider is normal active-side behavior — the "teardown while peeking" the gate feared can't happen via a click. The onclick's existing `if (peeking) return` guards (plus the `|| peeking` mid-flush rechecks) remain as the backstop for a re-peek DURING the async flush (e.g. the user clicks the other side mid-flip). So drop the `{#if !peeking}` render gate — the toggle now renders on both sides like every other invisible-freeze surface. Verified in the browser: opening the pane shows the toggle on the peeking preview; clicking the peeking master's "Markdown" button activates it and flips to raw mode in ONE gesture, ProseMirror unmounts cleanly, exactly one typeable editor throughout, zero console errors. Tests: FreezeProbe renders mode-toggle unconditionally; masterFreeze asserts it present while peeking; new host e2e opens the pane, confirms the toggle on the peeking side, and asserts the one-gesture flip (a successful flip proves activation preceded the guarded onclick). Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra |
||
|
|
29e49e4c63 |
fix(web): make the master/pane freeze invisible to the user (BUG-2263) (#987)
On the full-page item host, opening a detail pane froze the non-active
side by DEGRADING its DOM — fields became plaintext, buttons vanished,
the title turned read-only. Under the focus-follows-editing model
(PLAN-2179) the freeze is transient and one-click-reversible, so that
degradation was pure user-visible friction: you'd click a plaintext
field, the click would flip activePane, the field would re-render into a
live control, and you'd have to click again.
The freeze exists ONLY to keep exactly one TYPEABLE collab content editor
(single-owner of the editorStore/activeItem/tab-title singletons). It is
NOT a data-collision barrier: master and pane are always DIFFERENT items,
whose collab state is fully itemID-keyed / instance-local, and most REST
surfaces (fields, title, assign/role, tags, move, delete, share,
relationships, children, comments, reactions, archived restore, star) are
single-item, server-gated, side-independent writes.
So drop the `!peeking` term from those REST surfaces — gate them on
`canEdit` alone (their pre-freeze contract) — and keep `peeking` ONLY on
the content editor and its chrome (rich + raw editors, bubble/link
popover, provider-lifecycle mode toggle + retry). The content editor is
already invisible: the host's pointerdown-capture flips activePane before
the click's caret placement (TASK-2180 no-remount reactive editable), so
one gesture activates the side and lands the edit. Now the whole side is:
click anywhere -> edit it, no visible mode.
Two surfaces are NOT side-independent and stay confined to the active side
(the two documented exceptions, found by Codex review):
- Version restore REST-writes this item's `items.content` directly, which
collides with the retained Y.Doc on a peeking side. Kept frozen via a
new ItemTimeline `restoreFrozen={peeking}` prop; comments/reactions
(separate REST entities) stay live.
- The quick-actions "Manage/New" controls rewrite the whole collection
`settings` from a per-item snapshot (last-write-wins across two items in
one collection), so they gate on `isOwner && !peeking` and recheck
canEdit at dispatch; the read-only prompt-copy actions stay visible on
both sides.
Scope: full-page host only. The collection route never passes peeking, so
`mutationsEnabled === canEdit`, `frozen={peeking}` is inert, and
`restoreFrozen` defaults false there — every change is byte-identical on
that route. `mutationsEnabled` survives but now scopes to content-editor
chrome only.
Tests: rewrote the masterFreeze unit probe + both full-page e2e specs
(host + capstone) to assert the new contract — the frozen side keeps its
editable title/fields/buttons; only its content editor flips
contenteditable=false. The freeze signal moved from `h1.title-readonly`
to the ProseMirror `contenteditable` attribute. Added a runtime-mutation
e2e assertion (a field edit on the frozen master PATCHes the correct item),
a real-QuickActionsMenu integration test, and unit coverage for the two
exceptions.
Two PRE-EXISTING concurrency issues were surfaced by the review (version-
restore gating + collection-settings write-exposure are byte-identical to
main, so this PR neither introduces nor worsens them); filed as BUG-2264
(restore <-> Y.Doc reconciliation) and BUG-2265 (collection-settings
optimistic concurrency).
Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
|
||
|
|
69b361d7b2 |
docs: document shell completion setup in the README (#974)
Adds a Shell completion section to the CLI Reference covering install steps for bash, zsh, fish, and PowerShell, and calls out the dynamic completions that already exist (collection names, --workspace, --status/--priority). Closes #905 |
||
|
|
6fcc20fb33 |
refactor(web): remove dead EditorToolbar.svelte (#973)
Never imported or mounted anywhere (repo-wide search confirms zero references). The live insert surfaces are the slash menu, the mobile toolbar in Editor.svelte, and the selection/bubble menu. Closes #903 |
||
|
|
b447ce9938 |
feat(web): focus-follows-editing (activePane) on the full-page pane host (TASK-2181) (#986)
* feat(web): focus-follows-editing (activePane) on the full-page pane host (TASK-2181)
On the full-page item host, editing now FOLLOWS FOCUS: opening a pane keeps the
master editable and shows the pane as a read-only preview (DR-2); clicking a side
makes it the editable one and freezes the other. Exactly one side is editable at
any moment — the two-editor collision the freeze prevents still holds, now
dynamically. Builds on the TASK-2180 reactive freeze, so flipping `peeking` is a
cheap toggle (no editor remount).
- Host route ([collection]/[slug]/+page.svelte): adds `activePane: 'master'|'pane'`
($state, seeded viewport.isMobile ? 'pane' : 'master' for cold-load; forced to
'pane' on the mobile-breakpoint transition). Master `peeking={!!openItemRef &&
activePane==='pane'}`. First-open re-seeds the active side; drill / in-pane Back /
ESC-pop set 'pane' via the controller's focusPaneRegion dep (single wire point).
A document focusin classifier + capture-phase pointerdown activator flip
activePane only on a CHANGED region, classifying against BOUND elements
(itemPageEl / PaneHost.getPaneRegion()), exempting portalled surfaces (shared
inExemptSurface) and ignoring bare-<body> drops.
- PaneHost.svelte: optional `activePane` prop (unset on the collection route →
byte-identical). Forwards `peeking={activePane==='master'}` to its inner
ItemDetail; exposes getPaneRegion(); the desktop focusin backstop only pulls
focus back while activePane !== 'master' (so it no longer fights master
activation; collection-route behavior preserved when the prop is unset).
- paneFocus.ts: extracts the shared inExemptSurface() set (reused by the mobile
trap and the host classifier).
- ItemDetail.svelte: a FROZEN (peeking) instance never claims the singleton
collectionStore.activeItem / editorStore — loadData gates setActiveItem +
resetForDoc + setLastSaveTime on !peeking; onDestroy gates resetForDoc on
!wasPeeking; the freeze-END reclaim is gated on itemMatchesRef and restores
editorStore dirty/lastSaveTime from the instance's local shadows, so the
singletons always follow the active side.
- +layout.svelte: fences the self-save-suppression's late setActiveItem
continuation on the current activeItem (the ping-pong can switch sides mid-await).
Scope: full-page-host only; the collection route is untouched (pane stays always
editable there). No {#key} added (freeze is reactive). Known deferred R9
singleton-editorStore coupling for in-flight saves tracked in BUG-2184.
Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
* fix(web): frozen side never writes singleton editorStore on remote collab; first-click drill from a frozen preview (TASK-2181)
Two in-scope fixes from an independent review pass:
1. A FROZEN (peeking) ItemDetail must not clobber the singleton editorStore that
the ACTIVE side owns when REMOTE collab traffic syncs into its still-live Y.Doc.
handleContentUpdate's collab path now gates editorStore.setDirty(true) on
!peeking, and the collabFlusher save callback gates the singleton
setLastSaveTime/setDirty(false) on !peeking. The per-instance shadows
(localDirty/localLastSaveTime), the retain-alive snapshot persistence
(collabFlusher.schedule), and the per-instance saveStatus/showSaved all stay
unconditional — so the un-freeze END-reclaim still restores correct values and
+layout's self-save suppression for the active item is no longer corrupted by a
preview's background sync. (Distinct from BUG-2184's deferred pre-freeze
continuation.)
2. A content-link / child-row drill from a FROZEN preview now works on the FIRST
click. The focusin + capture-phase pointerdown detectors exclude navigable drill
targets (isNavigableDrillTarget = closest('a[href]')), so they no longer flip
activePane mid-gesture — which re-inited ChildItems' live dndzone (dragDisabled
tracks the freeze) and swallowed the click. The click's own drill
(navigatePaneTo → focusPaneRegion) sets activePane='pane', so the link drills AND
activates the pane in one click. Exclusion covers BOTH detectors because
Chromium/Firefox focus an <a> on mouse-click. Removed the capstone's
pane-activation workaround and assert the first-click drill instead.
Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
|
||
|
|
3525171886 |
refactor(web): reactive master/pane freeze — drop the peeking-driven editor remount (TASK-2180) (#985)
Make the `peeking` master-freeze work WITHOUT remounting the Tiptap editor, so
a future focus-follows model can flip it on every focus switch smoothly
(PLAN-2179 DR-1). Behavior stays byte-identical for `editable=true` callers —
this only ADDS runtime `editable` gates and removes a remount.
BlockDragHandle was the SOLE construction-gated freeze surface (registered via
`editable ? [...] : []` in Editor.svelte). Make it reactive-editable-aware
instead:
- Register BlockDragHandle unconditionally.
- Choke handle visibility on `editorView.editable` in onMouseMove + the plugin
update() (ProseMirror recomputes view.editable before plugin views update, so
a freeze hides the handle synchronously — no remount).
- Bail every mutation dispatch site on `!editorView.editable`: startDrag,
executeMove, the endDrag move dispatch, showMenu, and the four menu-action
listeners (turn-into, duplicate, delete, attach) plus the deferred
attach-picker change handler.
- Drop `peeking` from ItemDetail's editor `{#key}` (keep item.id +
forceRefreshNonce). The freeze now works via `editable={!peeking}` reactively.
Dropping the remount reopened the "late async continuation / lingering UI"
holes the remount used to close by destroying the editor. Gate each reactively
(no remount reintroduced) so a frozen master still can't be mutated:
- EditorBubbleMenu create: drop the wiki-link insert when `!originEditor.isEditable`.
- htmlBlock commit(): bail on `!editor.isEditable` (a block already in source
mode keeps its native textarea editable across the freeze).
- Editor slash / `[[` pickers: bail execSlash/execLink on `!editor.isEditable`,
render-gate the menus with `&& editable`, and dismiss them on the freeze.
- ItemDetail source-refresh: add `!targetEditor.isEditable` to the post-await
guard so a pre-pane-confirmed content REPLACE drops on a frozen doc.
Everything else the old remount cited was already reactive-gated (attachment
paste/drop, clipboard, image rotate/crop via `!view.editable`; mobile/table
toolbars via `{#if editable}`) — verified. Freshened the now-stale `{#key
peeking}` comments in attachment-upload.ts / attachment-image.ts. Reactive
freeze strictly improves BUG-2177 (no freeze-driven remount to orphan in-flight
editor actions).
Tests:
- blockDragHandleFreeze.svelte.test.ts — real Tiptap editor asserts the handle
hides on freeze, no view/DOM remount across an editable flip, every mutation
path bails while frozen, and the delete path still fires when editable (pure
superset).
- pane-full-page-host.spec.ts — real-browser guards: opening/closing the pane
freezes/thaws the master editor WITHOUT remounting its DOM node
(contenteditable flips in place); the master block drag handle appears on
hover when editable, never while peeking, and returns on close.
Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
|
||
|
|
3725d968e9 |
test(web): Phase-2 CAPSTONE e2e for the full-page pane host (TASK-2175) (#981)
Capstone runtime verification for PLAN-2154 Phase 2 (the full-page pane host, TASK-2170–2174), mirroring the Phase-1 R14 async-race capstone (TASK-2167) but exercising the properties the full-page host alone introduces: - Option-A mutation-SILENCE (the D2/HT-2176 freeze acceptance): while a pane peeks beside the retain-alive master, the key NEW-edit-INITIATION surfaces are disabled/absent (title click-to-edit, field inputs, comment composer, contenteditable editor, star/Share/Quick-actions/Move/Delete/ relationship add+remove); un-peeking restores them. Asserts initiation surfaces, NOT zero writes (a pre-pane pending save + remote collab sync are expected under Option A). Exhaustive per-path freeze stays unit-tested. - Bounded two-WS cost while peeking: opening the pane yields master + pane = 2 collab WS to distinct rooms (total-live ceiling asserted), the master's own room never gets a second provider, a drill re-targets the one pane provider (not N). Plus a WS-instrumented self-collision test: a cold-loaded ?item=<master> mints NO second provider on the master room. - Host-side R14 late-async continuations: drill-right-after-Back (stale back-settle can't revert), close-then-reopen (stale loadData can't clobber — sub-resource GET proof by loaded slug), a held Back burst (coalesces to exactly one mint), and a rapid double-close (one history.go, never overshoots the master route). Adds a localStorage-gated `__padPaneController` test hook to the host route (mirroring the collection page's), so the R14 drill/close continuations are synchronously drivable under adversarial timing. Zero production surface. Gates: svelte-check 0 errors; vitest 444 green; full pane e2e green at --workers=1 (existing 54 + 7 new). Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra |
||
|
|
a0bb31d33b |
feat(web): mount the detail pane on the full-page item host (TASK-2174) (#980)
* feat(web): mount the detail pane on the full-page item host (TASK-2174)
PLAN-2154 Phase 2 / Architecture E, bullet 5 — the Q1 payoff. The full-page
item route (`[collection]/[slug]/+page.svelte`), previously a 47-line wrapper
that rendered `<ItemDetail>` full-page, now mounts the SAME right-docked detail
pane the collection page carries beside the master. From a full-page item,
clicking a child / related / wiki-linked item opens a navigable mini-browser
pane BESIDE the master — the master is the `[slug]` PATH param, the pane is the
`?item=` QUERY param, no collision.
- Reuse the shared `PaneHost` shell, `createPaneController`, and the `paneMint`
provider-mint settle. Collection-specific deps (j/k pane-follow,
list-row focus-return, quick-create draft guard) are correct no-ops here.
- Layout: a flex-row `.item-page-host` fills `.main-content` and clips, with an
`.item-page` overflow column as the master's own scroll container (always, so
scroll restoration is consistent) — mirrors the collection host's `.pane-open`
overflow handling. `createScrollRestoration` gets the `.item-page`
`scrollTarget` getter (TASK-2171); `export const snapshot` is kept.
- Freeze: `peeking={!!openItemRef}` makes the master retain-alive read-only
(TASK-2172) — never a provider teardown. `onIdentity` captures the master's
resolved {id,ref,slug} (TASK-2173).
- Master content-links FIRST-OPEN the pane (`openItemPaneByRef`, a depth-0
paneOwned:true push); pane content-links DRILL (`navigatePaneTo`). Both go
through the `?item == master` guard, plus a cold-load strip effect that drops
a hand-crafted `?item=<master>` once identity resolves — never a second collab
provider on the master's own room.
- Depth-aware ESC mirrors the collection host's escape-stack `pane` slot minus
the list-focus step: depth>0 pops one level (fenced `handlePaneBack`); depth 0
closes via the shared escape stack.
paneHostController: extract `openItemPane(item)`'s body into `openItemPaneByRef
(ref)` and make `openItemPane` a thin `openItemPaneByRef(itemUrlId(item))`
wrapper — behavior byte-identical, so the collection page's pane e2e stays green.
The full-page cross-collection BUG-2129 fence e2e (pane-collection-migration-
race) navigated A->B via a plain relationship-link click, which TASK-2174 now
intercepts to open a pane; adapt it to reach the same same-instance
cross-collection full-page nav via the pane's expand. Add pane-full-page-host
e2e for the new host (open/read-only-master/drill/back/close/self-ref strip).
Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
* fix(web): harden the full-page pane host self-guard + ESC (Codex round 1)
- P1 (race): gate the pane MOUNT on a resolved, confirmed-non-self identity
(`showPane`), not just the after-the-fact `?item=` strip. A cold
`?item=<master-alias>` no longer transiently mounts a 2nd collab provider on
the master's own room before the strip's `goto` fires. Click-driven opens are
unaffected (master identity already resolved); only a cold `?item=` load waits
one master-load beat (master-first).
- P2 (equivalence): make `isMaster` ref-NUMBER aware (case-insensitive prefix,
zero-padding), mirroring paneTarget's `matchesRefNumber`, so `doc-5` /
`DOC-005` / a stale pre-move prefix that resolve to the master are caught by
the self-guard + cold-load strip, not just the byte-exact canonical ref.
- P3 (ESC): defer the host's ESC when a MASTER (non-embedded) graph drawer is
open — it keeps its own window ESC listener, so one press must close only the
graph, not the graph AND the pane. Scoped to a `.graph-drawer` outside
`.item-pane` (the pane's own graph stays in the escape stack).
Add an e2e for the legitimate cold-load `?item=<other item>` path so the
mount-gate can't regress into suppressing valid cross-item cold loads.
Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
* fix(web): provenance-correct master guard + refined graph ESC (Codex round 2)
- P1 (provenance): the hand-rolled `isMaster` checked slug before ref-number,
but a bare `?item=` resolves REF-BEFORE-SLUG server-side — so a ref-shaped
SLUG (master #5 slugged `plan-6`) was misclassified as the master, wrongly
stripping/blocking a legitimate open of item #6. Reuse the shared, unit-tested
`isSamePaneTarget` / `resolvePaneTarget` instead: guards call
`resolvePaneTarget(target, masterItem)` (self-guard for free), and the
cold-load strip + mount gate call `isSamePaneTarget({ href }, masterItem)` (the
documented bare-`?item=` href channel, ref-before-slug). Narrow paneTarget's
`current` param to `PaneGuardItem = Pick<Item,'id'|'slug'|'item_number'>` so
the host can pass the master's resolved identity (item_number parsed from ref)
without fabricating a full Item; a full Item stays assignable, so every
existing caller is unchanged.
- P2 (ESC): only defer to the master graph's standalone listener when it's the
FRONTMOST ESC concern (no pane graph drawer at ESCAPE_PRIORITY.graphDrawer is
the top stack layer). When a pane graph IS the top layer it's innermost and
must close first via runTopEscape.
Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
* fix(web): pane-host ESC layering, block-drag scroll, print (Codex round 3)
- Dual-graph ESC: when a master graph AND a pane graph are both open, the host
closes the innermost pane graph via runTopEscape, but the master graph's
uncoordinated window listener ignores preventDefault. Add a scoped
`stopImmediatePropagation()` (only when a master graph exists) so one ESC
closes exactly one layer; the host's window listener registers before the
master graph's, so it runs first.
- Block-drag auto-scroll: `block-drag-handle.ts` hard-coded `.main-content` as
the scroll container, which no longer scrolls beneath the full-page host's
`.item-page` column (and never matched the docked pane's `.item-pane`). Walk
to the innermost actually-scrollable ancestor by computed overflow-y instead —
class-agnostic (the host's scroll column shares `.item-page` with ItemDetail's
non-scrolling inner wrapper) and also fixes the pre-existing pane-editor case.
- Print: hide the docked pane + divider and un-clip the flex-row host so
Ctrl/Cmd+P captures only the master document (not two items side-by-side with
duplicate footers), mirroring app.css's shell/main-content print unlock which
doesn't reach these route-owned containers.
Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
* fix(web): suppress master-graph listener on held-ESC repeats (Codex round 4)
In the dual-graph case (master graph + pane graph both open), a HELD ESC's
auto-repeats reached the master-graph deferral (or exited the repeat branch)
without stopImmediatePropagation, so the master graph's uncoordinated window
listener (which doesn't bail on e.repeat) closed the second graph during the
same held press. Move the repeat bail before the master-graph deferral and
stopImmediatePropagation on repeats when a master graph coexists, so one
press/hold closes exactly one layer.
Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
* fix(web): gate master identity on ref freshness (Codex round 5)
masterIdentity (from onIdentity) LAGS a same-route master navigation: expanding
a pane item to the full page, then browser-Back to <prev-master>?item=<that
item>, reuses this route, so masterIdentity still holds the expanded item until
the master reloads the previous one. The self-guard/strip then compared the bare
?item= against the WRONG (previous) master and stripped a still-valid pane
(Expand->Back lost the pane). Fold a fresh-for-`ref` check into masterItem (a
$derived over `ref`, so it re-nulls synchronously the instant `ref` changes,
before the strip $effect runs) via the shared isSamePaneTarget; base showPane +
the strip on masterItem instead of raw masterIdentity. Add an Expand->Back
restoration e2e.
Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
* fix(web): route master graph through escape stack + pathname master freshness (Codex round 6)
- Master-graph ESC (converging fix): ItemDetail's full-page (non-embedded) graph
drawer kept its OWN window ESC listener on the now-false assumption of "no
pane/list contention on the full-page route". The full-page pane host adds a
pane beside a non-embedded master, and that uncoordinated listener double-closed
the master graph across ESC paths (dual-graph, held-key, text-edit/dialog
bails). Route BOTH embedded + non-embedded graphs through the shared escape
stack (ESCAPE_PRIORITY.graphDrawer); the host's single runTopEscape then closes
exactly one layer, innermost-first. Simplifies the host ESC handler (drops the
DOM-scan / stopImmediatePropagation whack-a-mole). Contained: embedded=false
ItemDetail is only the [slug] route, which owns that top-level listener; the
collection page's pane graph was already stack-based (unchanged).
- Master freshness (Finding 1, incl. cross-workspace): gate masterItem on a
pathname-stamp comparison instead of a ref-only check. onIdentity stamps the
pathname it resolved for; masterItem ($derived over page.url.pathname) drops a
stale identity the instant the pathname diverges — covering ref, collection AND
workspace navigations uniformly (a bare ?item= can no longer match a prior
master that merely shares a path ref across workspaces).
Deferred BUG-2178 (child of PLAN-2154): the pane's collection-rename/move
navigate-away emits collection-host-shaped URLs on the full-page host — a
separable, obscure refinement.
Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
* fix(web): close ref-shaped-slug guard hole, UUID identity, bind scroll target (orchestrator round 8)
- P1 (ref-shaped-slug HOLE): the server resolves a bare/href ?item= REF-FIRST
then falls back to SLUG (internal/store/items.go), so a master #5 slugged
`plan-6` (no live #6) is reached by the slug fallback — but isMasterRef's
ref-number channel alone returned false (6!=5), WRONGLY permitting a 2nd
provider on the master's own collab room. isMasterRef now matches the raw
string against the master's id/slug too (either interpretation → master); the
guards also drop a resolved content-link ref that string-equals the master
slug (closes the href-only editor-link path). Erring toward a match is the safe
direction for the forbidden D2 collision. Add a ref-shaped-slug cold-load-strip
e2e (seedDoc "plan" → ref-shaped slug plan-<ts>).
- P2 (UUID master routes): itemMatchesRef (ItemDetail) accepted only slug or
PREFIX-NUMBER, so a UUID route never fired onIdentity/onReady → the guard +
scroll-readiness silently disabled there. Add the item.id === itemSlug arm
(additive; also fixes scroll restoration on UUID routes).
- P2 (scroll-target fragility): bind the outer .item-page column element and pass
it to createScrollRestoration instead of document.querySelector('.item-page')
(which also matches ItemDetail's inner wrapper of the same class) — no global
selector, no collision.
BUG-2178 (collection-rename/move-from-pane URL shaping) remains the deferred,
non-destructive follow-up.
Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
* fix(web): mirror server UUID-first precedence in the master guard (round 8 verify)
The raw `candidate === masterItem.slug` check over-blocked a legitimate
`?item=<another item's UUID>` when the master's slug coincidentally equalled
that UUID (the server resolves UUID-FIRST, so it opens the other item, not the
master). isMasterRef now mirrors the server precedence by shape: exact id →
master; a UUID-SHAPED candidate that isn't the master's id → NOT master (don't
fall through to slug); otherwise ref-number OR raw-slug match (still catches the
ref-shaped-slug slug-fallback case). Add an e2e where a master's slug IS another
item's UUID and `?item=<that UUID>` correctly opens the other item.
Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
* fix(web): server-confirm the ambiguous ref/UUID-shaped-slug master guard (round 9 verify)
Shape alone can't decide whether a ref/UUID-shaped `?item=` that string-equals
the master's slug is preempted by a live item (UUID/ref-first) or reached by the
server's slug-fallback to the master (absent/archived/cross-workspace item) —
the over-block (round 8) and under-block (round 9) are the two horns of a
client-side-undecidable problem. Resolve it definitively: masterMatchSync now
returns true/false/NULL, and the NULL (uncertain) case — reached ONLY for a
pathological ref/UUID-shaped master slug, never for a normal plain-slug master,
so zero network cost in normal use — is confirmed by resolving `?item=` to its
actual item id via api.items.get and comparing to the master's id. The mount
gate blocks while that resolve is pending (never a 2nd provider on the master's
room before we know); the strip waits for a definite master (never deletes a
`?item=` that might still resolve elsewhere); the content-link guards drop only
a definite-master resolved segment. On resolve error, err toward blocking.
Both edge tests (ref-shaped-slug strip, UUID-slug over-block) now pass through
the server-confirm path.
Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
* fix(web): key-associate + de-wedge the server-confirm, drop guard over-block (round 10 verify)
Three fixes to the round-9 server-confirm machinery:
- P1 (wedge): the cancelled-fetch dedup left lastConfirmKey pointing at the
abandoned key, so revisiting it early-returned with no result — URL blocked,
no pane, no strip, forever. Replace with an inFlightKey plain-let cleared on
cancel AND completion, so a cancelled key re-fetches.
- P1 (stale result): confirmedTargetIsMaster was a bare boolean not tied to its
target, so a prior key's answer could transiently leak to a new uncertain
target (mount/re-target before confirmation). Store {key, isMaster} and treat
a key-mismatch as pending (confirmedFor). The effect no longer READS the
reactive result it writes (dedup is the plain-let inFlightKey) — no CONVE-1688
read-write cycle.
- P2 (guard over-block): the content-link guards used resolvePaneTarget(target,
masterItem), whose isSamePaneTarget same-item guard over-blocks the same
UUID-shaped-slug case (a UUID href == master.slug treated as self even for a
different live item). Resolve without the current-guard and drop only a
DEFINITE master alias (masterMatchSync === true); an uncertain alias defers to
the mount gate's server-confirm.
Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
* fix(web): revert to the conservative sync master guard (D2 err-toward-match)
The rounds 9-10 async server-confirm — added to distinguish the two
genuinely-undecidable-by-client cases of a ref/UUID-SHAPED master SLUG
(preempts to a different live item vs falls back to the master) — proved a
poor tradeoff: it introduced real race surface (stale-cache-across-archive, a
popstate/mint-settle remount race, SSE-driven refetch) and a shared-code
fireOpenTarget interaction, all for a scenario that requires a PATHOLOGICAL
master slug (one shaped like a ref or a UUID, never produced by an organic
title).
Revert to the simple, conservative sync guard the orchestrator's finding #1
explicitly directed ("return true whenever the target resolves to the master
under EITHER the ref or the slug interpretation ... erring toward a match is the
safe direction"): isMasterRef matches id OR raw-slug OR ref-number. This is
SAFE — it never allows the forbidden D2 collision (a 2nd provider on the
master's room). Its only cost is a BENIGN over-block in exactly the pathological
case (a `?item=<live different item>` whose string coincidentally equals the
master's ref/UUID-shaped slug declines to open a pane — no collision, no data
loss), which matches the existing shared isSamePaneTarget / fireOpenTarget
behavior. Remove the async apparatus + api import + the UUID-slug precision e2e
(the ref-shaped-slug strip test still covers the conservative slug-match block).
Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
|
||
|
|
48dfbe0c7b |
feat(web): expose ItemDetail resolved {id,ref,slug} identity via onIdentity (TASK-2173) (#979)
PLAN-2154 Phase 2 (Architecture E, bullet 4). Beyond the onReady boolean,
ItemDetail now emits its LOADED item's resolved {id, ref, slug} — or null
while unresolved — through a new optional `onIdentity` callback, fired on the
same switch boundary as onReady (itemMatchesRef). A host uses it to enforce
the `?item == master` guard even when a slug-path and a ref-query alias the
same item; the full-page pane host (TASK-2174) compares its `?item=` target
against all three fields before mounting a pane.
New shared `ResolvedItemIdentity` type in $lib/types. Purely additive —
existing callers that don't pass onIdentity are byte-identical. The resolved
identity is a $derived only READ by the emit effect (CONVE-1688-safe).
Behavioral coverage lands with the host guard in TASK-2174/2175.
Verified: npm run check 0 errors.
Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
|
||
|
|
acd9f3ff1b |
feat(web): retain-alive master freeze — peeking prop + complete mutation-path audit (TASK-2172) (#977)
PLAN-2154 Phase 2 (Architecture E, D2/R6/R9/R11/R12), per Dave's HT-2176 Option A.
Adds a `peeking` prop to `ItemDetail` so the full-page master goes read-only +
ALIVE while a detail pane peeks beside it — retain-alive: the collab
PROVIDER/Y.Doc is never torn down/flushed. `peeking` defaults false → every
existing caller byte-identical (no host passes peeking={true} until TASK-2174).
Invariant (HT-2176 Option A): the freeze blocks only the INITIATION of a NEW
edit while peeking; a save/action the user STARTED before the pane opened
completes normally. Acceptance: "no NEW user-originated edit can be INITIATED
while peeking" (not "zero dispatch").
Core freeze (blocks NEW-action initiation): `mutationsEnabled = canEdit && !peeking`
(unit-tested `computeMutationsEnabled`) gates title / fields / tags / assign /
move / delete / relationship-add; ChildItems add/link + reorder INITIATION;
timeline comment compose + edit forms + upload; version-restore initiation;
archived restore; star (`!peeking`); Share + the whole quick-actions menu
(`!peeking`). The rich Editor gets `editable={!peeking}` + `peeking` in the
`{#key}` (remounts re-bound to the SAME live Y.Doc, shedding BlockDragHandle —
D2's mechanism); bubble/link popovers gated.
Pre-pane in-flight SAVES/ACTIONS complete (Option A) — no suppression:
- FIELD: `updateField` has NO peeking recheck; a value typed before the pane
opened fires its 500ms FieldEditor debounce and saves. NEW field input is
blocked at the UI (`FieldEditor readonly={!mutationsEnabled}`).
- TAG / RAW / COLLAB debounced saves complete (drain/save callbacks un-gated).
- CHILD REORDER: gated at INITIATION (`dragDisabled: !canEdit || frozen`), NOT
finalization — a drag already in progress when the pane opens persists fully
(removed the `frozen` reject from `handleFinalize`).
Documented deliberate exceptions/edges:
- Rich⇄Markdown mode FLIP is refused while peeking (it would teardown/recreate
the collab provider — retain-alive/D2); the CONTENT still flushes, only the
flip is deferred. Commented at both toggle handlers.
- BUG-2177 (accepted, tracked, sev=low): the D2 `{#key}` editor remount destroys
the initiating view, so an in-flight EDITOR-BOUND action (attachment upload,
rotate/crop, source-refresh, timeline-composer upload) is orphaned — bails
gracefully via `view.isDestroyed`/identity checks (no crash, no committed-
content loss). Referenced at every bail site.
Confirmed no teardown: `createCollabFlusher`'s save is only
`api.items.flushCollabContent`; `provider.destroy` lives solely in the
`{#key}`/forceRefreshNonce $effect cleanup.
R12 complete editor-DOM-handler audit — every custom handler that dispatches a
mutation is editable-gated (AttachmentUpload paste/drop/command; AttachmentImage
rotate/crop/toolbar; code-block + table clipboard CUT/PASTE — copy read-only;
htmlBlock edit-mode). These also close the same latent holes for view-only
viewers (already-broken mutations) — a bug fix, not a working-flow regression.
R9 clear-if-owner activeItem via `activeItemOwnedId` (byte-identical on a
cross-collection load failure) + re-assert on un-peek; R11 master title effect
yields while peeking. Timeline freeze via a `frozen` prop threaded ItemTimeline →
TimelineCommentCard / TimelineVersionCard. Tests: `computeMutationsEnabled` truth
table + FreezeProbe gate probe + a FieldSaveProbe mount test proving a pre-pane
debounced field save completes after the field goes read-only. Running-app
mutation-silence e2e is TASK-2175 (F). Not a Y.Doc node-spec change —
SCHEMA_VERSION/DefaultSchemaVersion unchanged.
Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
|
||
|
|
04923c1c56 |
refactor(web): extract the pane controller + shell into paneHostController + PaneHost (TASK-2170) (#978)
* refactor(web): extract pane controller glue into paneHostController (TASK-2170) PLAN-2154 Phase 2 (Architecture E) increment 1 of 2. Move the pane- navigation controller — the depth/ownership state machine, the fenced `history.go` traversals + their `afterNavigate` settle, and the three-way staged close — out of the 4805-line collection route into a shared `$lib/collections/paneHostController` factory, so the collection page and (in TASK-2174) the full-page item host mount ONE controller with no duplicated arithmetic. The pure decision logic already lived in `paneController.ts` (unit-tested); this extracts the SvelteKit-bound glue that executes those plans. The five collection-specific hooks are injected: getOpenItemRef, cancelFollow (j/k pane-follow), focusPaneRegion (owned by the shell), captureReturnFocus (list-row focus return), and setBypassNavGuard (unsaved-draft leave guard). The route destructures the controller with the SAME names its markup, handlers, and test hook already use, so no call sites changed; the depth- aware ESC pop now routes through the public `handlePaneBack` primitive instead of the now-internal `paneHistoryGo`. Behavior-preserving: `npm run check` 0 errors, vitest 435 passed, pane e2e 49/49 (matches the pre-extraction baseline). Increment 2 extracts the <aside> shell + resize/focus into PaneHost.svelte. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * refactor(web): extract the pane shell into PaneHost.svelte (TASK-2170) Increment 2/2 of PLAN-2154 Phase 2 (Architecture E, bullet 1). Pull the right-docked detail-pane <aside> SHELL out of the 4805-line collection route into a new reusable web/src/lib/components/collections/PaneHost.svelte so both the collection page and (in TASK-2174) the full-page item host mount ONE shell. Moved into PaneHost: the .pane-divider + <aside class="item-pane"> markup and its {#if paneMintForRoute}<ItemDetail embedded .../> render; the resize machinery (paneEl/paneWidth/storedPaneWidth/measuredPaneWidth/ariaMin/ariaMax state, PANE_WIDTH_* consts, paneBounds/clampPaneWidth/readStoredPaneWidth/ setPaneWidth/fittedPaneWidth/refitPaneWidth/currentPaneWidth, the ResizeObserver effect, the divider pointer/keyboard handlers); the focus machinery (focusPaneRegion, the mobile focus-into effect, the mobile focus-trap effect, the desktop focusin backstop); and the .item-pane / .pane-divider / mobile @media CSS. The mid-drag body-chrome cleanup that the route ran from an openItemRef→null effect is now a PaneHost onDestroy — the shell's unmount IS that transition. Stays in the route (unchanged): the {#if openItemRef}<PaneHost/>{/if} gate, paneMint (paneMintRef/paneMintPathname/paneMintForRoute + createPaneMintSettle + its afterNavigate + onDestroy cancel — its always-mounted lifecycle is load-bearing for TASK-2166 popstate coalescing, so the route computes paneMintForRoute and passes it as a PROP), createPaneController, schedulePaneFollow/cancelPaneFollow, focusedItemId + pane-snap, returnFocusToList/resolvePaneReturnTarget, dismissPaneOnBackgroundClick, handlePageKeydown. Seam: PaneHost exposes focusPaneRegion via a Svelte 5 instance export; the route binds bind:this={paneHostEl} and rewires both the controller's focusPaneRegion dep and the list→pane Tab bridge to paneHostEl?.focusPaneRegion(). Behavior-preserving refactor — the load-bearing no-{#key} invariant is intact (an A→B switch stays a ref PROP UPDATE reusing the one mounted ItemDetail instance + its single collab provider + SSE sub; the only mount/unmount is the outer {#if openItemRef}). Gates: svelte-check 0 errors; vitest 435 passed; pane e2e 49 passed / 49 skipped / 0 failed (the pre-extraction baseline). Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra |
||
|
|
11c7e1e98c |
feat(web): parameterize scroll-target in createScrollRestoration (TASK-2171) (#976)
* feat(web): parameterize scroll-target in createScrollRestoration (TASK-2171) Add an optional `scrollTarget` getter to `ScrollRestorationOptions` and route both the capture and restore-loop call sites through a local `resolveScrollTarget = opts.scrollTarget ?? getScrollTarget`. When omitted (all 12 existing callers), behavior is byte-identical — the module default still resolves `.main-content`, falling back to `window`. This unblocks PLAN-2154 Phase 2 (Architecture E): the full-page item host scrolls its own `.item-page` overflow column once a detail pane docks beside it, and will pass that container via the new getter in TASK-2174. Verified: `npm run check` (0 errors), `make install` web+go build green. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): retry scroll restore when a custom scrollTarget is not yet mounted (TASK-2171) Codex review caught a doc/code gap: the scrollTarget getter's documented per-frame-retry contract wasn't delivered — the tryScroll loop aborted permanently when resolveScrollTarget() returned null, and since restoredKey is already set for the key, the gated effect never re-fires, stranding the parked offset. Now a null target re-schedules the RAF within the existing 2s budget until the element mounts. The default .main-content/window resolver never returns null in-browser, so existing callers are unaffected. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra |
||
|
|
2aff33d99c |
test(web): R14 async-race capstone e2e suite for the pane mini-browser (TASK-2167) (#975)
The R14 fence-on-continuation sweep of every pane-controller async
continuation site (schedulePaneFollow debounce, paneHistoryGo + afterNavigate
latches, the popstate mint-settle, and ItemDetail's loadGeneration-gated
loadData/SSE/save continuations) confirmed all sites are already fenced by
TASK-2155/2156/2157/2161/2162/2166 — this adds the capstone suite that
exercises those fences end-to-end under adversarial timing so a regression at
any one fails loudly.
Eight tests, each asserting the R14 invariant (no late continuation writes
stale state over a newer item — no ?item= clobber, no depth desync, no crossed
content, no stale field write-back), made deterministic and non-vacuous
(route-gated stale requests proven to enter the gate and drain; fenced-out
actions proven to issue no fetch; crossed-Y.Doc caught at the editor-body
level). Each timing test runs in its OWN fresh collection so its list is
deterministic and it doesn't bloat the shared docs list:
1. a late j/k follow surviving a popstate-forward drill — isolates the
continuation-time depth RE-CHECK from the cancellation belt (the follow
target is held distinct from the forward target so the same-item guard
cannot be what saves it);
2. a drill fired right after a browser Back, mid mint-settle;
3. a close + drill fired during an in-flight cold-base close traversal + latch
(proves the fenced-out drill target is never fetched);
4. a rapid ?item= re-target while a route-gated loadData GET is outstanding
(title + field + editor body all stay on the newer item);
5. a deep browser Back/Forward traversal through a drilled stack (editor-body
locked per hop);
5b. a genuine multi-popstate Back BURST (each back chained off the preceding
popstate; 3 events asserted) coalescing via the mint-settle;
6. a cross-collection Back onto an open pane — paneMintForRoute route-reuse
clamp, proven by the fetch sequence (destination item fetched, stale source
never re-fetched);
7. a loadData GET resolving after the pane closed and a new item opened writes
nothing over the new item.
No production code changes: the audit found no unfenced continuation to close.
Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
|
||
|
|
57da084a32 |
feat(web): focus per hop for the detail-pane mini-browser (TASK-2162) (#972)
* feat(web): focus per hop for the detail-pane mini-browser (TASK-2162)
PLAN-2154 Architecture C / R1. Each in-pane hop (a content-link drill or
an in-pane Back) changes `?item=`, remounting ItemDetail's `{#key itemSlug}`
subtrees and destroying the just-activated link/row; `keepFocus` then drops
focus to `<body>`, where the next `j`/`k` runs list-nav and can laterally
re-target the drilled `?item=`, corrupting the stack.
Host now owns focus-per-hop: `focusPaneRegion` moves focus onto the STABLE
aria-labeled `<aside>` synchronously at each drill (`navigatePaneTo`) and
Back-button pop (`handlePaneBack`), before the imminent remount can drop it.
This covers the editor-link keyboard path (EditorLinkPopover hides its
popover — removing the focused anchor — synchronously) since it funnels
through the same `navigatePaneTo` chokepoint. A narrowed desktop `focusin`
backstop mirrors the mobile trap, re-pulling focus into the pane only when
it drops to `<body>` from within `.item-pane` (the list stays reachable).
The depth-aware ESC pop removes no focused control, so it deliberately does
not force focus into the pane — that would fight the depth-0 return-to-list.
Resolves the focus edge TASK-2164 deferred here: its component-local
armed-`pendingBackFocus` restore (which could mis-fire onto a pane control
on a coalesced Back-then-Forward no-op) is retired in favor of this
deterministic host-owned focus — the explicit host resolution signal
TASK-2164 round 10 identified as the real fix. `onBack` is now a pure notify.
Tests: focus-per-hop e2e (mouse + keyboard drill land focus in the pane
mid-load with the destination gated so only `focusPaneRegion` could have done
it; j stays inert; in-pane Back keeps focus in the pane). TASK-2164's
Back-chevron focus assertions reconciled to assert focus-in-pane (the actual
acceptance criterion) rather than a specific button. The depth-0 ESC test now
asserts the standard two-level return-to-list-then-close, which focus-per-hop
correctly restores (drilling no longer strands focus on `<body>`).
Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
* fix(web): run focus-per-hop on mobile + the ESC pop (Codex round 1)
Two P1s from review:
- focusPaneRegion no-op'd on mobile while pendingBackFocus (which was NOT
desktop-gated) had been removed, so a mobile Back/drill could strand focus
on <body> behind the overlay if the mobile trap's focusin missed the
removal. Focus the stable paneEl on mobile too — a synchronous belt that
supersedes what pendingBackFocus gave the mobile Back path (j/k is already
inert on the overlay, so this is purely the a11y "don't lose focus" belt).
- The depth-aware ESC pop skipped focusPaneRegion; if the user had Tabbed to
a control the pop then removes (e.g. the Back chevron via the header swap),
focus could strand on <body>. Land focus on the stable paneEl at the pop.
It targets paneEl (never a removed control) so it needs no focusin, and it
doesn't fight the depth-0 two-level ESC — that's a separate later press that
correctly returns focus to the list from within the pane.
Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
|
||
|
|
a0299321ca |
feat(web): in-pane Back chevron for the pane chrome (TASK-2164) (#970)
* feat(web): in-pane Back chevron for the pane chrome (TASK-2164) Render a Back chevron in the pane header (ItemDetail's embedded-chrome branch) whenever `page.state.paneDepth > 0` (PLAN-2154 Architecture C). Wired to the collection host's existing fenced `paneHistoryGo(-1)` / `paneNavInFlight()` traversal — the same mechanism the depth-aware ESC handler (TASK-2163) uses — rather than a bare `history.back()`, so a rapid double-click can't stack a second traversal (R14). Depth is read reactively via SvelteKit's `page.state` accessor inside ItemDetail itself, so a cold-loaded shared `?item=` correctly starts at depth 0 with the chevron hidden. Shared markup covers desktop and the mobile full-screen overlay alike. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): keep the Back chevron reachable in the pane's minimal header Every drill sets loading=true, which switches ItemDetail to the minimal embedded header (previously Close-only) — so a slow or failed drilled item stranded the user with no way back, worst on mobile where ESC isn't reachable. Mirror the same depth-gated chevron there (Codex review round 1). Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): restore focus to the Back chevron after each pop (Codex round 2) Clicking Back triggers a reload of the drilled-to item, which briefly sets loading=true and swaps the loaded pane header for the minimal one — unmounting the just-clicked, focused Back button and dropping focus to <body>. Without this, a keyboard user popping a multi-level drill stack loses their place after every press. Track the intent with two flags (not one): `paneDepth` updates synchronously with the popstate `history.go(-1)` fires, landing before `itemSlug` changes and `loadData()` actually sets `loading = true` — a single-flag effect can observe a stale `loading === false` on that intermediate run and consume the flag before the real reload cycle even starts. `backFocusSeenLoading` requires observing `loading` go true first, so the restore only fires on the matching false after it. Also fixes a race in the new multi-hop e2e test: `drillTo()` only awaits `navigatePaneTo`'s synchronous portion (the `goto()` it fires is fire-and-forget), so firing three drills back-to-back without polling between them could read stale depth. Poll after each hop, and assert the Back button keeps focus across presses. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): key Back-chevron focus restore off itemSlug, not a loading transition (Codex round 3) Codex round 3 found a real race in the loading-transition-based focus restore: pressing Back again from the minimal header — while a PRIOR item is still loading (paneNavInFlight() only fences the history.go traversal, not the data fetch) — could let the effect latch onto that stale, already-in-flight cycle instead of its own. Investigating with instrumentation showed the generation-fenced version of the fix was actually correct in isolation, but the added console logging was itself perturbing timing enough to mask a genuinely flaky window under heavier parallel load. Replaced the two-flag, generation-counting design with a much simpler one: key off itemSlug (purely URL-derived, updates synchronously with the same popstate that stamps paneDepth, independent of fetch status) actually changing away from the ref captured at click time, then wait for loading to clear. No transition-observation bookkeeping needed. Verified with 25+ consecutive passes of the new race-condition e2e test across workers=1/2/4, including alongside the full pane suite under system load from concurrent sibling agents. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): fall back to the Close button after the terminal Back pop (Codex round 4) The last Back press (landing at depth 0) has no Back button left to restore focus to, so it was stranding focus on <body> with the pane still open. Fall back to the always-present Close button — a stable, keyboard-reachable control — rather than leaving it unhandled. Full "focus per hop" for arbitrary content-link drills stays TASK-2162's scope; this only closes the gap the Back button's own terminal press opened. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): bind the minimal header's Close button too (Codex round 5) closeBtnEl (the terminal-Back-pop focus fallback added in the previous commit) was only bound on the loaded header. If the pop's destination is slow or fails to load, the minimal header can still be mounted when the restore effect fires, leaving closeBtnEl undefined and the restore a silent no-op. Bind it on both headers, mirroring backBtnEl's existing pattern. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): fence Back-chevron focus restore against a superseding navigation (Codex round 6) Comparing only itemSlug treated ANY later navigation as "the Back pop landed" — so clicking a different row (or hitting Forward) while the Back destination is still loading would let that unrelated navigation's itemSlug change satisfy the check, stealing focus into a pane the user never asked to restore-focus on. Also left the pending flag armed indefinitely across a quick Back->Forward that never matched. Fence with loadGeneration (bumped synchronously at the top of every loadData() call, regardless of trigger): capture it at click time and require the settled generation to be EXACTLY one more — the Back click's own load and nothing else raced in ahead of it. A mismatch abandons the restore instead of stealing focus, and self-resolves the "armed indefinitely" case since loadGeneration only increases. Verified the new regression test actually catches the bug: temporarily disabled the guard, confirmed the test fails (focus lands on Close), then restored it and confirmed it passes. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): bound the Back-chevron focus-restore intent with a timeout (Codex round 7) The generation fence alone didn't self-resolve every "superseded" case: a Back immediately reversed by Forward within TASK-2166's ~140ms pane-mint settle window can coalesce to a net no-op — neither itemSlug nor loadGeneration ever change — leaving pendingBackFocus armed with nothing left to ever falsify the generation check against. The next, wholly unrelated single-load navigation would then satisfy backFocusStartGen + 1 by coincidence and steal focus. Rather than chase each interleaving individually, bound the pending intent with a timer (mirroring the host's own PANE_GO_SETTLE_MS "give up waiting" pattern): if the click's own restore hasn't resolved within 600ms, disarm unconditionally. Cleared on both the normal resolve path and onDestroy. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): replace the blunt Back-focus timeout with a targeted no-op check (Codex round 8) Round 7's flat 600ms wall-clock timeout disarmed the pending restore unconditionally, which also cancelled a legitimately slow, still- in-flight restore — loadData() makes several requests, and a modestly slow connection can easily outrun a few hundred ms, stranding a keyboard user's Back press on <body> for the common case, not just the rare coalesced-no-op one. Replace it with a single check shortly after TASK-2166's ~140ms pane-mint settle window: itemSlug updates synchronously with the popstate (independent of network speed) and loadGeneration bumps synchronously at the top of loadData(), so a load that's genuinely in flight has already moved one of the two by the time the check fires. Only the coalesced-to-nothing case (Back immediately reversed by Forward) still shows both unchanged — that's the only case the check disarms. Every other pending restore, however slow, resolves normally through the existing effect. Added a dedicated regression test proving a 400ms-delayed (well past the 200ms check) but legitimate destination load still restores focus correctly. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): correct the Back-focus no-op check window against TASK-2166's mint settle (Codex round 9) An earlier comment claimed itemSlug updates synchronously with the popstate onBack triggers — true before this branch merged TASK-2166, false after: that sibling change added a provider-mint settle that deliberately coalesces EVERY popstate-driven ?item= change onto the <ItemDetail> ref prop (even a single Back press) behind a PANE_MINT_SETTLE_MS (140ms) window, so itemSlug only starts moving ~140ms plus the history.go->popstate round-trip after the click, regardless of network speed. The 200ms no-op-check window from round 8 started at click time, so any traversal taking as little as ~60ms beyond the mandatory 140ms settle could clear pendingBackFocus as a false no-op, stranding a keyboard user's Back press on <body>. Import PANE_MINT_SETTLE_MS from the authoritative source and size the check at 2x it for headroom over the popstate round-trip on top of the settle itself. Also consolidated the accumulated round-by-round comment trail into a single numbered summary plus one timing note, since the old inline diary had an outdated claim baked into it. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): drop the timer-based Back-focus no-op heuristic (Codex round 10) Three review rounds (7-9) tried progressively longer wall-clock "give up waiting" timeouts to disarm a stale pending focus-restore on a coalesced Back-then-Forward no-op. Each one got shown to be racing an ever-longer worst-case bound elsewhere in the pipeline: the mint settle, then the settle plus the popstate round-trip, then paneHistoryGo's own 500ms fallback on top of both. A fixed-duration heuristic is structurally the wrong tool for this — closing it for real needs an explicit "did this specific click's traversal resolve" signal from the host, which is new cross-component plumbing, not a hardening pass on the existing effect. Drop the timer. Keep the generation fence (timing-independent, and the part that closes the actual bug class Codex round 6 found: an unrelated navigation superseding a stalled Back pop). Document the residual gap as a known, narrow, low-severity limitation for TASK-2162 (the already-planned general "focus per hop" follow-up) instead of continuing to patch it here: worst case, focus lands on a nearby, still-sensible, keyboard-reachable pane control instead of the ideal target — not lost, not a correctness or data issue, and it requires a fairly deliberate Back-then-Forward sequence to trigger at all. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra |
||
|
|
b37797edfc |
feat(web): settle popstate-driven pane mints to one teardown/mint (TASK-2166) (#971)
* feat(web): settle popstate-driven pane mints to one teardown/mint (TASK-2166) A held Back/Forward fires one popstate per history entry traversed, and each `?item=` change re-drives the split pane's collab provider (destroy + fresh Y.Doc/WS + op-log replay). j/k pane-follow already coalesces via PANE_FOLLOW_DEBOUNCE_MS, but popstate bypassed it entirely. Add paneMintSettle (pure, unit-tested) to settle popstate-driven `?item=` changes ~140ms before feeding ItemDetail's `ref` prop, while a deliberate open/drill/close still applies immediately. Audited the rest of PLAN-2154 Architecture D: loadUrlFilters' known-params whitelist, updateUrlFilters' `?item=`/page.state re-emit, and the `!openItemRef` title gate were already correct (TASK-2157/2158). The pane's scroll container deliberately skips the route-level persistKey/snapshot system entirely (PLAN-2105), so there's no persistKey collision to fix. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): guard pane-mint settle against cross-route reuse and null refs Two Codex-review findings on the popstate settle (TASK-2166): 1. This route component is reused across a workspace/collection switch (same route id, different page.params) — wsSlug/collSlug update immediately while a coalesced ref could linger from the SOURCE workspace for up to the settle window. Gate coalescing on a same-pathname popstate only; any pathname change forces the settle module's immediate branch. 2. The `paneMintRef ?? openItemRef` fallback silently bypassed the settle whenever paneMintRef was null (pane opening from closed via a Forward burst), passing every intermediate ref straight through. Nest `<ItemDetail>`'s mount on `paneMintRef` itself (ref: string is required, so no fallback) instead of falling back to the live URL value. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): never delay a pane-close settle (Codex review) A popstate landing on ref=null (the pane closing) now applies immediately instead of joining the settle window. The pane's mount boundary (`{#if openItemRef}`) already reacts to the raw URL instantly, so a delayed null left a stale non-null paneMintRef around: a quick Back-to-close-then-Forward-to-reopen within the settle window would remount ItemDetail against the pre-close ref before the real settle caught up — wrong content plus a wasted extra mint. Adds a close-then- reopen burst regression test. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): close the afterNavigate-timing gap on cross-route pane mints Codex review (PR #971 round 2): afterNavigate fires AFTER SvelteKit has already committed page.params/page.url and Svelte has re-rendered the $derived tree over them (wsSlug, collSlug, ItemDetail's props). Correcting paneMintRef only from afterNavigate therefore still let one render pass through pairing the DESTINATION workspace/collection against the STALE SOURCE item ref on a workspace/collection switch (this route component is reused across such switches). Add paneMintForRoute, a $derived clamp that recomputes SYNCHRONOUSLY in the same reactive pass as page.url.pathname itself: it falls back to the live, always-route-consistent openItemRef the instant the pathname diverges from paneMintPathname (the plain var afterNavigate corrects, now just a hint rather than the sole source of truth). A same-pathname settle-in-progress — the actual pane-burst case this feature targets — is unaffected; the clamp is a pure no-op whenever the pathname hasn't changed. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra |
||
|
|
c9564713e1 |
feat(web): un-hide structural parent breadcrumb in the embedded pane (TASK-2165) (#969)
Single parent hop for v1 (PLAN-2154 Architecture C / D3): the embedded pane now renders Home / parent-collection / parent / current, built entirely from the item's own parent_ref/parent_slug/parent_collection_slug fields (zero new fetches), so it's correct even on a cold-loaded shared ?item= pane URL. The parent hop routes through the pane-navigate callback (paneOpenTarget) with href preserved for modifier/middle-click popout. Extracted breadcrumbParentTarget() into $lib/collections/paneTarget.ts, alongside the existing resolvePaneTarget/isSamePaneTarget helpers, so the reconstruction logic is unit-testable without mounting ItemDetail. The full-page (!embedded) breadcrumb is unchanged. |
||
|
|
b14953a94e |
feat(web): Phase 1 depth-aware ESC — pop one level at depth>0 (TASK-2163) (#967)
* feat(web): depth-aware ESC pops one drill level in the pane (TASK-2163) At depth>0 in the item pane's mini-browser stack, ESC now runs history.back() to pop exactly one drill level and consumes the key, instead of routing through the list-row helpers (returnFocusToList / resolvePaneReturnTarget) which are meaningless once detached. Only at depth 0 does ESC fall through to the existing two-level return-focus-to-list-then-close behavior — unchanged on both desktop and mobile. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): route depth-aware ESC through the fenced paneHistoryGo Codex review round 1: a bare history.back() never set paneGoInFlight, so repeated ESC presses (or an ESC racing a close/reset click) could queue a second traversal against stale depth and overshoot. Route the pop through the existing paneHistoryGo(-1) fence, matching every other controller history.go call site. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): ignore auto-repeat ESC keydowns in the depth-aware pop Codex review round 2: a held ESC key fires many auto-repeat keydowns, and each one settles the paneHistoryGo in-flight fence before the next arrives, so one held press could unwind several drill levels (even closing the pane). Gate the pop on `!e.repeat` so a held key still consumes ESC but only ever pops the level from the initial physical press. Adds a Playwright test that dispatches synthetic repeat:true keydowns and asserts no further level pops. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): hoist the ESC repeat-guard above the depth-aware branch Codex review round 3: gating !e.repeat only inside the depth>0 branch left a leak at the depth 1->0 boundary — the physical press pops to depth 0, then subsequent repeat:true events (now depth 0, no .item-pane focus) fell through to runTopEscape() and closed the pane within the same held key. Move the repeat check to the top of the ESC chain so every repeat is a pure no-op regardless of what the initial press already changed. Adds a boundary-crossing Playwright test. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): detect an open dialog/BottomSheet by existence, not focus Codex review (2nd pass): the ESC handler's dialog guard checked whether the EVENT TARGET was inside a dialog/[role=dialog], but BottomSheet/DockedSheet don't move focus into themselves on open — so Quick Actions / Move To on mobile left document.activeElement on the trigger button back inside .item-pane. ESC then fell through this guard, popped/closed the pane underneath, AND the sheet's own independent window listener also closed it — two layers from one press. Switch to an existence check (`dialog[open], [role="dialog"]`) so it detects an open sheet regardless of focus. `dialog[open]` (not bare `dialog`) because Modal.svelte's native <dialog> is always mounted and toggled via showModal()/close(), so a bare existence check would false-positive on any page with an idle Modal instance. Adds a mobile Playwright regression test. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra |
||
|
|
af7cc1135c |
feat(web): editor content-link pane interception via EditorLinkPopover (TASK-2160) (#968)
* feat(web): editor content-link pane interception via EditorLinkPopover (TASK-2160) Injects the pane-navigate callback at EditorLinkPopover.handleHrefClick, the single goto chokepoint for editor content-body/wiki-links (all other editor anchors are inert data-href + globally preventDefault-ed). A content-link to a same-workspace item now drills the split pane in place when onOpenTarget is wired; modifier/middle-click, external URLs, cross-workspace resolver links (/-/r/), links to other workspaces, and non-item app routes keep navigating. - editorHrefClick.ts: pure planHrefClick() decision extracted for unit testing (mirrors itemCardClick.ts's shouldOpenInPane pattern). - paneTarget.ts: isSameWorkspaceItemHref() validates the href positionally — workspace slug matches current, collection segment is a known collection whose prefix matches the ref's prefix (case-insensitive), and a positive ref number — so a cross-workspace plain path, a non-item route, a self- inconsistent collection/ref path, or a TASK-0 ref can't be misrouted into the current pane's ?item=. - ItemDetail.svelte: threads fireOpenTarget + wsSlug + a collection slug→prefix map into EditorLinkPopover, and only when a real onOpenTarget is wired (full-page / unembedded views keep plain navigation). PLAN-2154 Architecture B.3. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): harden editor content-link pane gate (Codex round 3) - alt-click now passes through to native download (matches shouldOpenInPane and every other pane interceptor). - isSameWorkspaceItemHref rejects malformed double-slash / trailing-slash paths (strict segment parse, no filter(Boolean)) and accepts digit-bearing collection prefixes (e.g. R2-1) by comparing the ref against the KNOWN collection prefix instead of a letters-only REF_SHAPE. - collectionStore stamps `collections` with its source workspace (collectionsWorkspace / collectionsAreFreshFor), mirroring itemsWorkspace; ItemDetail's collection prefix map declines (empty → safe goto fallback) while a workspace switch's loadCollections() is in flight, so a link is never validated against a stale previous-workspace prefix set. TASK-2160 / PLAN-2154 Architecture B.3. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): canonicalize link host + fence collection load order (Codex round 4) - isSameWorkspaceItemHref parses the href with the URL API and requires the resolved host to equal the sentinel base host, so a backslash-authority href ("/\evil.example/...", which browsers resolve cross-origin) or a protocol-relative "//host/..." is treated as external and navigates instead of being misclassified as a local item and pane-drilled. - collectionStore.loadCollections gains a monotonic load-generation guard so a late-resolving previous-workspace response can't clobber the newer workspace's collections / collectionsWorkspace stamp (which would otherwise strand collectionsAreFreshFor at false and suppress valid pane drills). TASK-2160 / PLAN-2154 Architecture B.3. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): require canonical href form in the pane gate (Codex round 5) The round-4 URL-parse fix validated the CANONICALIZED pathname, but the RAW href is what the host later resolves (resolvePaneTarget splits on "/" only), so a backslash-in-segment href like /alice/myws/tasks\TASK-5 normalized to a valid item during validation yet would drill a bogus `tasks\TASK-5` item. Require the raw path to already equal its canonical url.pathname; any non-canonical href (backslash, dot-segments, encoded chars) navigates normally instead. Every item-URL builder emits clean ASCII paths, so this rejects nothing legitimate. TASK-2160 / PLAN-2154 Architecture B.3. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra |
||
|
|
62f2d0a46b |
feat(web): drill the pane from relationship, child, and graph-Open anchors (TASK-2159) (#966)
Intercepts the anchor-based content-link surfaces PLAN-2154 Architecture B targets — the relationships link-target anchor, ChildItems/NestedChildren row anchors, and ItemGraph's node "Open" anchors (not the node click, which stays select-only) — so a plain click drills the pane in place via onOpenTarget/fireOpenTarget, while modifier/middle-click still falls through to the anchor's href for a full-page popout. |
||
|
|
fe04de0f8f |
feat(web): detach at depth>0 — clear list highlight + gate pane-snap (TASK-2161) (#965)
At stamped pane depth>0 the split-pane detail is a detached mini-browser, so the collection list must go inert. TASK-2157 already made schedulePaneFollow bail at schedule time and re-check depth in its fired callback, cancelled the pending follow on navigatePaneTo, and made a detached list-row click RESET the stack (never a replace of the drilled entry). This closes the two remaining detach gaps from PLAN-2154 Architecture C / D-detach: - focusedItemId (the List/Board/Table row-highlight marker) now gates on currentPaneState().paneDepth === 0, so the highlight is CLEARED once the pane drills past its base and re-appears on unwind (page.state is reactive). - the pane-snap $effect bails when depth>0, so it no longer snaps the cursor to a drilled item that may not even be in the list. Together with the pre-existing schedulePaneFollow guards, j/k is fully INERT at depth>0: no pane-follow and no visible highlight movement. Tests (e2e/pane-controller.spec.ts): a highlight-cleared-and-restored test (drill clears the row highlight, j/k can't re-introduce it, browser Back restores it) and the R3 late-timer test (a j/k follow scheduled at depth 0 does not clobber a drill to depth>0). Full pane-controller suite (9) + pane-a11y-focus (6) + collections vitest (157) green. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra |
||
|
|
a5620eb0c4 |
feat(web): onOpenTarget(PaneTarget) callback contract + central resolution + same-item guard (#964)
* feat(web): onOpenTarget(PaneTarget) callback contract + central resolution + same-item guard (TASK-2158)
Adds the PaneTarget seam for PLAN-2154 Architecture B: ItemDetail gains an
onOpenTarget prop that future content-link surfaces (relationships,
breadcrumb, editor wiki-links, graph — TASK-2159/2160) will fire with a
minimal ref/slug/href/collectionSlug description instead of a full Item.
resolvePaneTarget (web/src/lib/collections/paneTarget.ts) centrally turns
any such target into the canonical `?item=` value navigatePaneTo expects,
and its same-item guard normalizes a slug-vs-ref alias of the currently
loaded item to its own canonical ref so the pane controller's existing
same-ref no-op catches it. The collection host wires
onOpenTarget={(target) => navigatePaneTo(resolvePaneTarget(target))} on the
pane's ItemDetail; no content-link surface calls it yet, so this is
behavior-neutral.
* fix(web): make the pane-target same-item guard actually operable (TASK-2158)
Codex round 1 found two related gaps: the host's onOpenTarget wiring never
passed a `current` item into resolvePaneTarget, so the guard could never
fire; and even when it did, normalizing a same-item alias to the item's own
canonical ref (rather than an explicit no-op) could itself mismatch the
pane's actual open `?item=` value when that's slug-shaped (e.g. a shared
deep link), causing planPaneDrill to wrongly treat it as a new target.
Fixes: resolvePaneTarget's same-item branch now returns null (a clean
no-op signal) instead of recomputing a ref that might not match `?item=`.
ItemDetail gains an internal fireOpenTarget(target) wrapper — using
isSamePaneTarget against its own loaded `item` — that TASK-2159/2160's
content-link interceptors will call instead of invoking the onOpenTarget
prop directly, so the guard runs where the loaded item is actually
available rather than depending on the host reconstructing it.
* fix(web): mirror server ref-alias semantics in the pane-target same-item guard (TASK-2158)
Codex round 2: the guard's ref comparison was case-sensitive and only
matched an exact prefix, missing the server's GetItemByRef fallback for a
stale ref after a collection move (item numbers are workspace-unique, so
the server resolves "PLAN-42" to an item now filed as TASK-42 via its
number alone). A pre-move or lowercase self-link would push a redundant
pane history entry instead of no-oping.
isSamePaneTarget now parses a ref-shaped candidate's item NUMBER
case-insensitively and compares it to current.item_number directly,
ignoring a stale/mismatched prefix — mirroring internal/store/items.go's
parseItemRef/GetItemByRef fallback.
* fix(web): refuse cross-workspace resolver hrefs in pane-target resolution (TASK-2158)
Codex round 3 (P1): resolvePaneTarget's href handling took the trailing
path segment at face value, so a cross-workspace wiki-link's resolver URL
(/-/r/{workspace}/{ref}, emitted by wikiLinksToMarkdown/renderMarkdown for
[[otherWorkspace::REF]] links) would resolve to a bare ref and could drill
the CURRENT workspace's pane to a same-numbered LOCAL item — the wrong
item — or false-positive the same-item guard when numbers coincide.
lastHrefSegment now recognizes the /-/r/ sentinel (which can't appear in a
same-workspace item URL — those are always /{username}/{workspace}/...)
and refuses to resolve it, so a cross-workspace link becomes a safe no-op
through the pane rather than opening the wrong item; TASK-2159/2160's
actual interceptors should let that link shape navigate normally instead
of routing it through onOpenTarget at all.
* fix(web): parse hrefs via URL (not raw prefix match) and distrust the whole target on a cross-workspace signal (TASK-2158)
Codex round 4 (P1): the previous /-/r/ prefix guard used a raw
string.startsWith on the href, which only catches root-relative hrefs.
HTMLAnchorElement.href always returns the fully-resolved ABSOLUTE URL, so a
future click-interceptor reading it off a live DOM anchor (a likely
TASK-2159/2160 pattern) would bypass the check entirely. Also: a target
carrying both a same-workspace-shaped `ref`/`slug` AND a cross-workspace
`href` only had the href field itself refused, letting the (contradictory,
likely mis-derived) ref/slug still resolve to a local item.
isCrossWorkspaceHref now parses the href via the URL API against a
throwaway base and checks .pathname, catching root-relative and absolute
forms uniformly. rawPaneTargetCandidate checks it FIRST and returns null
for the whole target when it fires, before ever consulting ref/slug —
a cross-workspace href is unambiguous evidence of non-locality, and by
this type's own contract a genuine same-workspace target never carries one.
* fix(web): mirror the server's letters-only ref prefix grammar in the same-item guard (TASK-2158)
Codex's PR-diff review pass: the guard's ref-shape regex allowed digits in
the prefix (`[A-Za-z][A-Za-z0-9]*`), but internal/store/items.go's
parseItemRef requires an ALL-LETTERS prefix (its validation loop rejects
any non-A-Z byte). A digit-bearing slug like "roadmap2-5" was therefore
misread as ref number 5, false-positiving the same-item guard against an
unrelated item with item_number 5 (e.g. TASK-5) and silently swallowing a
legitimate navigation. REF_SHAPE now requires [A-Za-z]+ for the prefix,
matching the server exactly.
* fix(web): make the same-item guard field-provenance-aware (TASK-2158)
Codex's PR-diff review pass: isSamePaneTarget collapsed target.ref/slug/href
into one candidate string before comparing, so a ref-sourced candidate that
happened to equal current.slug's literal string (e.g. current is slugged
"plan-6" but numbered 5, and a target explicitly carries { ref: "plan-6" }
naming some OTHER item numbered 6) matched via the slug check and wrongly
suppressed a legitimate navigation to that different item.
isSamePaneTarget now checks each field with its own semantics: a
ref-sourced candidate is judged only as a ref (id or item-number match,
never a raw slug compare); a slug-sourced candidate only as a slug; an
href-sourced candidate (no declared identity) still tries all three,
mirroring the server's own ambiguous-string resolution order for a bare
?item= value.
* fix(web): apply ref-before-slug precedence to href-derived candidates too (TASK-2158)
Codex's PR-diff review, round 2: the previous fix made target.ref
provenance-aware (ref-shape checked before slug), but the href branch still
checked candidate === current.slug before trying ref-shape/number, leaving
the identical collision reachable via the href channel — an href whose
trailing segment is ref-shaped and happens to equal current.slug's literal
string (e.g. current slugged "plan-6", numbered 5; href ".../plan-6" meant
for a DIFFERENT item numbered 6) would wrongly match as the same item.
The href branch now checks ref-shape first (matching only by item number
when ref-shaped, never falling through to slug) and only tries the slug
compare when the candidate isn't ref-shaped at all — mirroring the same
ref-before-slug precedence already applied to the `ref` field, and the
server's own resolution order.
Not addressed: Codex also flagged that resolvePaneTarget({slug: "plan-6"})
emits a bare string that the DOWNSTREAM ?item= resolution (shared by the
whole app, not introduced here) could itself interpret as a ref if some
other item happens to have that exact ref. That ambiguity is inherent to
the existing slug-or-ref addressing scheme every ?item= consumer already
has (openItemPane, direct URL navigation, etc.) — fixing it would mean
redesigning the app's item-addressing format, well outside this seam's
Phase 1 scope.
|
||
|
|
5095748a53 |
fix(web): per-instance localDirty shadow + scalar-consumer audit (TASK-2156) (#963)
* fix(web): shadow editorStore.dirty per-instance to stop cross-pane redirects editorStore is a module singleton — with a second concurrently-mounted ItemDetail coming in PLAN-2154's full-page host + docked pane, one instance's content edits flipped the SHARED dirty/lastSaveTime flags, which the OTHER instance's SSE archive/delete guards and ChildItems' self-save suppression also read. A frozen master could self-redirect off a pane edit, or a pane's save could suppress the master's child reload. Add a per-instance localDirty/localLastSaveTime shadow in ItemDetail, mirrored on every editorStore write, and reroute the destructive SSE guards (item_archived, sync-delete) and ChildItems' reload-suppression (via new selfDirty/selfLastSaveTime props) to the shadow instead of the global singleton. Single-instance behavior is unchanged. Also fixes vitest's jsdom project failing in agent worktrees, where web/node_modules is a symlink to the main checkout: Vite's fs-access guard denies files whose realpath resolves outside the project root, which broke every jsdom test (including pre-existing ones) with a "does the file exist?" error. Explicitly allowing the resolved node_modules realpath fixes it without changing behavior for a normal checkout. Closes TASK-2156. * fix(web): keep project root in vitest jsdom fs.allow list Codex review: setting server.fs.allow REPLACES Vite's default (project root + ancestors), so allowing only the node_modules realpath denied access to ordinary project source files. Include projectRoot alongside nodeModulesRealPath. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra |
||
|
|
eaeeb09ab1 |
feat(web): pane-navigation controller — depth/ownership state machine (TASK-2157) (#962)
* feat(web): pane-navigation controller — depth/ownership state machine (TASK-2157) Turn the collection page's split pane (PLAN-2105) into a navigable mini-browser per PLAN-2154 Architecture A. Depth + session ownership are stamped in SvelteKit page.state (never raw history.state), so they follow opaque Back/Forward, survive history.go, and reconstruct on cold-load. - New pure controller ($lib/collections/paneController.ts): planPaneDrill (same-ref guard + soft depth cap + ownership INHERITANCE), planLateralOpen (first-open mints ownership / depth-0 re-target / depth>0 stack reset), planPaneClose (three-way staged unwind). Fully unit-tested (26 cases). - navigatePaneTo(target) drill added beside openItemPane; ownership created only by first-open, inherited by drills (cold-load base = unowned). - Three-way ownership-aware staged close: OWNED -> go(-(depth+1)); UNOWNED & depth>0 -> go(-depth) then afterNavigate-latched replaceState-delete; UNOWNED & depth 0 -> replaceState-delete. - R14 fence-on-continuation baked in: controllerActionSeq + a one-shot afterNavigate latch (seq-fenced, state-rechecked), schedulePaneFollow made inert at depth>0 (schedule + fired callback) and cancelled on drill/close, an in-flight guard so a rapid gesture can't stack a second history.go. - depth+ownership preserved through every ?item=-preserving nav: updateUrlFilters, the ?graph toggle (ItemDetail), and the collection rename onNavigateAway (now replaceState, not push). - navigatePaneTo exported onto the pane ItemDetail seam for TASK-2158 and reachable now via a localStorage-gated __padPaneController test hook. Tests: 26 unit + 5 Playwright e2e (open/close/j-k, drill/back/same-ref, detach j/k inertness, cold-load close, detached-row reset). Existing pane e2e suite (13) still green. Closes TASK-2157 Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): rebase pane ownership on collection rename + tie latch to its popstate Codex review of the pane controller (TASK-2157): - P1 (rename ownership): a collection rename replaceState's /old?item=X -> /new?item=X, but every predecessor history entry still points at the now- dead OLD slug — carrying paneOwned=true forward made an owned close history.go back onto a 404. Ownership means "a live pre-pane entry exists to unwind to", which is false after a rename, so onNavigateAway now stamps a fresh {paneDepth:0, paneOwned:false} base on the new slug: close drops ?item= in place, staying on the valid new route. New e2e covers it. - P2 (latch): gate the afterNavigate latch on nav.type==='popstate' so only its own history.go can consume it; a competing goto/link/form leaves it armed until the go settles. - P1 (owned close discards mid-pane filter changes): documented as the plan-mandated R8 behavior — an explicit close is now identical to the browser Back that already closed the pane in PLAN-2105 (no deviation). Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): harden pane latch consumption + bypass draft guard on rename Second Codex round (TASK-2157): - Latch is no longer dropped by a competing history traversal: run() now RETURNS whether it reached its destination (depth collapsed to the base); the afterNavigate handler consumes the latch only when run() fires, so an unrelated browser Back/Forward during the go's in-flight window leaves the latch armed for its own popstate instead of clearing it against the wrong entry. - Collection rename now bypasses the unsaved-draft beforeNavigate guard (navigatePaneAfterRename): the server-side rename already committed and the route component is reused across the same-route pathname change (drafts survive), so a "Stay" prompt could otherwise strand the user on the dead old slug with a stale owned stamp. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): bound pane latch with a fallback timer + stronger reset correlation Third Codex round (TASK-2157): - P1 lockup: the "leave the latch armed until run() reaches its destination" rule could leave paneNavInFlight() stuck forever if the arming history.go was superseded (its own popstate never lands). Add a bounded fallback timer (PANE_LATCH_FALLBACK_MS) that best-effort-fires then UNCONDITIONALLY clears the latch, so the in-flight guard can never stick. clearPaneLatch() also tears down the timer (onDestroy + on consume). - P2 reset correlation: the detached-open reset now requires the landing entry to carry ?item= (the pane base), not just depth 0 — rejecting a competing browser Back that landed on the pre-pane (no-?item=) entry. - P2 rename + browser Back: documented that Back to the old-slug predecessor is an inherent rename-in-history limitation (past entries can't be rewritten), out of the controller's reach; the imperative close is fixed. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): fence every controller history.go + split rename vs move nav-away Fourth Codex round (TASK-2157): - P1 (move vs rename): onNavigateAway is fired by ItemDetail for BOTH a collection rename (/user/ws/NEWSLUG?item=X — pane preserved) AND a cross-collection item move (/user/ws/coll/slug — full-page route, no ?item=). handlePaneNavigateAway now branches on whether the target keeps the pane (?item=): rename gets the rebase-to-unowned + draft-guard bypass; a move keeps the ORIGINAL guarded push so its unsaved-draft prompt still fires (the collection page unmounts and would lose drafts). - P2 (duplicate close): the production owned-go close is a one-phase history.go(-1) that wasn't fenced, so a double-click ✕ / ESC+click could stack a second traversal and overshoot the pre-pane entry. Unify all controller traversals (owned close, cold-base close, reset) through paneHistoryGo(), which marks navigation in-flight (paneNavInFlight blocks a duplicate gesture) until the traversal's own popstate settles or a bounded fallback. New e2e asserts a double close lands exactly on the pre-pane URL. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra |
||
|
|
53252ee5d0 |
fix(web): close BUG-2129 stale-fields gap in the collection-edit switch fence (#961)
* fix(web): close BUG-2129 stale-fields gap in the collection-edit switch fence
The EditCollectionModal onupdated handler's switch fence used
`{@const keyedSlug = itemSlug}` to detect a superseded save, but Svelte 5
compiles that as a lazily-pulled derived signal — since keyedSlug was only
read inside the async callback, it always evaluated the CURRENT itemSlug,
not the value at modal-open time, making the fence a no-op. Replace it with
a genuine gen+id snapshot (pendingCollectionEditGen/-ItemId) captured
synchronously in the onmanage click handler, and use it to (a) skip
navigation/archive/close for a genuinely superseded save and (b) still
refresh the currently-shown item's fields when it's in the collection that
just migrated, closing the stale-fields clobber gap BUG-2129 describes.
Adds two Playwright regression tests: a same-collection pane-switch repro
(BUG-2129's literal scenario) and a cross-collection full-page navigation
test that mutation-testing confirms discriminates the fix (fails on the
pre-fix code with an observable wrong-page hijack).
Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
* fix(web): thread editedCollectionId through EditCollectionModal to fix overlap + rename/archive gaps
Codex review of the initial fix found two real gaps: (1) the
pendingCollectionEditGen/-ItemId snapshot was a single shared mutable slot,
so opening a second collection-edit (for a different item) while an earlier
save was still in flight would overwrite it, letting the earlier save's
completion misapply the wrong item's context; (2) the superseded-but-same-
collection branch only handled the reload case, not a pending rename
(still uses the stale collSlug -> 404) or archive (silently did nothing).
Replace the parent-side snapshot with a value EditCollectionModal itself
captures synchronously (before its API call) and echoes back through
onupdated(updated, editedCollectionId). ItemDetail's fence collapses to one
check — does the currently-shown item still belong to editedCollectionId —
applied uniformly to the reload, rename-redirect, and archive-redirect
paths, so all three now behave correctly whether or not the pane switched
items mid-save.
Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
* fix(web): gate the collection-edit fence on route/load settlement + fix a PATCH-timing test race
Codex round 2 found a narrower race: between a route change (collSlug/
itemSlug updating) and loadData()'s async resolution, item/collection can
transiently still hold the PREVIOUS item's data while collSlug/itemSlug
already reflect the new one. A mixed read across that window could pass
the collection-id fence using the stale `item` but build a navigation URL
from the already-updated collSlug/itemSlug, hijacking to a mismatched URL.
Gate the whole onupdated body on the existing `itemMatchesRef` invariant
(already used elsewhere in this file for the same "has loadData() caught
up" check) — bailing there is always safe since the route's own in-flight
loadData() will fetch fresh state regardless.
Also fixes a regression-test-only flake Codex flagged: the field-update
PATCH readback used waitForRequest (resolves on dispatch) instead of
waitForResponse (resolves on commit), so the immediate belt-and-suspenders
GET could race an in-flight write.
Deferred (documented in the PR, not fixed here): a further compound race
where a still-open second collection-edit modal on a different item could
be closed by an earlier, unrelated same-collection save's completion —
narrow, pre-existing-adjacent, and squarely in PLAN-2154 Phase 1's
dedicated R14 fence-sweep scope (TASK-2167) rather than this Phase 0 fix.
Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
* fix(web): allow a safe corrective reload during route transitions; sync test on PATCH response
Codex round 3: gating the ENTIRE onupdated body on itemMatchesRef (added
last round) was too broad — it also suppressed the corrective void
loadData() call during a route-transition window, reopening BUG-2129's
core symptom (an in-flight load that raced the migration and lost gets no
second chance). loadData() is idempotent and gen-fenced against any
in-flight load, so calling it again is always safe, even mid-transition.
Split the behavior: the relevance + reload decision runs unconditionally
(using whatever `item` is currently known, stale or not — false positives
just cause a harmless redundant reload), while only the RISKY navigation
(rename-redirect / archive-redirect, which builds a URL from collSlug/
itemSlug) stays gated on itemMatchesRef, since those are what a mixed
stale-item/fresh-route read could misdirect (Codex round 2's finding).
collectionStore.loadCollections(wsSlug) now runs unconditionally too,
keeping the global sidebar in sync regardless of pane relevance.
Also tightens the cross-collection test: replaces a fixed 1.5s sleep
after releasing the migration with an explicit wait for the PATCH
response, so the assertion can't false-pass by running before the
(possibly buggy) navigation had a chance to fire.
Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
* fix(web): fence the collection-edit callback on collSlug instead of item state
Codex round 4: gating rename/archive redirects on itemMatchesRef (added
last round) fixed the cross-collection hijack but broke the SAME-collection
case — during a same-pane item switch, collSlug never changes, so a
pending rename/archive that resolves mid-transition would skip the needed
redirect (itemMatchesRef false) and then loadData() 404s fetching the
now-stale/gone collSlug, with nothing to correct it afterward.
Replace the item-state-based fence entirely with a comparison against
`collSlug` — a plain reactive prop derived straight from the route params,
with no async lag (item/collection require a loadData() round-trip to
catch up; collSlug updates synchronously with navigation). EditCollectionModal
now also echoes back `editedCollectionSlug` (captured the same way as
editedCollectionId). `collSlug === editedCollectionSlug` is correct in
every case with no separate itemMatchesRef gate needed: unchanged for a
same-pane switch (so rename/archive/reload all still fire correctly),
and updated immediately for a cross-collection navigation (so a stale
edit's redirect correctly no-ops). This resolves the tension between
rounds 2-4 of Codex's findings with one simpler, more robust check.
Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
* fix(web): guard the collection-edit callback against a destroyed instance + workspace reuse
PR-level Codex review found two more real gaps in the fence:
1. The onupdated closure can still fire after this ItemDetail instance is
torn down entirely (pane closed, or the whole page navigated away)
while a save was pending — JS doesn't cancel a lingering promise on
unmount. Unguarded, that stale closure's goto()/collectionStore writes
would visibly affect whatever the user has since navigated to. Added a
one-way `destroyed` flag (set in onDestroy, alongside the existing
loadGeneration bump) checked first in the callback.
2. Collection slugs are workspace-scoped, so if this component instance
is ever reused across a workspace switch (no remount, same as the
existing collSlug/itemSlug reuse this fence already relies on), a
collSlug match alone can't tell two different workspaces' same-named
collections apart. EditCollectionModal now also echoes back
`editedWsSlug`, compared against the live `wsSlug` prop.
Also documents (not fixed here) a narrow pre-existing-adjacent edge case
Codex flagged: an embedded pane driven by a hand-crafted `?item=` whose
item lives in a different collection than the host page's collSlug won't
match this fence when its real collection is edited. This doesn't regress
anything (the prior fence was dead code and refreshed unconditionally
regardless of relevance) and is deferred to PLAN-2154 Phase 1 (TASK-2167).
Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
|
||
|
|
4b96834d19 |
feat(web): click list-column dead space to collapse the item pane (IDEA-2148) (#960)
While the split detail pane is open on the desktop split, a primary click on genuinely dead space anywhere in the list column now collapses the pane — the pointer analogue of ESC-to-close. A click on any interactive/meaningful surface (a card, a button, a form control, an ARIA-roled widget, the resize divider) is left to that surface's own handler and never collapses: the handler bails via a denylist `closest()` check, and because those handlers run first on bubbling, re-targeting to another card or firing a button never races the close. Guards skip modifier/aux clicks, drag-to-select, and scrollbar-gutter clicks. Excluded on mobile (the list column is `inert` behind the full-screen overlay). Verified with Playwright across list, board, and table views: dead-space clicks close; card / other-card / header-button / table-row clicks keep the pane open. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra |
||
|
|
b6d6a1c537 |
fix(web): debounce the item link-search inputs to stop per-keystroke requests (#959)
The add-child "Link existing" search (ChildItems) and the add-relationship search (ItemDetail) fired an api.search request on every keystroke, tripping rate limits while typing. Add a 250ms debounce to both: - onSearchInput / onAddLinkInput schedule the search after a typing pause. The timer is a plain non-$state let (CONVE-1688), cleared on every teardown / close / reset / tab-switch / item-switch path so no pending fetch fires after the input goes away. - Emptying or editing the query invalidates any in-flight request (searchSeq / addLinkSearchSeq fence) and clears stale results immediately, so an old-query result can't appear or be clicked during the debounce window; the spinner can't get stuck. Codex-reviewed to CLEAN (3 rounds). Runtime-verified: 15 fast keystrokes now fire 0 requests while typing and 1 after the pause (was ~15 per burst). Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra |
||
|
|
456a50f342 |
feat(web): in-place "Add child" in the Children section (PLAN-2140) (#958)
Add a "+ Add child" affordance to an item's Children section with two modes, closing the gap where you had to open the child and set its parent from the other side: - Create new: collection picker + title -> new item parented to the current item (parent wired via fields.parent, never parent_id). - Link existing: search -> attach an existing item as a child, with an always-shown move-safe confirm (single-parent reparent). Frontend-only in ChildItems.svelte, reusing existing endpoints (POST items with fields.parent; POST links link_type=parent). Capability-correct gating (create=target-collection edit, link=owner/editor-or-edit-grant + canEditItem candidate filter), collection picker excludes system, exact-parent-keyed, and required-no-default schemas. Switch-safety fences (destroyed flag + loadSeq/searchSeq) and in-flight busy guards throughout. The section now renders a compact entry point even at zero children. Codex-reviewed to CLEAN. Implements IDEA-2132 via TASK-2141/2142/2143. Follow-up filed: BUG-2144 (collectionStore workspace freshness fence). Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra |