mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-11 13:28:57 +00:00
00a91dfcf463037af8ada3e8f3edec01ad40e664
701 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
00a91dfcf4 |
feat(push): session targeting — target_session_id + delivered_sessions (TASK-2588) (#1108)
* watchevents: add session-targeted push delivery predicate
PLAN-2558 S5 (TASK-2588). Notification gains TargetSessionID,
evaluated in the existing per-connection KindPush predicate in
watchNotificationVisible alongside TargetUserID — one delivery path,
targeted is broadcast-with-a-predicate, no bus changes. Empty
TargetSessionID (the pre-S5 shape) still matches every one of the
target user's sessions.
* server: accept target_session_id on push, report delivered_sessions
PLAN-2558 S5 (TASK-2588). POST .../items/{slug}/push accepts an
optional target_session_id (an id from GET /api/v1/sessions) and the
response gains delivered_sessions — a prediction read from the S1
presence registry at push time, scoped to the caller's own
ListForUser(userID) so a vanished id and one belonging to a different
user are both an honest 200/0 with no existence oracle across users.
Omitting the field keeps the exact pre-S5 request/response shape.
* web: session picker in the push composer, targeted-miss handling
PLAN-2558 S5 (TASK-2588). PushToAgentDialog gains a target picker
(broadcast default + one option per live session), reusing the
presence read already fetched for the count — no second GET
/api/v1/sessions. Selecting a session passes target_session_id;
leaving it untouched keeps the exact pre-S5 3-argument push() call.
A targeted miss (delivered_sessions === 0) toasts "that session is
gone — refresh the list", drops the selection back to broadcast, and
re-polls presence instead of closing — zero delivery means nothing
was sent, so nothing is duplicated by resending.
* server: bound target_session_id, skip publish on a targeted miss
Codex round 1 fixes for TASK-2588:
- Cap target_session_id at 256 runes (400 over-cap) so an authenticated
caller can't park arbitrary garbage in the bus's shared replay buffer;
a registry-issued id (36 runes) can never hit this bound.
- Snapshot presence BEFORE publish instead of counting after: the old
order raced a target disconnecting between publish and count, which
could report delivered_sessions=0 on a push that had already landed
once. A targeted push now skips the publish entirely when its id
isn't in the pre-publish snapshot — session ids are per-connection
and never reused, so a target absent now can never be matched later,
making the 0 a guarantee rather than a race. Broadcast is unaffected
(still publish-always, pre-publish count).
Strengthened the targeted-miss and cross-user tests to assert the bus
does not grow (not just that the notification fails to arrive
downstream) — verified this fails if the skip-on-miss guard is
reverted.
* push targeting: document the pushed ruling, fix stale picker selection, guard mixed-version responses
Codex round 2 dispositions for TASK-2588:
- pushed:true on a skipped publish is RULED, not a bug (dispatcher):
moved the ruling from a test comment onto the contract itself —
pushResponse.Pushed's own doc comment in Go, mirrored in the TS
ItemPushResult doc comment.
- Fixed a real sharp edge: when a presence refresh drops the selected
session, a <select> can visually fall back to "All connected
sessions" while the bound value stays the stale id, so the wire
would carry a dead target the UI no longer shows as selected.
Added reconcileSelectedSession(), called at every point `sessions`
is reassigned outside the fresh-open reset (a live poll, a failed
read, and the staleness-expiry path).
- Guarded the mixed-version hazard with a cheap check, not capability
negotiation (the deployment shape — web assets embedded in the
server binary — bounds this to a transient stale tab, argument
recorded in the comment): delivered_sessions is now optional on the
wire type, and a targeted send whose response omits it entirely is
treated as UNKNOWN (info toast, dismiss like a normal success) —
never inferred as a confirmed miss.
Verified all three new/changed legs actually catch their regression
by temporarily reverting each fix and confirming the corresponding
test fails, then restoring.
* push targeting: fix stale publish-guarantee comments (codex round 3)
Two doc-comment remnants of round 2's skip-on-miss fix, both claiming
push unconditionally publishes:
- watchevents.KindPush's doc comment ("publishes exactly one of
these") now notes handlePushToItem decides whether to publish at
all, and points at TargetSessionID / pushResponse.DeliveredSessions
for why.
- api.items.push()'s JSDoc in client.ts no longer claims a resolved
promise means "published to the bus" unconditionally — a targeted
miss resolves with delivered_sessions: 0 and nothing published.
Comment-only; no behavior change.
|
||
|
|
312f28dd14 |
chore(deps)(deps-dev): bump vitest from 3.2.6 to 4.1.10 in /web (#1045)
Bumps [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) from 3.2.6 to 4.1.10. - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.10/packages/vitest) --- updated-dependencies: - dependency-name: vitest dependency-version: 4.1.10 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> |
||
|
|
b887b0bfe1 |
test(web): capture pristine DOM probes at module load in mockOpenModals helpers (#1105)
vitest 4 hands back the SAME spy when vi.spyOn targets an already-spied method, so a helper that re-captures "the real function" mid-test captures the spy itself and the pass-through branch recurses (swallowed by the :modal probe's guard, which then reads as ':modal unsupported'). Capturing document.querySelectorAll / Element.prototype.matches once at module load is correct under both vitest 3 and 4; suite measured 1609/1609 on each. Unblocks the vitest 3->4 major (dependabot #1045), whose merged-tree run failed 3 Lightbox drag-abort tests (TASK-2458) through this pattern. Claude-Session: https://claude.ai/code/session_01BhQoeaWXxJbvw86ezzK8dt |
||
|
|
a7b70c2092 |
chore(deps)(deps-dev): bump @testing-library/jest-dom in /web (#1044)
Bumps [@testing-library/jest-dom](https://github.com/testing-library/jest-dom) from 6.9.1 to 7.0.1. - [Release notes](https://github.com/testing-library/jest-dom/releases) - [Changelog](https://github.com/testing-library/jest-dom/blob/main/CHANGELOG.md) - [Commits](https://github.com/testing-library/jest-dom/compare/v6.9.1...v7.0.1) --- updated-dependencies: - dependency-name: "@testing-library/jest-dom" dependency-version: 7.0.0 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> |
||
|
|
8ba08c7164 |
chore(deps)(deps): bump the npm-minor-and-patch group (#1072)
Bumps the npm-minor-and-patch group in /web with 7 updates: | Package | From | To | | --- | --- | --- | | [@dagrejs/dagre](https://github.com/dagrejs/dagre) | `3.0.0` | `3.1.0` | | [mermaid](https://github.com/mermaid-js/mermaid) | `11.16.0` | `11.16.1` | | [svelte-dnd-action](https://github.com/isaacHagoel/svelte-dnd-action) | `0.9.77` | `0.9.78` | | [yjs](https://github.com/yjs/yjs) | `13.6.31` | `13.6.32` | | [marked](https://github.com/markedjs/marked) | `18.0.7` | `18.0.9` | | [svelte-check](https://github.com/sveltejs/language-tools) | `4.7.4` | `4.7.5` | | [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.2.0` | `8.2.1` | Updates `@dagrejs/dagre` from 3.0.0 to 3.1.0 - [Release notes](https://github.com/dagrejs/dagre/releases) - [Changelog](https://github.com/dagrejs/dagre/blob/master/changelog.md) - [Commits](https://github.com/dagrejs/dagre/compare/v3.0.0...v3.1.0) Updates `mermaid` from 11.16.0 to 11.16.1 - [Release notes](https://github.com/mermaid-js/mermaid/releases) - [Commits](https://github.com/mermaid-js/mermaid/compare/mermaid@11.16.0...mermaid@11.16.1) Updates `svelte-dnd-action` from 0.9.77 to 0.9.78 - [Changelog](https://github.com/isaacHagoel/svelte-dnd-action/blob/master/release-notes.md) - [Commits](https://github.com/isaacHagoel/svelte-dnd-action/commits) Updates `yjs` from 13.6.31 to 13.6.32 - [Release notes](https://github.com/yjs/yjs/releases) - [Commits](https://github.com/yjs/yjs/compare/v13.6.31...v13.6.32) Updates `marked` from 18.0.7 to 18.0.9 - [Release notes](https://github.com/markedjs/marked/releases) - [Commits](https://github.com/markedjs/marked/compare/v18.0.7...v18.0.9) Updates `svelte-check` from 4.7.4 to 4.7.5 - [Release notes](https://github.com/sveltejs/language-tools/releases) - [Commits](https://github.com/sveltejs/language-tools/compare/svelte-check@4.7.4...svelte-check@4.7.5) Updates `vite` from 8.2.0 to 8.2.1 - [Release notes](https://github.com/vitejs/vite/releases) - [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite/commits/v8.2.1/packages/vite) --- updated-dependencies: - dependency-name: "@dagrejs/dagre" dependency-version: 3.1.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: npm-minor-and-patch - dependency-name: mermaid dependency-version: 11.16.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: npm-minor-and-patch - dependency-name: svelte-dnd-action dependency-version: 0.9.78 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: npm-minor-and-patch - dependency-name: yjs dependency-version: 13.6.32 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: npm-minor-and-patch - dependency-name: marked dependency-version: 18.0.9 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: npm-minor-and-patch - dependency-name: svelte-check dependency-version: 4.7.5 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: npm-minor-and-patch - dependency-name: vite dependency-version: 8.2.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: npm-minor-and-patch ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> |
||
|
|
bfa90826e9 |
feat(web): quick actions push to a connected agent session (TASK-2562) (#1103)
* feat(web): quick actions push to a connected agent session (TASK-2562)
PLAN-2558 S4. `resolvePrompt()` already did the templating push needed
(`{ref} {title} {status} {priority} {collection} {content} {fields} {plan}
{phase}`); only the last hop was a clipboard ferry. That hop is now a push,
and the clipboard becomes the fallback rather than the mechanism.
Zero-touch migration, as the task required: quick actions are still
`{label, prompt, scope, icon}` in collection settings. No settings rewrite,
no schema change, no per-action opt-in.
Routing, per the plan's ruling — with zero live sessions, fall back to the
clipboard with an honest toast, never a hard error and never a queue:
session(s) live push the collapsed prompt; toast hedges ("delivery isn't
confirmed") because a push gets no ack
zero sessions copy, "No agent session connected — copied to clipboard
instead"
can't tell copy. This is where S4 DIVERGES from S3's dialog, which
leaves Send enabled on an unreadable presence answer. The
dialog is right to: the warning is on screen and the user
chooses with it in front of them. A quick action asks
nobody, so the tie goes to the lossless branch — copying
when we could have pushed costs one paste, pushing into
nothing loses the instruction outright.
no item to collection-scope actions keep the pre-S4 behavior exactly.
address The endpoint is POST .../items/{slug}/push and there is
nothing to point it at, so they don't even spend a
presence read finding that out.
PRESENCE IS READ WHEN THE MENU OPENS, NOT ON THE CLICK, and that is the one
non-obvious thing in this diff. Both clipboard APIs want the user gesture
that is live during the click handler and gone after a network round-trip
(Safari strictest, Firefox too). Deciding from a read issued on the click
would put an await in front of the very fallback this slice promises. The
cost is a small window — click before the first read lands and presence is
null, which routes to the clipboard with an honest toast — and that is the
right way round.
The menu's footer line now says which way the next click will go, so the
routing is visible before it happens rather than only in the toast after.
A logged-out or workspace-token viewer gets a 401 from /sessions, which is
"can't tell", which is the clipboard — today's behavior, no gating needed.
Push failure splits on the same line CopyItemDialog and the S3 composer draw
(DR-13): a recognised pre-publish refusal means nothing went out, so the copy
is OFFERED as a toast action (a fresh gesture, which is what makes a
clipboard write work this long after the original). An unrecognised failure
leaves the outcome unknown — the handler publishes BEFORE it writes its
response — so nothing is offered, because a paste would be the duplicate the
message is warning about on an endpoint with no idempotency key.
PRE_PUBLISH_ERROR_CODES moved out of PushToAgentDialog into
$lib/push/dispatch so the two surfaces can't drift on it.
Also: the local `copyToClipboard` is replaced by `$lib/utils/clipboard`'s.
The local one returned true from the promise path WITHOUT awaiting it, so a
rejected write reported success and never reached the execCommand fallback —
harmless when copying was a convenience, not harmless now that "we copied
instead" is a load-bearing claim.
Verified live against a throwaway instance (built binary, real browser),
three legs, with the SSE stream as the receipt:
no session tagline "No agent session connected — actions copy to your
clipboard"; toast matches the ruling; clipboard holds
"Implement TASK-9: Ship the thing (status open)"
one session tagline "Pushes to your connected agent session"; the
connected stream RECEIVED {"kind":"push","item_ref":
"TASK-9","summary":"Implement TASK-9: ..."}; clipboard
untouched
presence 503 same live session still connected, /sessions aborted: copies
instead, and the stream's push count did NOT increase — the
counterfactual, not just the end state
Each of the five behaviours is mutation-tested 1:1 against its test: an
await before the copy fails ONLY the synchronous-gesture test; routing
'unknown' to push fails only the two uncertainty tests; dropping the collapse
fails only the raw-vs-collapsed test; offering a copy on an unconfirmed push
fails only that test; copying on the happy path fails three.
Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd
* fix(web): expire a stale presence answer in the quick-actions menu (codex round 1)
Codex's one finding, and it is real. A FAILED poll already degrades to
'unknown'; a poll that HANGS does not — it simply never writes, so the last
count stayed in place indefinitely while the menu went on offering a push
into a session that may have dropped minutes earlier. That is the one
direction that loses the user's instruction, which is the whole thing this
slice exists to prevent.
A 'known' answer now expires after 30s without a refresh — the server's own
worst-case presence staleness (watchEventsKeepaliveInterval), the same bound
and the same reasoning as PushToAgentDialog's round-2 fix. Requests already
in flight at the moment of expiry are retired (presenceAppliedSeq advances to
presenceSeq) so one issued BEFORE the expiry cannot land after it and restore
the very count we just declared too old to trust.
The expiry is checked in TWO places, and the second is the one worth noting:
the poll tick rewrites the state (so the footer line stops claiming a
connection), but the ROUTING decision reads through `currentPresence()` at
click time. A tick-only expiry leaves a window of up to one whole poll
interval in which the menu still pushes against a count it has already
outlived — and a click is exactly what lands in that window.
Both halves mutation-tested: disabling the expiry fails the two staleness
tests and leaves the control leg (polls still landing → no downgrade) green;
reading raw `presence` at click time instead of `currentPresence()` fails
ONLY the between-ticks test.
npm run check 0 errors · web unit suite 1607 passed.
Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd
* fix(web): the offered copy reports its own outcome (codex round 2)
Round 2's one finding, and it is real. `Copy instead` on a push-failure toast
discarded the `copyToClipboard()` result, and taking the offer dismisses the
toast that carried it — so a failed copy said nothing at all. That silence is
worst on exactly this path: a pre-publish refusal means the instruction was
never sent, so a silently-failed copy leaves it neither sent NOR copied, with
the user believing they rescued it.
The offer now routes through the same `copyAndAnnounce` every other clipboard
path uses, under a new `'offered'` ClipboardReason that renders the plain
"Copied to clipboard" — the user asked for the copy, so there is no absent
push to explain — and the ordinary error on failure.
Mutation-tested: reverting to the discarded-result form fails the new test
and the existing pre-publish test, and nothing else.
npm run check 0 errors · web unit suite 1608 passed.
Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd
* fix(web): flip the quick-actions footer line when the answer comes due (codex round 3)
Round 3's finding, and it is a self-inconsistency round 1 introduced. The
click-time expiry check made the ROUTING correct immediately, but the footer
line still read raw `presence` until the next 10s poll tick — so for up to a
full interval the menu said "Pushes to your connected agent session" while the
next click would copy. That line's entire job is to say what the next click
will do.
A successful read now arms a one-shot timeout at the exact expiry, so the
display flips when the answer comes due rather than when a poll happens to
notice.
`currentPresence()` STAYS, and the redundancy is the point: a timer is a
request, not a guarantee. Browsers throttle timers hard in a backgrounded tab,
so the expiry can fire long after it came due — including after the user has
returned and clicked. The timer keeps the DISPLAY honest; the click-time check
keeps the DECISION correct, and only the decision can lose a message. The poll
tick keeps its expiry check for the same reason.
Both halves mutation-tested, and they fail different tests: dropping
`armExpiry()` fails ONLY the comes-due test; dropping `currentPresence()` at
the click fails ONLY the throttled-timer test. That second test models
throttling by moving the CLOCK without running any timer — which is exactly
what a throttled tab looks like from the component's side, and is not
reachable with advanceTimersByTime.
npm run check 0 errors · web unit suite 1609 passed.
Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd
|
||
|
|
2c5803a204 |
fix(web): arm the connect-time sync on FIRST connect, not only on leader promotion (BUG-2540) (#1101)
* fix(web): arm the connect-time sync on FIRST connect, not only on leader promotion (BUG-2540) A page reads its items and only then subscribes to SSE. A mutation landing in that window reaches nobody: no subscription exists yet so the frame is never received, and nothing reconciles the gap afterwards — the row stays stale until some unrelated event happens to trigger a sync. `pendingSyncOnConnect` is exactly the right mechanism and already existed, but was armed only on leader promotion and the lock-failure fallback. The FIRST connect — the one every page load performs — was uncovered. Arms it on every EventSource open instead. Cursor-advance semantics are untouched: the delta is asked from whatever cursor syncService already holds. IDEA-2535 owns that question. Also removes the "first leader vs promoted follower" classification (a `navigator.locks.query` probe plus a >100ms grant-delay heuristic from TASK-1359 rounds 2-3). It existed solely to gate this flag; with the flag armed unconditionally nothing reads it, and keeping it would mean a `locks.query()` round-trip per connect producing a value no one consumes. Strictly more coverage, not a trade — every case it classified as "promoted" still arms. VERIFICATION — and three instruments that did NOT discriminate before one did: - Unit tests (5) pin the mechanism: armed on the lock path, on the no-leader-election fallback, dispatched AFTER open rather than before (the TASK-1359 round-4 ordering property this must not lose), claimed once across the onopen/`connected` arms, and re-armed for the next connect. Reverting the fix reddens all 5. - Collection page + "is the row visible": CANNOT discriminate. That page runs its own deltaSync on mount, which covers the same window either way. Fixed and unfixed both "passed". - Graph page + "is the node's title in the page text": BLIND. A positive control — item created with no race at all — is also "not present", so every reading was measuring nothing. - Graph page + /graph response bodies, natural timing: still cannot discriminate. The write consistently lands before the page's own first read, so nothing is ever missed and both builds "recover". - What finally worked: builds with the EventSource open delayed 3s so the window is wide enough to aim at, write timed into it. 3/3 LOST on unfixed, 3/3 RECOVERED on fixed, both binaries confirmed serving and differing only in the arming sites. One hypothesis was refuted along the way rather than written down as fact: I suspected the graph subscribed too late to receive the connect dispatch. An instrumented run showed `dispatchSyncRequired subscribers=2` — both syncService and the graph are registered before it fires. The real reason those runs failed was that a stale server process was still bound to the port, so they were served by an unrelated binary. Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd * fix(web): fence the connect-sync against a stale EventSource (codex review) `pendingSyncOnConnect` is shared state and the open/connected handlers closed over no identity, so on a fast workspace switch source A's already-queued handler could still run after B existed — clearing the flag and broadcasting on B's channel, at which point B's own open would find it false and SKIP the sync it needed. `close()` does not retract queued event tasks, so this is reachable. It would silently reopen the exact gap this branch closes, on the switch path where a fresh read is most likely to be stale. Each handler now returns unless its own source is the current one. Those guards also stop a torn-down source writing `status` / broadcasting, which they never did before. Listener registration uses the local `source` throughout rather than re-reading module state (behaviour-identical; the two are the same object at registration). `disconnect()` also clears the flag. Labelled in-code as belt-and-braces rather than implied load-bearing: mutation-testing shows removing that line ALONE changes nothing observable, while removing the identity guards reddens the stale-source test. Kept because leaving per-connection state set after the connection is gone is how this bug arose. Three tests added, and the mutation testing is worth recording because the first attempt was a false green: dropping the identity check inside `claimPendingSync` reddened NOTHING, since the handler-level guards still caught it — a layered-guard mask. Only removing every guard isolates which layer acts. The tests now discriminate at that granularity. Also covers the lock-failure fallback arming path, which had no test at all. Codex's other finding — follower tabs have the same uncovered window and cannot use this mechanism, since they never open an EventSource — is real and filed as BUG-2576, along with the adjacent unguarded listeners this commit had no reason to touch. Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd |
||
|
|
e03ba45b5c |
feat(web): push-to-agent composer in the item view (TASK-2561) (#1099)
* feat(web): push-to-agent composer in the item view (TASK-2561)
PLAN-2558 S3 — the web half of IDEA-2544's push-to-harness. Adds
`api.items.push`, a new `api.sessions.list`, and a "Push to agent…" row
in the item pane's ⋯ menu that opens a small composer.
The deliverable is the presence line, not the textarea. `pad push` is
fire-and-forget — no durable inbox, no ack, no "nobody was listening"
warning — which is defensible for a CLI verb typed by someone who knows
their own session is running, and indefensible for a button. So the
dialog answers "is anything listening?" before the click, and keeps
three states apart rather than two:
N > 0 send, worded "N session connected", never "will be
delivered" — the registry can name a session that died up
to ~30s ago and no push gets a receipt
N == 0 send DISABLED. Nothing listening means the message is
lost, not queued; the empty state offers the clipboard
instead (the fallback S4 rules for quick actions)
can't tell send ENABLED, uncertainty stated. A 503/401/network
failure is not zero — rendering it as zero is the exact
lie handleListSessions returns 503 rather than an empty
list to avoid
The menu row is gated on a resolved user, not on canEdit: push is
self-addressed, so a viewer pushing an item into their own session is a
read. Without a user the endpoint 401s.
$lib/push/message mirrors the server's rune-after-collapse accounting so
an over-length message is caught in the composer instead of coming back
as a 400. It deliberately does not use JS `\s`: Go's unicode.IsSpace and
`\s` disagree in both directions (U+0085 is whitespace to Go only,
U+FEFF to JS only), so a `\s` client under-counts a pasted BOM and
over-counts a pasted NEL. The agreement is pinned by a shared fixture
(internal/server/testdata/push_message_cases.json) read by BOTH
internal/server/push_message_collapse_test.go and the web unit test — a
TS-only table would assert a belief about Go rather than Go's behaviour.
Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd
* fix(web): close the push composer's races and ambiguity gaps (codex review)
Round-1 review findings on the S3 composer, all real:
- ItemDetail did not reset `pushDialogOpen` on an item switch. The dialog
is {#key itemSlug}-remounted while `open` is owned by the parent, so a
stale `true` silently REOPENED the composer pointed at the new item.
The reset block's existing comment (written for copyDialogOpen)
describes this exact failure. Verified live, with the counterfactual:
reverting the one-line fix reopens the dialog on item B after a
client-side navigation. (The typed draft does NOT carry over — the
{#key} remount clears it — so the defect is the silent reopen, not a
retargeted message.)
- Presence polls shared one generation counter, which fences OPENINGS,
not requests. A stalled poll could resolve after a later one and
overwrite a fresh count with a stale one, re-arming Push against a
session list already known to be empty. Added a per-request sequence;
only a strictly newer response is applied.
- Nothing bounded a `/sessions` read, and 'checking' disables Push, so a
request that never settled stranded the composer with a dead button and
no explanation. It now degrades to the honest "can't tell" state after
5s; a later response still lands and upgrades the answer.
- A failed send re-armed Push unconditionally. The handler publishes
BEFORE writing its response, so an unstructured failure (rejected
fetch, non-JSON 502) leaves the outcome genuinely unknown and a second
click can deliver the instruction twice on an endpoint with no
idempotency key. Split on the same line CopyItemDialog draws (DR-13):
a structured PadApiError means the server refused before publishing —
re-arm; anything else latches an outcome-unknown state.
- `willCollapse` compared against `String.trim()`, reintroducing the very
JS-vs-Go whitespace mismatch $lib/push/message exists to avoid (JS
trims a leading U+FEFF the server keeps; it leaves a U+0085 the server
strips). Added `trimPushMessage`, which trims with Go's class.
- The textarea described only the counter, so the collapse note and the
over-length error reached no screen reader. Both now live in one stable
referenced node that swaps text rather than mounting and unmounting —
an aria-describedby pointing at an absent id resolves to nothing.
- Positive presence wording implied the count was current. It now says
"as of the last check" and names the ~30s window.
Test changes: the Go fixture test duplicated `strings.Fields` rather than
invoking the handler, so a change to the handler's normalization would
have left BOTH suites green — demonstrated by mutating the join
separator, which the copied-expression test did not notice and the new
handler-driven test caught on 22 cases. The bound is likewise now
asserted through the endpoint at 4096/4097 instead of comparing the
constant to a copy of itself.
Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd
* fix(web): fence the push composer against destroyed instances, unrecognised errors, and a frozen count (codex round 2)
Three findings, one of them introduced by round 1's own fix:
- The send/copy continuation fence used the generation counter, which
cannot see a keyed REMOUNT. `{#key itemSlug}` gives item B a fresh
instance with its own counter, so item A's in-flight send still saw its
own `gen` unchanged and called the SHARED parent `onclose` — closing the
composer the user had just opened for B. Added a per-instance
`destroyed` flag, which is what actually distinguishes "still mine to
close" from "I no longer exist".
- The outcome-unknown split treated any PadApiError as proof the server
refused before publishing. It isn't: the API client turns EVERY JSON
error envelope into one, including a gateway 5xx invented after the
handler published. Replaced with a whitelist of codes the handler and
its middleware actually emit pre-publish; everything unrecognised is
now ambiguous. The asymmetry is deliberate — an unnecessary "we can't
tell" costs the user a check, a wrong re-arm delivers twice.
- PRESENCE_STALL_MS only rescued the FIRST read. A later poll that hung
froze the count at its last value indefinitely while the UI kept
rendering "1 session connected" as fact. A known answer now expires to
"can't tell" after 30s without a refresh — the server's own presence
staleness bound, so past it our answer carries no more authority.
Also dropped the status→alert role swap on the composer's live region:
changing a live region's role and its text together is not reliably
honoured, so the escalation was a promise the markup couldn't keep. The
blocking condition rides `aria-invalid` on the textarea instead.
The "latest ARRIVED, not latest ISSUED" behaviour of the sequence fence
is kept and now documented as a choice: dropping an early-arriving
response because a newer request exists strands the UI when that newer
request is the one that never settles.
Each fix mutation-tested 1:1 against its new test.
Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd
* fix(web): complete the pre-publish whitelist and retire in-flight polls on expiry (codex round 3)
Two of round 3's three findings were real:
- `csrf_error` and `email_not_verified` are middleware refusals, written
strictly before the handler runs, so they belong in
PRE_PUBLISH_ERROR_CODES. Without them a CSRF mismatch told the user we
couldn't tell whether their message was sent, when nothing had been.
- The 30s staleness expiry didn't fence requests already in flight. A
poll issued before the expiry could land after it and reinstate the
very count we had just declared too old to trust. Expiry now advances
`presenceAppliedSeq` to the current `presenceSeq`, retiring those
responses; the poll issued in the same tick carries a newer seq and
still applies.
The third finding — that `archived` belongs in the whitelist, and that
the launcher should be hidden for archived items because "the endpoint
always rejects them" — is REFUTED. handlePushToItem has no archived gate
(`requireItemVisible` admits archived items), and pushing to an archived
item against a running server returns 200 with `pushed: true`. There is
no `archived` error code on this path to whitelist, and hiding the
launcher would remove a capability that works. Recorded rather than
silently skipped so the next reader doesn't re-derive it.
Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd
|
||
|
|
e91c4fc261 |
fix(store): include the cursor's own second in /changes deltas (BUG-2539) (#1086)
* fix(store): include the cursor's own second in /changes deltas (BUG-2539)
items.updated_at / items.deleted_at are RFC3339 whole-second strings
(store.now()), while the /changes cursor is a unix-millisecond value —
normally the previous response's server_time. ItemsModifiedSince
formatted that cursor with the same second precision, truncating it
DOWN, then compared with a strict `>`. Every change landing in the
cursor's own second compared equal and was dropped, permanently: the
caller advances its cursor past that second and nothing reaches back.
User-visible symptom: a bulk archive ~450ms after a page seeded its
cursor left the item rendering as LIVE indefinitely — no banner, no
redirect — while the server had deleted_at set. It was never
archive-specific (updates were dropped identically); a missed update is
usually re-delivered by the next event, a missed deletion never is.
Compare inclusively against the truncated second instead. The boundary
second may be re-delivered, which every consumer of this endpoint
applies idempotently, and it is bounded to one second of changes per
sync. Sub-second storage is the other fix and is a migration, not a
one-liner: these comparisons are lexicographic on TEXT columns and
mixing precisions inverts them ("…20.451Z" sorts BEFORE "…20Z").
Verified against a live instance with four cursors all strictly earlier
than the archive in real time: two inside its second MISS, two in
earlier seconds HIT.
Tests:
- TestItemsModifiedSince_SameSecondCursor — same-second leg plus a
previous-second control. Fails 3/3 unfixed, passes 3/3 fixed; the
control passes on both.
- e2e bug-2539-sync-window — the banner must appear in the already-open
page AND follow a /changes delta that carried the deletion, so a
reload cannot satisfy it. The 450ms leg fails unfixed; 1200ms control
passes on both.
Claude-Session: https://claude.ai/code/session_01QGbUKZBAZoWdEgiTNWsXag
* test(store,e2e): close the review gaps in the BUG-2539 counterfactuals
Codex review of
|
||
|
|
2cb5e0aab0 |
fix(web): surface swallowed sync failures; defer a mid-sync sync_required (BUG-2508) (#1084)
* fix(web): stop losing sync changes when a consumer fails to apply them (BUG-2508)
Three defects on the incremental-sync path, all reproduced before any fix. The
reproduction IS the regression test (syncCursor.svelte.test.ts) rather than a
throwaway, because what proves the loss and what pins it are the same artifact.
1. THE CURSOR ADVANCED BEFORE CONSUMERS APPLIED. `triggerSync` set
`lastSyncTime = changes.server_time` and then notified, so a consumer whose
refetch failed left the cursor past changes nobody had applied — and since
`/changes` is asked FROM that cursor, the server could never re-deliver them.
Silent and permanent. The `full_refresh` arm three lines below already had
the right discipline ("don't advance until pages confirm success"), so the
file disagreed with itself; the incremental arm now shares it via `deliver`.
2. ASYNC CONSUMERS' REJECTIONS WERE NEVER OBSERVED AT ALL. `notify` wrapped
`cb(result)` in try/catch, which catches synchronous throws only, and two of
the five consumers are async. Their rejections did not reach that catch —
they surfaced as unhandled rejections while the service went on believing
the sync had been applied. Callbacks are now awaited, so "caught and ignored"
and "not caught at all" collapse into one honest answer, and the failure is
logged instead of dropped.
3. A `sync_required` ARRIVING MID-SYNC WAS DROPPED, not deferred. The in-flight
request was issued before that signal, so its window cannot cover it, and
nothing re-announces the gap. `triggerSync` now records it and runs one more
pass; the flag is cleared before each request so a signal arriving during one
is not swallowed by the pass that predates it.
Plus the second half of the report: the timeline's SSE-driven refresh caught its
failures and did nothing at all with them, leaving the panel quietly missing a
comment somebody else had just posted, with no indication and no retry — the
next refresh only comes with the next relevant SSE event, which may never
arrive. It now logs and retries ONCE on a backoff. Not a banner (this is a
background refresh, and a modal-weight failure surface would be worse than the
bug) and not unbounded retries (the debounce exists because SSE replay can
hammer that endpoint).
Scope held to the triage: `doIncrementalOrFull`'s fallback behaviour is
untouched.
The tests assert the `since` ARGUMENT OF THE NEXT REQUEST, never an end state
(team CONVE-12): "changes lost", "changes never made" and "refetch succeeded
with nothing to do" are indistinguishable by end state, and what separates them
is whether the server can still be asked. A control leg pins that a clean sync
still DOES advance the cursor, so a fix that simply never advances it fails.
Three mutants, each failing only its own tests. The rig itself nearly produced a
false green — the service is a module singleton whose `setWorkspace` issues its
own `/changes` call, and the first version counted it and leaked cursor state
between tests, which made one leg pass on another leg's calls.
* test(web): cover the timeline's SSE-refresh retry, and make its SSE mock fan out (BUG-2508)
The retry added in the previous commit had no test. Adding one required fixing
the harness first: this file's `sseService` mock returned a disposer and dropped
the callback, so the component was subscribed to nothing and any test of how it
REACTS to an event would have passed vacuously. It now fans out, which is the
same mock defect BUG-2509 hit on the attachment bus.
Both legs assert on the REQUESTS issued, not on rendered entries: a timeline that
never refreshed and one that refreshed successfully with nothing new look
identical on screen (CONVE-12). The control leg — a successful refresh must NOT
retry — is what stops a fix that simply retries unconditionally from passing,
which would double every successful refresh on an endpoint the debounce exists to
protect.
Two mutants: removing the retry and dropping the once-only guard each fail only
this test.
* fix(web): fence the timeline's SSE refresh against teardown (BUG-2508)
The retry added earlier in this branch could fire after unmount: a rejected
request schedules it from its own catch, and the identity fence there
(reqSlug/reqWs) is not a teardown fence — a remounted panel can legitimately
carry the same identity, so "same item" never meant "still alive". `onDestroy`
only unsubscribed SSE and left both timers running.
Now `onDestroy` clears the shared timer and latches `destroyed`, which every
continuation that can outlive the mount checks: entry, the success path before
it writes state, and the failure path before it schedules the retry.
Found by an independent review pass on this branch — the leak was mine, introduced
with the retry.
* fix(web): revert the cursor gate; keep the failures observable (BUG-2508)
Scope call after review: keep this bug narrow, and revert the coordinator change
rather than ship it.
The gate ("advance the cursor only if every consumer applied") was correct at the
service boundary and INERT in production, because no consumer reports failure —
verified at four sites, and confirmed by a live leg that behaved identically on
gated and ungated builds. Shipping it would have read as a fix for the reported
bug while changing nothing, which is worse than the open bug: the next person
cites it as handled. Reverting an inert change loses nothing users ever had.
The design half — consumer contract, the poison-consumer case that would pin the
cursor for everyone against an unbounded /changes window, and the
markSynced/onTabResume inconsistencies — is filed as IDEA-2535.
What ships here, all within "surface the failures":
- FAILURES ARE OBSERVABLE. The old try/catch caught synchronous throws only, and
two of the five consumers are async with their promise discarded — those
rejections were not "caught and ignored" but unobserved entirely, surfacing as
unhandled rejections with nothing tying them to the sync that caused them. A
rejection handler is now attached to whatever a callback returns, and both arms
log. Deliberately NOT awaited: consumers keep running concurrently and delivery
stays synchronous, so observability does not smuggle in an ordering change.
- A `sync_required` ARRIVING MID-SYNC IS DEFERRED, not dropped. The in-flight
request was issued before that signal, so its window cannot cover it, and
nothing re-announces the gap.
- Cursor semantics are UNCHANGED, and a test pins that deliberately: if someone
reinstates the gate, it fails and sends them to IDEA-2535 rather than letting an
inert contract ship quietly a second time.
Three mutants (drop the async observer, drop the sync log, drop the deferral
flag), each failing only its own test, plus a control that a sync with nothing
pending runs exactly one pass.
|
||
|
|
d8b68c443e |
fix(web): clear the attachment NodeView missing-latch on parent restore (BUG-2509) (#1083)
* fix(web): clear attachment NodeView missing-latch on parent restore (BUG-2509)
Archiving an item 404s its attachments without deleting them (DR-13). Any
attachment surface that PROBES inside that window therefore observes exactly
what a deletion produces, latches it as permanent, and stays dead after the
restore. The strip and the timeline already reconcile this themselves via the
`parentArchived` prop plus their own epoch + no-store re-probe; the editor
NodeViews could not — their latch is closure-private state inside a Tiptap
view, unreachable from a prop — so they were routed out of PLAN-2392 3c-iii's
scope and never got the equivalent.
Two distinct defects, both reproduced live before any code was written:
1. The shared HEAD metadata cache memoizes `missing` for the page lifetime, on
the premise that a settled result is a durable fact about a content-addressed
row. True for deletion, false for archive: a `missing` observed in the
archived window is a fact WITH an expiry cached as though it had none. Every
later reader replays a 404 the server would no longer give — INCLUDING a
NodeView constructed fresh, which is why remounting the editor did not heal
the file chip.
2. The NodeView `deleted` latch is cleared only by a uuid swap, and a restore
does not change the uuid.
Which one bites depends on `canEdit`, which is forced false while archived: an
edit-permissioned user's restore flips the content branch and builds a fresh
editor (healing the image, whose load event repaints it, but not the chip, which
makes no request and reads the poisoned cache), while a viewer keeps the SAME
editor across the whole flip and sees both stay dead.
The fix is a restore channel on the attachment bus — deliberately NOT the mirror
of the deletion channel. Deletion is authoritative and subscribers latch it;
this signal carries NO VERDICT and only prompts a re-ask. Subscribers re-probe
no-store and clear the latch ONLY on an authoritative `ok`, so an attachment
genuinely deleted while its parent was archived 404s and stays dead, and a
mis-routed signal costs one HEAD and changes nothing. That is what keeps restore
from becoming an undo-resurrection vector (DR-17).
`announceAttachmentParentRestored` does both halves because they cover different
populations: the notify reaches surfaces already mounted and latched (a cache
invalidation cannot — their latch is not a cache read they repeat), and the
invalidation covers surfaces built later, which a notify cannot reach because
they did not exist when it fired. Invalidate first, so a subscriber re-probing
synchronously inside the notify is not answered from the entry being dropped.
Verified in a real browser against an isolated instance, on both legs (owner and
viewer, item opened while archived then restored with no reload), plus the DR-17
leg (attachment row genuinely deleted during the archived window — stays dead)
and the control (opened live, then archive/restore — never latches, since the
latch requires the NodeView to be CONSTRUCTED inside the archived window).
Tests: the three bus mocks now fan the restore channel out to subscribers rather
than only recording the subscription — a spy-only mock leaves the NodeView
subscribed to nothing and every reaction test passes vacuously. New coverage for
the cache invalidation, the channel (including the invalidate-before-notify
ordering, asserted through its observable consequence), and both NodeViews' heal
/ stay-dead / routing / teardown behaviour. Each new assertion was mutation-
tested: no-op'ing the invalidation fails 3, short-circuiting either listener
fails the heal tests.
* fix(web): fence the restore probe against deletion, workspace and item-switch (BUG-2509)
Three defects an independent review pass found in the previous commit. The
first is the safety property that commit claimed to protect.
1. DR-17 RESURRECTION, reachable in one interleaving. The deletion bus sets the
latch synchronously, but the restore continuation fenced only on teardown and
a uuid swap — so "restore probe starts → delete is confirmed and broadcast →
probe resolves ok" cleared the latch and repainted a row the server no longer
has. The browser leg missed it because there the delete preceded the signal,
so the probe itself 404'd; the hole is only in the overlap. Fixed with a
monotonic deletion generation captured before the probe and re-checked after —
a bare `deleted` re-check would not do, since the latch can be set and cleared
again while one probe is in flight.
Relatedly, a non-`ok` result was treated as "do nothing" when `missing` is in
fact the authoritative existence answer the probe asked for. It now latches,
which also settles two concurrent probes resolving out of order rather than
letting whichever answered first win.
2. The continuations never re-read the address after the await, so an answer
about ws-A's copy could heal a node whose editor had since moved to ws-B —
the same cross-workspace staleness that made these files read the workspace
off the live reader in the first place. Re-checked on landing, as
ItemTimeline's probe already does.
3. The ItemDetail edge was keyed on the LEVEL (`itemMatchesRef && isArchived`),
which also drops when you navigate AWAY from an archived item — so every such
navigation announced a restore that never happened, invalidating a workspace's
metadata cache and prompting probes. The latch now carries the item id: a
restore is "the same loaded, matched item went archived → live", nothing else.
Extracted as `parentRestoreEdge` and unit tested, because ItemDetail resists
jsdom mounting and that case is otherwise unpinnable.
Also: the two teardown tests passed vacuously — a leaked listener still sees
`destroyed` and returns, so they stayed green with the dispose call deleted.
They now assert the registry is empty. Mutation testing then caught the same
class in two of my own new tests: the image's mid-probe-deletion and
workspace-change cases asserted only that the placeholder was still visible,
which is true either way until a `load` fires. They now assert the observable
consequence of a wrong heal — the cache-busted reload it would arm.
Every new fence was mutation-tested individually (5 mutants, each failing only
its own tests). Full suite 1496 passed; svelte-check 0 errors; all three browser
legs re-verified against a binary built from this tree.
* fix(web): generation-fence every probe; key the restore signal by item (BUG-2509)
Round two of independent review. Three more findings, all confirmed against the
source; the third means the previous fix was incomplete for a realistic
navigation, not merely unfenced.
1. A STALE PROBE COULD STILL REACH THE LATCH PATH. The previous fence was
per-CAUSE (did a deletion land?) rather than per-TRANSITION, so it only caught
one direction. The other: an OLDER probe answering after a NEWER transition —
the image's archived-window probe still in flight when the restore heals, then
re-latching what the restore just fixed; and the chip's CONSTRUCTION probe
(issued inside the archived window, which is the whole bug) landing after the
restore signal and marking a live chip dead with nothing left to undo it.
Replaced with one monotonic per-NodeView generation, bumped on every
authoritative transition — deletion, missing-latch, heal, uuid swap, and
receipt of a restore signal — captured by every continuation that mutates
presentation. The invariant is now structural rather than a list of cases:
a continuation may only act if nothing authoritative happened since it
started. Bumping on receipt is what invalidates a probe issued before the
restore, including the early-return path where there is nothing to heal.
2. UUID FENCING WAS BY VALUE, so a swap away and back passed the check again —
and the deletion listener filters on the CURRENT uuid, so a delete arriving
while the node pointed elsewhere was ignored. Stale ok + same uuid on return =
resurrection. The swap now bumps the generation, which is what the
continuations compare.
3. THE EMITTER MISSED RESTORES THAT HAPPEN WHILE THE PANE IS AWAY. Archive A,
navigate to B, let someone else restore A, come back: no archived→live edge is
ever observed by this tab, so nothing was announced and a fresh chip read the
archived window's cached 404 — the original bug by another route. A per-mount
edge latch cannot see this, and keying it to the item id does not help; the
memory has to outlive the mount. Replaced `parentRestoreEdge` with
`archivedItemRegistry`: mark an item when seen archived, announce when it is
next seen live. That subsumes every case the latch handled (restore in place,
navigate away, mount-on-archived) and covers the one it structurally could not.
Verified live, same document throughout (asserted, not assumed — the leg stamps
the document and checks the stamp survives, since a full reload would drop the
very state under test). Control: the same leg against a binary built from the
PREVIOUS commit's emitter leaves the chip dead on return while the image heals,
which is exactly the predicted symptom — so the leg discriminates rather than
passing for free.
Tests: registry unit tests including both the false positive and the miss; the
stale-probe orderings for both NodeViews; the uuid away-and-back case; and the
restore probes' `cache: 'no-store'` (the mocks were discarding the options
argument, so the point of the re-probe was untested). Ten mutants total across
both rounds, each failing only its own tests — one (the uuid-swap bump) survived
first time and got the test it was missing.
* fix(web): make the latch fence structural; correct three stale comments (BUG-2509)
Round three of review. The generation fence I added covered the two probes I had
been looking at and missed three others that also latch: both toolbar MIME probes
and the activation probe. Same defect as the one already fixed — a probe issued
inside the archived window answering 404 after the restore healed the node — at
call sites I had not enumerated.
Rather than patch three more sites, the fence moved INTO `latchMissing`, whose
signature now REQUIRES the captured generation. Every path into it is an async
probe answering a question it asked earlier, and a latch is destructive and
permanent, so "the caller will remember" was the wrong shape: an unfenced call
site is now a type error rather than a bug found in review. Fixing this class one
site at a time is what produced the miss.
Three comments were left saying things that are no longer true, which in this
file is not cosmetic — the comments are how the next reader learns the rules:
- the PLAN-2411 seam note still said the restore channel was "stated, not built
here" and that only that future channel could clear the latch. This branch is
that channel. Rewritten to state what actually holds now (the signal never
clears anything; only a server `ok` does).
- my own generation docstring claimed EVERY mutating continuation captures it,
which the transforms and the activation-open path do not. Restated to say
what it governs (the latch and the heal) and what it deliberately does not
(opening a viewer, which `activationSeq` owns; rotate/crop, which mint a new
attachment).
- the metadata cache's "no staleness concern" predates the distinction this bug
turns on. Split: a settled `ok` cannot go stale, a settled `missing` can,
because it describes reachability rather than contents.
Two review findings are DECLINED, recorded in the code where the next reader will
ask. Re-checking `address().itemId` after the await: itemId is routing, not
ownership, and the probe's answer is about the ATTACHMENT — fencing on it would
imply a relationship the code does not have. Eviction for the archived-item
registry: it holds one uuid per archived item viewed, and dropping a mark early
reintroduces this bug silently for long sessions only, which is the worse trade.
The new toolbar-probe test passed vacuously at first (`selectAll` does not build
the toolbar, so the probe under test never ran and the stale-release handle was a
no-op default). It now drives a real NodeSelection and asserts the probe fired.
Twelve mutants across the three rounds; two survived first time and each got the
test it was missing.
|
||
|
|
7b46894413 |
fix(e2e): silence cross-actor SSE creation toasts suite-wide (BUG-2334)
The e2e suite shares one pad instance and one workspace, so items seeded by OTHER concurrently-running specs arrive over SSE and stack "X created: ..." info toasts bottom-right — directly over bottom-right UI (the graph drawer's detail card), turning unrelated specs' clicks into a race. pane-content-link-anchors:238 paid a ~40-minute rerun tail at nearly every merge gate. The fix is a narrowly-scoped test-surface kill switch, not a retry: - `quietExternalToasts()` (toast store): reads a localStorage flag no production code ever sets; never throws whatever storage does. - The ONE call site announcing another actor's SSE work — the external `item_created` toast in the workspace layout — checks it. Toasts the page earns with its own actions are untouched, so specs still exercise the real toast surface (copy-dialog's no-force-click policy keeps its protective value). - The shared e2e fixture installs the flag on every context via `quietCrossActorToasts()`; collab-persistence's self-built contexts install it explicitly; account-delete's contexts never enter workspace routes and stay bare. - sse-toast-quiet.spec.ts pins BOTH sides: the quiet leg anchors on the layout branch's own by-uuid GET (pre-attached response log — no arm-order race; SSE-stream response gates the create; bounded settle before the negative assert), and a deliberately unflagged CONTROL context proves the product toast still fires — the real behavior cannot silently regress behind the suite-wide silence. Evidence: three consecutive full local suite runs with ZERO failures (baseline: 1-3 interception/load flakes per run); unit tests pin the helper's contract. Reviewed to fresh-angle CLEAN over four Codex rounds (vacuous-anchor, arm-order, SSE-connectedness, and self-built-context holes all found and fixed by the loop). Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu |
||
|
|
837035e3ce |
fix(viewer): disarm failed promotion, gate degrade, rebase pinch on flip (PLAN-2392 3d fix round)
Three final-pass P2s in the attachment viewer's touch gestures: 1. FAILED-PROMOTION / STALE-OWNER: tryPromoteToPinch's missing-founder (!a || !b) path returned with the pan scalars still set — a stranded phantom pan that ate later gestures. It now fully disarms (release capture, clear scalars, drop the stale entry, disarm the tap, clear swallow) and re-arms the incoming touch as a fresh first touch, superseding the stale owner the way the first-touch reconcile does. The onTouchDown non-touch guard now requires the owner registry entry PRESENT, so a reconciled-out owner routes to that disarm instead of being misclassified as a live non-touch owner and swallowing the press. 2. GATES-ON-DEGRADE: the 2->1 degrade armed a survivor pan without the pointerGatesOpen check every START path carries — a native modal or stacked viewer opening mid-pinch left a pan that resumed when the layer closed. degradeToPan now gates on the leased viewer root and full-clears (before clearing `pinching`, so the held suppressClick drops too) when the gates are shut. 3. FLIP-MID-PINCH: the sheet class flips synchronously with the breakpoint while the ResizeObserver re-clamp is async, so an immediate post-flip move mixed the new stage rect origin with the old midpoint baseline and jumped the offset. onPinchMove now tracks the baseline's rect origin and re-seeds the midpoint (zero delta; scale is rect-independent) the moment the origin shifts, before the async re-clamp catches up. Four discriminating tests (all fail on the reverted code); the 36-test V2 pinch suite + all existing suites stay green unmodified. Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu |
||
|
|
84eef5dd38 |
test(viewer): CDP mobile touch-gesture proof + device checklist (TASK-2519)
PLAN-2392 phase 3d V3 (final task of the plan) — the mobile browser proof for the attachment viewer's touch gestures shipped in V2 (TASK-2518). Real touch is driven through the compositor via CDP Input.dispatchTouchEvent (a CdpTouch helper in the e2e lib), since Playwright's touchscreen is single-tap only. New web/e2e/attachment-viewer-touch.spec.ts (mobile-chromium), 9 legs: - two-finger spread/converge zoom in/out - off-centre affine anchor oracle: a moving-midpoint translate+spread keeps a known image-local point under the midpoint (sub-pixel residual; a zoom-around-centre mutant misses by ~75px, TOL=6px) - double-tap fit<->actual + single-image-tap-inert + backdrop-tap-close - 2->1 lift degrade: jump-free hand-off + survivor pan arms - '+' mid-pinch rebase (no stale-baseline snap; sampled on a tiny post-+ move) - touchCancel all-cancel teardown + next gesture arms fresh - letterbox touch never pans + letterbox tap closes - image/stage touch-action:none, backdrop auto - tap-to-load first-tap priority CDP semantics empirically pinned (not assumed): touchStart/Move carry the full active set; touchEnd names the ending point (pointerup#<id> observed); touchCancel is all-or-nothing. Emulation boundary recorded honestly in DOC-2521 (device-proof checklist): the 2->1 survivor-pan CONTINUATION can't be expressed in CDP — synthetic touch releases the survivor's implicit pointer-capture on the next move, tearing the fresh pan down (a real digitiser keeps it), so the leg proves arm+no-jump and the continuation is device-verified. Also checklisted: gesture feel/arbitration, momentum, real touchCancel, iOS Safari (no WebKit CI project), off-root release. Mutation-verified (build web+go at worktree root, fresh CI server per run): pinch handler disabled -> spread/oracle/rebase red; anchor->stage-centre -> oracle red while spread stays green (discrimination); double-tap disabled -> toggle red; degrade disabled -> 2->1 red; rebase disabled -> rebase red. Codex: 3 rounds, final CLEAN (r1 flagged a stage-settle race -> fixed, and a docs-based touchEnd objection -> refuted empirically; r2 flagged the rebase test wasn't discriminating -> sampled on a tiny move + mutation-proved). Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu |
||
|
|
9d9670e5cc |
feat(viewer): touch pan, pinch, double-tap + touch-action:none (TASK-2518)
3d-V2 of PLAN-2392: the attachment viewer now owns touch. `touch-action: none` on the image and stage lets pointer handlers drive single-touch pan, two-finger pinch, and double-tap-to-toggle; the letterbox stays a native tap-to-close (the backdrop keeps `touch-action: auto`). Gesture state machine (built on V1's pointer registry): - Touch gestures arm only on a PAINTED-IMAGE hit, gated by a per-element paint generation (`paintedGen === loadToken`) so retry-loading is inert while the thumb→original upgrade stays live. The accept-gate snapshots the loader's fence inputs before `decoded()` mutates them. - Pinch composes ONE candidate at the clamped final scale (anchor-zoom around the previous midpoint + midpoint translation), clamped once; PINCH_MIN_DIST=12 with the below-min HELD-scale skip and re-entry rebase. - 1→2 promotion surrenders the pan capture (swallowing its lostpointercapture); 2→1 degrade rebases to the surviving founder; third-and-beyond touches are registry-only; per-pointer pointercancel routes degrade-vs-full-clear. - DOUBLE_TAP_MS=300 / SLOP=24, image-only, with compat-dblclick dedup; a live touch gesture is never seized by a mouse press, and a mouse pan keeps the looser bitmapPresent arm. Owed premise inversions: the sheet e2e now asserts touch-action none; the restore guard + test comments updated (the viewer owns touch via pointer events, but a touchmove is still not defaultPrevented, so the origin check remains the catch). Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu |
||
|
|
cf85e8b8d3 |
feat(viewer): pointer registry + touch tap semantics (TASK-2517)
PLAN-2392 phase 3d V1 — the multi-pointer plumbing V2's pinch needs,
shipped inert. A `pointerId -> {x,y,type}` Map ('registry') tracks every
primary pointer (mouse, pen, touch); the mouse/pen drag keeps byte-
identical semantics as the 1-entry case (ownership still keys off
`gesturePointerId`, capture off `capturedPointerId`).
- Remove the `touch stays native` early-return gate; branch touch at the
top of onPointerDown: touch ENTERS the registry but arms NO drag and
takes NO capture. Taps still work — a touch tap falls through to the
backdrop onclick (close), the chrome exclusion, and the deferred
tap-to-load button (first-tap priority).
- Registry hygiene precedes every guard (round-2 P1): registry.delete
runs FIRST in onPointerUp AND onPointerCancel, before any owner guard,
so browser-claimed touches (which pointercancel routinely under
touch-action:auto) never leak. abortGesture / onLostPointerCapture
delete the pointer too; cancelGesture is restructured to clear the
registry UNCONDITIONALLY (before its no-gesture early return).
- Reconcile a STALE armed owner (an off-root missed pointerup, pre-
capture) out of the registry on the next superseding press, guarded so
a same-id re-press never drops the entry it just set.
- The id-change effect deliberately does NOT touch the registry: nav
doesn't change the physical pointer set.
Test inversion (falsify-don't-contort): the former ':2248' test pinned
"a touch pointerdown is IGNORED". It is replaced by the V1 contract,
split honestly into (a) touch press+move arms nothing / mouse byte-
identical and (b) a real no-move touch tap closes via the backdrop.
Added: registry drains on pointerup AND pointercancel (direct assert via
a test-only __registrySize accessor — the registry is inert in V1, so a
leak has no indirect observable), a pointercancel-storm-during-mouse-drag
leak/ownership test, a chrome-tap-inert test, a stale-owner
reconciliation test, and a touch tap-to-load first-tap-priority test.
NO touch pan, NO touch-action change, NO pinch in this task (V2).
Sheet-swipe-dismiss routed out of V1 as IDEA-2520.
Codex: 2 rounds, final CLEAN (round 1 caught the stale-owner leak + a
conflated inverted test; both fixed).
Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu
|
||
|
|
3d191017ed |
fix(web): fence ordinary attachment loads against in-flight deletes
U2 (TASK-2511) added the ref-counted `inFlightDeletes` marker but applied it only to `revalidateAfterRestore`'s merge. The ordinary list-load reconciliation paths still filtered on `deletedIds` alone, and that set is latched only AFTER a delete's API await (via the deletion bus self- broadcast). In the gap between an optimistic removal and that broadcast a row is gone from `attachments` but not yet tombstoned, so an ordinary list() response issued (or in flight) across that window — a mount/retry load, or the restore path deferring to an in-flight same-view load — could carry the row and repaint the tile the user just removed. Honor `isDeleting(id)` on every ordinary-load reconciliation path, exactly as the restore merge already does: the response row filter, the pending- upload merge, and the load-failure repaint. The settle-time rollback stays a direct write into `attachments` (the marker is cleared in performDelete's `finally`, after the catch re-inserts the row), so a genuinely failed delete still rolls its row back into view. Continuation-count math is unchanged in spirit — `rows`/`missed` simply exclude the same ids the restore path already excludes. Tests (jsdom, each mutation-verified): the P1 flow via a retry load, the pending-upload merge leg, the load-failure repaint, and the rollback- after-failure discipline surviving a list response that landed mid-delete. Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu |
||
|
|
885871a638 |
test(web): browser-prove attachment lifecycle completeness (TASK-2514)
The falsifiable subset jsdom can't see for PLAN-2392 phase 3c-iii — one Playwright leg per lifecycle mechanism the U1-U3 chain built, plus the fix for the U3 count test that was authored but never run. - Navigation-step (U3): a 3-image set whose viewer order is DERIVED at runtime (created_at-DESC ties are the DB's, not the upload order); the middle-navigated "arrival" is deleted via a SEPARATE API context so the process-local bus never tombstones it, its metadata is primed with a cacheable 200 in the PAGE context, and arrowing onto it after a reopen forces a no-store HEAD that 404s DESPITE the primed 200 (armed waitForResponse, causally the arrow's probe) → tombstone-advance to a distinct survivor. - Restore-revalidate (U2): on an ISOLATED workspace (the shared suite's SSE stream starves the delta-sync cursor), archive via per-item event then RESTORE via the BULK endpoint (items_bulk_updated, no item_id) — proving the prop-driven strip revalidation covers what a per-item SSE subscription would miss. Asserts no attachments.list on archive, and a one-shot route HOLDS the restore's revalidation list in flight to prove the tiles never blank DURING the fetch, not just after. - Timeline (U1): a strip-UI delete (so announceAttachmentDeleted runs on the process-local bus) reconciles a comment thumbnail img→missing live, with the document + timeline element stamped to prove no reload or remount. Also fixes attachment-surface-chrome.spec.ts's U3 count barrier: a bodyless HEAD is reported as net::ERR_ABORTED after its headers arrive, so it fires requestfailed, never requestfinished — the completion barrier now keys on the response. New e2e/lib/attachment-viewer.ts helpers: createWorkspace, createDoc, archiveItem, restoreItem, bulkItems (workspace-slug-aware), STRIP_DELETE. Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu |
||
|
|
ffaba632cf |
test(web): pin attachment open-set mutation contracts (TASK-2513)
PLAN-2392 phase 3c-iii U4. Pin the open-set mutation contracts DR-15 style — assert the chosen behavior, don't assume it. - Upload-during-open: a new REAL end-to-end test mounts the strip + the AttachmentSurfaceHost, opens the surface on a 2-image set, fires the upload bus, and asserts the open surface's set is unchanged (counter stays 1/2) while the strip's own tile list DOES gain the row. The two legs are independent: a dead upload bus fails the strip leg, a live-following surface fails the surface leg — neither masks the other. It pins the no-live-follow half; the in-place-mutation half stays pinned by events.test.ts's deep-snapshot test. - Rename/metadata-change: no channel exists to exercise it (api.attachments has no rename/update-in-place op; transform mints a new peer row; metadata is immutable), so the contract is WRITTEN DOWN in the events.ts deep-snapshot doc rather than tested, and the future channel is routed to IDEA-2515. Codex-reviewed (3 rounds): tightened the doc to scope claims to the event channel, correct the api.attachments surface, and account for deletion reconciliation + downstream metadata completion. Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu |
||
|
|
c5190d6a96 |
feat(web): revalidate attachment metadata per navigation step (TASK-2512)
Generalize T6's per-open forced-probe (`forcedNonce`) to per-(openNonce,
attachment): `forcedFor: { nonce, ids: Set }`. The opened entry AND every entry
navigated to now gets exactly one automatic `no-store` revalidation, while
arrowing BACK to an already-probed entry within the same open takes the fast
path. A cross-tab deletion of a sibling is no longer invisible when arrowing to
it. A reopen mints a fresh nonce, so the set resets and every entry re-probes.
Two semantics pin the accounting:
- COMPLETION, not dispatch (round-2 P1): a pair is recorded only when its forced
probe resolves non-stale. A probe discarded stale (arrow away before it
resolves) leaves the pair unseen, so arrow-back re-probes rather than painting
a maybe-deleted entry live off the seed.
- AUTOMATIC only (round-4 P2): a Retry-/restore-driven forced probe (the reload
path) never records the pair, keeping the two mechanisms independent — an
arrow-back after a Retry still gets its one automatic probe.
The mark is a plain-object write in the async continuation, guarded by the
existing `req.stale()` check and keyed to the pair the run dispatched for, so it
joins no tracked scope and cannot self-invalidate the effect.
Tests: this task owns the T6-era expectations its behavior change INVERTS.
- surfaceMetadata.svelte.test.ts: the two "navigation keeps the nonce → no
additional forced probe" tests now assert navigation to a fresh sibling forces
a second no-store revalidation (complete OR incomplete seed); added an
arrow-back-is-fast-path test and two new-behavior tests (delayed probe →
stale-discarded → re-probes; completed Retry does not record → arrow-back still
auto-probes), both mutation-verified to fail on the naive regressions.
- AttachmentSurfaceHost.svelte.test.ts: the arrow test inverts to "arrowing to a
fresh entry forces one no-store probe of the arrival; arrowing back does not".
- Lightbox.svelte.test.ts: corrected two tombstone-advance comments that claimed
advanced-to entries use the plain fetch (they now force per U3).
- attachment-surface-chrome.spec.ts (e2e, not runnable in this worktree): the
no-store counting test inverts — arrowing to a fresh sibling now forces one
HEAD of the arrival; final counts a:2,b:1. Kept to race-free claims only.
Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu
|
||
|
|
08b6d94d30 |
feat(web): revalidate attachment strip content on parent restore (TASK-2511)
The item attachment strip has no SSE subscription — its only live inputs are the in-process delete/upload buses — so a restore that happened while this browser was elsewhere never reaches it, and its rows keep rendering from the pre-archive fetch (thumb URLs work again by accident, metadata may be stale). Thread `parentArchived` from ItemDetail (the same signal the timeline U1 and the surface host DR-14 take, so it covers BULK archive/restore whose `items_bulk_updated` carries no item_id) and reconcile the CONTENT gap (PLAN-2392 3c-iii U2): - RESTORE (true->false edge): `revalidateAfterRestore` re-fetches the attachment list and MERGES it over the current rows — a DIFFERENT, gentler path than the load effect's non-retry rerun, which blanks attachments/expanded/pendingDelete/ deletedIds/pendingUploads synchronously. It never blanks (rows stay painted until replaced), preserves the tombstones / pending uploads / expanded-overflow / open confirmation the reset path would wipe, recomputes the continuation count off the fresh `total`, and clears a stale load error on success. A failed revalidation is swallowed (not surfaced as the blocking error row) — the strip already holds a good pre-archive list. - ARCHIVE (false->true edge): a content no-op. Tiles keep their painted bytes; the interaction paths already fail server-side (DR-14's 404 correction). Edge correctness: - The archived latch is keyed to VIEW IDENTITY, not just the boolean: the strip persists across item switches, so an archived item A -> active item B is also a true->false transition — reseeding the latch on any view-key change keeps a SWITCH from firing a duplicate racing load (round-3 P2). - `inFlightDeletes` (ref-counted, so a concurrent second delete of the same id can't be cleared early) excludes a row whose optimistic removal has run but whose tombstone broadcast — post-await in `performDelete` — hasn't yet, so a restore refetch landing in that window can't repaint the just-removed tile (round-3 P1). - The revalidation DEFERS to a load already fetching THIS view (per-view counter, since the api client has no request abort and a stale prior-view load lingers): that load returns fresh-enough data, and superseding it would strand the strip empty if the revalidation then failed. Latches are plain `let`s read/written under `untrack` (the Svelte self-write trap). Adds the strip's `parentArchived` prop mount in ItemDetail. 22 new unit tests; each guard mutation-verified. Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu |
||
|
|
e3d80619f9 |
feat(web): reconcile timeline attMeta across attachment lifecycle (TASK-2510)
ItemTimeline cached attachment HEAD-probe results in `attMeta` with no
invalidation path: a deleted attachment stayed a live `<img>`, an archived
parent kept painting a soon-to-be-broken image, and a restore never escaped a
`missing` cached while archived.
Add three reconciliation surfaces (PLAN-2392 3c-iii U1):
- Deletion bus subscription + per-id `tombstoned` set. A HEAD that resolves ok
AFTER the delete can't repopulate `attMeta`; the tombstone is per-id so one
deletion never false-fences another attachment's in-flight probe.
- A per-timeline lifecycle epoch captured at probe dispatch and checked before
every authoritative write, bumped by both archive/restore edges — a
pre-archive ok or pre-restore missing that lands after the edge refuses to
write.
- A `parentArchived` PROP (threaded from ItemDetail, mirroring the surface
host's DR-14 prop — NOT an SSE subscription, so it covers bulk
archive/restore whose `items_bulk_updated` carries no item_id). While true,
every probe goes through `revalidateAttachmentMetadata(..., {cache:'no-store'})`
so a stale cached ok can't repaint a broken image and a genuine 404 lands as
missing (the LEVEL rule). The false->true edge drops this item's tracked
attMeta/probe state; true->false re-probes the unresolved set no-store via a
reactive `probeNonce`.
Both lifecycle edges reconcile over the whole tracked set (referenced ∪ attMeta
∪ probed ∪ unresolved), not just currently-referenced ids, so an attachment
resolved-then-unreferenced can't replay a stale ok as a broken image or skip a
restore re-probe on a stale probe mark.
Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu
|
||
|
|
bbb5a69219 |
fix(attachments): dock-clear the viewer nav on the mobile sheet (PLAN-2392 3c-ii)
The prev/next arrows were direct children of the fixed backdrop, centred `top: 50%` against the FULL viewport. The T5 phone sheet shortens the stage and docks meta+toolbar at the bottom, but the arrows had no sheet-scoped anchor, so on short/landscape phones they landed in or over the dock — obscured, or stealing the dock's taps. Move the two `.lightbox-nav` buttons INSIDE `.lightbox-stage`. On desktop the stage is `position: static`, so their `position: absolute` still resolves against the fixed backdrop — byte-identical full-viewport centring. In the sheet the stage is `position: relative`, so `top: 50%` re-anchors to the shortened stage box and the arrows clear the dock with no magic-number dock height. Add `pointer-events: auto` to `.lightbox-nav` (the stage is `pointer-events: none`); on desktop that was already the inherited value. Nav now trails the toolbar in DOM order (Close, toolbar, Previous, Next); accessible-name addressing keeps the trap tests green — adjusted the two order-naming assertions in the modal-contract spec. Adds a 720x400 landscape e2e leg asserting the arrows centre on the stage (not the viewport), sit clear of the dock, and stay clickable; the pre-fix DOM fails the stage-centre assertion by a dock-half. Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu |
||
|
|
9c9107176c |
test(e2e): reconcile attachment e2e with the converged surface (TASK-2493)
PLAN-2392 phase 3c-ii T7 — the e2e half of the convergence (one host, one Lightbox for ANY attachment; the options-panel + image-viewer channels retired). Falsified + rewritten (the convergence changed the premise, so these were rewritten to assert the new behaviour, not deleted): - strip file-tile / editor file-chip open the role=dialog surface (no-bytes fallback arm), not a role=menu options panel - modal two-stacked-viewers -> the SUPERSEDE invariant (one host mounts at most one Lightbox by construction) - owner-4 BottomSheet source moved from the retired file-panel to the surviving strip delete-confirm menu - parity/two-host exact dialog-name -> anchored RegExp (T2b grew the accessible name to "name, type · size"); enforced in the hostile-name leg - zoom thumb->original timeline, switch-safety, and mobile deferred-load counts: filter to GET (the T6 always-revalidate-on-open no-store HEAD hits the same variant-less URL and polluted the counts) New legs: PDF/ZIP fallback integration (Open for PDF, none for ZIP); T6 no-store HEAD count (one per open, none on arrow, one on reopen); DR-14 archived-parent probe-gate + archive-while-open close; dual-host peeked addressing + un-peek; Pixel-7 sheet geometry / dock contiguity / backdrop-vs-chrome dismissal / shortened-stage zoom / file route / overlay-centring / DR-18 label reveal / native-pinch touch-action / forced-colors Canvas plate; desktop-unchanged contrast. Each of the four load-bearing behaviours was MUTATION-verified (break in source, rebuild the worktree ./pad, confirm the targeted leg FAILS, restore, confirm green): fallback admission, host event addressing, archive-close transition, and the T6 forced no-store probe. Codex-reviewed to CLEAN over five rounds. New selectors live in web/e2e/lib/attachment-viewer.ts, addressed by class or accessible name (never a bare [role="dialog"]); assertions are item-scoped / by-id / by-anchored-name to avoid the BUG-2504 unscoped-list pagination trap. Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu |
||
|
|
5ee40d728f |
feat(attachments): mobile phone-sheet layout for the viewer (TASK-2492)
The AM-3 Lightbox-owned phone-sheet layout (PLAN-2392 3c-ii / T5). A reactive `isSheet = $derived(viewport.isMobile)` toggles a `.lightbox-sheet` class on the existing dialog root, and CSS scoped under that class re-lays-out the EXISTING chrome into a bottom-anchored sheet: the toolbar and meta leave their desktop absolute anchors and dock, stacked, to the bottom edge (via `position: static` + `order`), the stage fills the space above (and becomes its own containing block so its overlays centre over the shortened stage, not the dock), and the counter moves to the top-left. A class, not a bare `@media`, so JS and CSS share the one app breakpoint and the flip is a DOM fact the modal-contract jsdom suite can drive and read. The layout is fully layout-independent of the modal contract: the portal, lease, focus trap, escapeStack registration, loader and zoom transform are untouched, and nothing is keyed on the viewport, so a breakpoint flip mid-open re-lays-out the SAME instance with zoom/selection state intact. No `BottomSheet`/`Menu` instance nests, no swipe dismissal, and no `touch-action`/pointer-capture changes (per the amended DR-6). Every rule is scoped under `.lightbox-sheet`, so the desktop layout is byte-identical, and the sheet chrome carries its own forced-colors boundary. Tests: sheet selection, a mid-open flip proving same-instance re-layout (root + img identity, src + zoom survive), the docked chrome staying excluded from all three pointer-gesture lists (pointerdown/wheel/dblclick, each with a live control), no dismissal on a chrome click, and the full modal contract re-run under the mobile viewport mock. Geometry, touch, `@media`/forced-colors visuals are named for T7's browser legs. Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu |
||
|
|
44ee5ad806 |
feat(attachments): always-revalidate-on-open via per-open nonce (TASK-2491)
The converged attachment surface now revalidates the OPENED entry's metadata
on every open, so a cross-tab / background delete is caught rather than shown
as a live-looking row backed by a stale HEAD.
Mechanism: AttachmentSurfaceHost mints a per-open `openNonce`, incremented once
per accepted surface request and ridden on the request object into Lightbox (so
the `{#key request}` remount carries the matching nonce). The nonce joins the
metadata machine's SUBJECT identity (ws, attachmentId, nonce) and drives a
`forcedNonce` tracker that forces exactly one probe of the opened entry per open
— gated on the probe's own precondition (`isOpen && addressable`) so a not-yet-
open/addressable subject can't burn the nonce. Navigation keeps the nonce, so
arrowing does not force (3c-iii owns navigation-step revalidation). Unlike
`seenReload`, the nonce is deliberately NOT seeded from the incoming value: the
guarantee is to force on the first nonce seen.
The forced probe passes the literal `cache: 'no-store'` fetch option, threaded
through revalidateAttachmentMetadata -> fetchAttachmentMetadata -> fetch init,
so the endpoint's `max-age=3600` HEAD cannot serve a stale cached HEAD and
defeat detection. A `missing` result routes through the existing tombstone path.
Deliberate behavior change: the strip's zero-probe fast path is gone. A
complete-seed open previously issued no HEAD; it now issues exactly one forced
no-store revalidation (the displayed fields still come from the seed — seed-wins
merge — so the header is unchanged). A HEAD is not a byte fetch: the mobile
deferred cell's no-auto-bytes rule is untouched. The renderer load key does NOT
gain the nonce — a reopen is a whole new keyed mount, so cross-open coherence is
the remount's job and the nonce is constant within an open.
Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu
|
||
|
|
af2a6ed57d |
refactor(attachments): retire the two legacy attachment channels (TASK-2490)
3c-ii T4b: with every producer already on the surface channel (T4a), delete
the two legacy channels and the cutover-window bridge. Gone from
`events.ts`: `notifyViewerOpen`, `notifyAttachmentPanelOpen`,
`ViewerReadyImage`, `ViewerOpenRequest`, `AttachmentViewerOpenEvent`,
`AttachmentPanelOpenEvent`, both legacy predicates
(`isAttachment{Viewer,Panel}EventForHost`) and registries
(`registerAttachment{Viewer,Panel}Listener`), plus the producer-boundary
MIME-drop gate (it lived inside `notifyViewerOpen`). The surface channel —
no admission MIME gate, renderer arm decides — is the sole open channel.
`AttachmentSurfaceHost` loses its two legacy subscriptions and the
`fromPanel` / `fromViewer` translators, and its `wsSlug` prop retires (the
surface channel captures its own workspace at emit); `ItemDetail` stops
passing it. The now-dead viewer-toolbar context props left by T4a
(`mutationsEnabled` / `getItemContent` / `getLiveContent`) delete from
`ItemAttachmentStrip` and `ItemTimeline` — the host forwards them to
`Lightbox` directly. Stale `AttachmentViewerHost` / panel-channel comments
updated across the touched files.
Tests: the legacy-channel unit + bridge tests delete with the channel; the
three host tests that exercised real surface behavior through a legacy event
re-point onto the surface channel (and the invoker + one-probe tests gain
discriminating assertions). Full suite green (1357), `npm run check` 0 errors.
Reviewed to a fresh-angle CLEAN over four Codex rounds.
Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu
|
||
|
|
72a7491dff |
feat(attachments): all six producers emit the surface channel (TASK-2489)
3c-ii T4a: every attachment producer now emits `notifyAttachmentSurfaceOpen` with a captured `workspaceSlug`, its `invoker`, and its seeds — the two direct `Lightbox` mounts (the strip's and the timeline's) are gone, and the ONE `AttachmentSurfaceHost` owns every open. The bridge stays subscribed throughout, so this window still ships a working route. - Strip IMAGE tile (`openLightbox`): direct mount deleted; emits the raster `lightboxImages` set at the clicked index, invoker = the tile. - Strip FILE tile (`openOptions`): panel → surface, a single-image set from the list row, invoker = the anchor. The flat seeds are normalized to match images[0] exactly (`filename || null`) — a blank `''` filename against a `null` record would fail the notify validator and silently drop the open. - Timeline (`openLightboxFromImg`): direct mount deleted; emits the sibling `viewerImageFor` list, invoker = the img. - Image NodeView: the raster/non-raster fork collapses to ONE surface emit after the (unchanged) resolve-before-emit gate — svg and raster both emit the same event, and the surface's own `getSurfaceRenderer` picks the arm. - Chip: panel → surface single; `workspaceSlug` captured from the live address. Both direct-mount producers gain a `paint.isCurrent()` STALE-ACTIVATION fence they lacked: the strip reuses its existing paint fence; the timeline gains one (a `viewIdentity` + `createPaintFence` recorded in an effect that tracks `entries` and captures the view through `untrack`, so a bare workspace change that has not yet reloaded keeps the old paint and refuses a stale click). The timeline's old `lightbox`-clear-on-switch is retired — the host closes the open surface on the resource switch now. Tests: the nine producer suites migrate to the surface channel — each producer's emitted workspaceSlug / invoker / seeds / index asserted, and the strip/timeline opens asserted THROUGH a mounted `AttachmentSurfaceHost` (the real Lightbox end to end). `viewerImagePayload`'s direct-mount premise is falsified and rewritten to assert the emitted set. The svg cases assert one surface emit / the fallback arm rather than "opens nothing". Housekeeping: the stale `AttachmentViewerHost` comments in the touched files updated. npm run test 1389 pass, npm run check 0 errors; the blank-filename open is regression-pinned; reviewed to a fresh-angle CLEAN (round 1 caught the flat-seed mismatch, round 2 clean). Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu |
||
|
|
9dc569c866 |
feat(attachments): the atomic cutover — one surface host, panel retires (TASK-2488)
3c-ii T2b, the commit users feel. ItemDetail's two attachment hosts collapse to ONE `AttachmentSurfaceHost` at the top-level position, carrying the union prop set (wsSlug for the legacy-panel bridge + itemId + hostToken + resourceGen + mutationsEnabled + the content getters + parentArchived). BUG-2413's server fail-closed disposition is on the base, satisfying the merge-gate. Deleted: `AttachmentPanelHost`, `AttachmentViewerHost`, `AttachmentDetailsPanel` (551 lines, + its CSS + its Menu/MenuItem usage). `AttachmentDeleteConfirm` survives as the drill-down inside the surface. The panel's `closeAfterNavigation` retires with it — the surface stays open after Open/Download, where the panel closed itself. Every open now flows through the one host and the file-capable Lightbox (T3): a strip file tile → the fallback arm + file toolbar (through the legacy panel channel + the bridge — producers repoint in T4a), a chip → the same, an image NodeView → the raster arm, a non-raster redirect → the fallback arm. Preserved three panel behaviors the cutover would otherwise have dropped (the round-3 "what did the panel do that nobody ported" lens): - A SINGLE-item surface whose file 404s shows the panel's inert "no longer available" overlay instead of flash-closing — `soleMissing` keys on `images.length === 1` (a panel open is always single), disposes the loader (no bytes) and keeps the toolbar inert; a MULTI-image set still advances / closes through the tombstone path, and an EXTERNAL bus delete still closes a single, exactly as the panel host did. - The dialog's accessible name is the display name plus the header's type · size, not a bare alt. - A null-filename file is named with the shared "Untitled file" fallback (the bridge uses `displayFilename`), not the Lightbox's bare "Attachment". Test migration (named, not silently dropped): the extraction grep-gate → `Lightbox.extraction.test.ts` (same contract, new consumer); the NodeView → host → Lightbox whole-route test retargeted onto this host (the SVG redirect now lands on the fallback arm); the three host suites consolidated into `AttachmentSurfaceHost.svelte.test.ts` (lifecycle/addressing from T2a) and the Lightbox suite (panel behavior), with a migration manifest naming what moved where. Grep: zero PRODUCTION references to the three deleted components. npm run test 1388 pass (Lightbox 190, host 22), npm run check 0 errors; the single-item overlay + the aria-name changes are pinned; reviewed to a fresh-angle CLEAN (round 1 caught the three dropped behaviors, round 2 clean). Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu |
||
|
|
e0c06b0bdf |
feat(attachments): unified surface host with three-channel bridge (TASK-2487)
3c-ii T2a: `AttachmentSurfaceHost` — the one host that replaces the legacy panel
host + viewer host, mounting the grown Lightbox that opens ANY attachment. Built
COMPLETE but mounted NOWHERE: T2b does the atomic cutover (mount this, delete the
two legacy hosts + the panel), T4a repoints the six producers. Only its own suite
mounts it (grep confirms zero app-code references).
Three channels, one request (the bridge invariant). During the migration the
producers still emit on the two LEGACY channels, so the host subscribes to all
three (surface, panel, viewer) and TRANSLATES the legacy shapes INTERNALLY into
its own request state — it never calls `notifyAttachmentSurfaceOpen`. One `$state`
request → `{#key request}` → one Lightbox mount, so an accidental old+new double
emission for the same open supersedes rather than double-opens.
Translation, explicit: a legacy PANEL event becomes a single-open request with
`invoker = anchor` (never the live activeElement; no positioning), a one-element
images set from the seeds, and `workspaceSlug = the host's wsSlug prop` — the
transitional exception, since the panel channel carries no workspace (the new one
always does). A legacy VIEWER event maps field-for-field, keeping its captured
workspace and filling the flat seeds from images[index].
Lifecycle, ported from the two hosts and stated as the rule T3 deferred:
- Archive-closes / restore-revalidates, TRANSITION-based — which naturally splits
archive-while-open (close) from open-while-already-archived (no transition → the
surface mounts probe-gated INERT, not a flash-close: the user asked for the file,
so show the inert "unavailable" state rather than blink it shut).
- Resource-switch clear on itemId change OR resourceGen advance (the complete rule).
- External-deletion close-when-SINGLE; a multi-image set is left to the Lightbox's
own tombstone path (advance / close-last), never preempted by the host.
- closeRequest bound to its target (stale-continuation fence) and `request?.` guards
on the lazily-read Lightbox props (a delete continuation reads them after the
close nulled request).
Adds a minimal `revalidateToken` prop to Lightbox (threaded into the metadata
address, replacing the hardcoded 0) so restore re-probes an archived-at-open
surface; T6's always-revalidate-on-open openNonce layers onto the same input.
Tests: the three subscriptions; exact-once per channel (one legacy event → one
request → one mount → one probe → one focus return; old+new double emission does
not double-open); translation fidelity incl. the transitional-wsSlug and
captured-wsSlug cases; anchor→invoker for focused/null/disconnected; dual-host
addressing isolation; and the ported panel-host lifecycle (archive/restore/
item-switch/resourceGen/deletion). npm run test 1440 (host 21), npm run check 0
errors; the set-vs-single deletion guard mutation-verified; reviewed to a
fresh-angle CLEAN.
Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu
|
||
|
|
af055a9bc9 |
feat(attachments): Lightbox admits any attachment — file route and reclassification (TASK-2486)
3c-ii T3: the image viewer becomes the converged surface that opens ANY
attachment. PURELY ADDITIVE — no producer routes files/archived to it yet (T4a),
so only the tests exercise the new capability and production behavior is
unchanged.
Admission flips (DR-20 final form). The last-mile filter that kept only
allowlisted-raster MIMEs navigable and REFUSED null/unsafe is gone, along with
the `unsafeAtOpenIds` snapshot: every entry is admitted, and safety moves to the
ARM. `shownRenderer` (and the toolbar's Open, and the fallback icon) derive from
the RESOLVED MIME — the seed's, or what the metadata machine's HEAD probe filled —
so `'raster-image'` mounts the `<img>` + bytes while a non-raster type (unsafe,
a file, or a still-unresolved MIME) mounts the no-bytes icon fallback. Admitting
unsafe/unresolved renders no hostile bytes: the arm fails closed on the resolved
MIME, joins the load key, and the loader is disposed off the raster arm.
Reclassification. A null-seed open shows the fallback until its probe answers,
then re-derives: raster → the image arm, PDF → fallback + Open, ZIP → fallback
without Open. The raster load hands the loader the RESOLVED mime
(`{ ...img, mime_type: resolvedMime }`) so its own DR-16 gate — which reads the
img it is given — agrees with the arm rather than refusing a null-seed row the
arm admitted.
Archived parent. `parentArchived` is a prop now (was hardcoded false), threaded
to the metadata machine so an archived-parent open forces a reachability probe
(DR-14). Every toolbar action is inert while `missing || unreachablePending`,
where `unreachablePending = parentArchived && (phase !== 'ok' || slow) && !missing`
— inert until a SETTLED ok, covering seeded, transient (slow-timeout), and a
forced re-probe after a prior ok. Disabled anchors drop their href (keyboard-inert),
not just aria-disabled. Threading the prop through the production hosts is T2a/T4a;
the archived lifecycle (archive-closes / restore-revalidates) lands with the host.
Tests: swept and rewrote the falsified 3c-i at-open-refusal pins to
admission+fallback assertions; added the file route (PDF Open present, ZIP absent),
delayed null-seed reclassification (raster/PDF/ZIP), and archived-parent gating
(pending / transient-stays-inert / ok→archived re-probe / re-enable-on-ok). The
3c-i unsafe-mid-view tests still pass. npm run test 1419 (Lightbox 190), npm run
check 0 errors; key invariants mutation-verified; reviewed to a fresh-angle CLEAN.
Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu
|
||
|
|
da1b3f82ed |
feat(attachments): unified surface-open channel (TASK-2485)
The 3c-ii convergence's first task (T1): one bus channel for opening ANY attachment on the grown Lightbox — image, file, or a row whose type is not yet resolved — alongside the existing panel and viewer channels. Purely ADDITIVE: the two legacy channels are untouched and nothing repoints yet (T2a builds the host, T4a repoints producers, T4b deletes the legacy channels). Adds `AttachmentSurfaceOpenEvent`, `notifyAttachmentSurfaceOpen`, `isAttachmentSurfaceEventForHost`, and `registerAttachmentSurfaceListener` to events.ts, matching the sibling channels' conventions (DR-8 addressing via `isAddressable`, one host token per mount, same guard order and comment voice). Differs from the viewer channel by design: NO admission MIME gate (files and null-MIME/unresolved rows pass — the allowlist governs the render arm downstream, never admission), and no `anchor` (the centered surface returns focus via `invoker`). The event carries a CAPTURED-at-emit workspaceSlug (required, no host fallback) and nullable single-attachment seeds that, when present, describe images[index]. The emitter is the convergence boundary every producer will funnel through, so it is hardened accordingly: it reads every input EXACTLY ONCE (event scalars, the array length, each of the seven record fields), enforces the event's own invariants at the boundary (index in range; images[index].id === attachmentId; a non-null flat seed must agree with that record), and delivers a DEEP snapshot built by explicit field projection — a fresh all-primitive record per entry and an explicit event projection — so a caller that keeps mutating its set, a getter/proxy TOCTOU, a shadowed `.map`, a stray property, or a non-string identity field cannot reach an open surface. `invoker` is the one intentional live reference (the focus target). Tests (events.test.ts): predicate address isolation + null event; the capture rule; null-MIME pass-through (the old gate's drop asserted ABSENT here); the four boundary cases each their own test (out-of-range index, id mismatch, inconsistent seed, deep snapshot of array AND records); plus record-integrity, projection, and undefined-seed cases. cd web && npm run test (1410) + npm run check (0 errors) green; key assertions mutation-verified; reviewed to a fresh-angle CLEAN. Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu |
||
|
|
e08901df28 |
fix(e2e): resolve imported attachment via content reference, not list page 1 (BUG-2504) (#1075)
The round-trip spec's final assertion fished the imported workspace's unscoped attachment list, which defaults to limit 50 / created_at_desc. Once sibling specs (PLAN-2392 browser proofs) grew the shared e2e workspace past one page, the seeded logo — old in the sort — fell off page 1 and the find() failed, holding main's CI red since 2026-08-06. The trigger window contained only CI-action bumps; the race was latent and runner-timing shifts made it deterministic. Resolve the rewritten pad-attachment: UUID straight from the imported item's content instead — the contract the UI actually follows — and assert filename via Content-Disposition plus a byte-for-byte download match. Immune to suite growth by construction. Claude-Session: https://claude.ai/code/session_01VxyZv1g6W6rGx7nuaGcH3i |
||
|
|
7e85d1ce4e |
fix(attachments): viewer honors the missing metadata phase; comment refresh (PLAN-2392 3c-i)
Route the surface metadata machine's authoritative `missing` (404) phase through the viewer's existing tombstone/advance path: an out-of-page delete (another tab, a job, the API) never crosses the process-local deletion bus, but where the header probes the shown image its HEAD returns `missing`, which is just as authoritative (DR-17). A sentinel + untrack keep the effect CONVE-1688-safe and terminating (a whole gone set cascades advance→advance→close, one 404 at a time). Only `missing` latches; a `transient` stays retryable. Fence `runToolbarAction` against the shown identity and reset `toolbarBusy`/ `toolbarError` on subject change, so a confirmed or slow delete of the shown image that races the advance can't strand "Deleting…" or an error on the survivor now on screen. Refresh the final-state comments: the viewer is now the second consumer of the shared action list; the viewer host carries live `mutationsEnabled` since C1 while the open channel stays permission-free; the deletion bus is process-local, and the missing path's probe-scope (strip images seed mime+size and aren't re-probed) is stated where it matters. Tests: missing→advance, missing-only→close, whole-set cascade→close, transient→ no-op, and shown-delete-while-confirm-up→clean-toolbar (mutation-verified against the reset). Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu |
||
|
|
a593407a21 |
test(attachments): browser proof for the 3c-i surface chrome (TASK-2484)
DR-9's rule — the a11y and interaction work of the A-E chain is verified in a
browser or it is not verified. Desktop-chromium legs for the viewer's toolbar,
metadata header, delete flow, permission gate and gesture seams (the sheet
layout has no mobile e2e until 3c-ii).
New spec web/e2e/attachment-surface-chrome.spec.ts:
- Toolbar renders on all THREE origins (strip, timeline, body NodeView), with
Open/Download as real anchors carrying the EXACT canonical variant-less URL
(^/api/v1/workspaces/{ws}/attachments/{id}$) and the exact download filename.
- The permission gate: a peeked side withholds the delete affordance
(mutationsEnabled=false reaches it) and the active side's viewer toolbar
offers Delete.
- The delete flow: toolbar Delete → drill-down reached BY KEYBOARD with the
roving tabindex asserted (0/-1 ↔ -1/0), confirmed with Enter, the viewer
ADVANCES to the survivor (not the retired C1 close), and the deleted strip
tile disappears (bus reconciliation).
- The metadata header: name/type/size visible, a 180-char filename clipped with
a resolved text-overflow:ellipsis and the full value in title (DR-13), and the
inert-label contract proven by a DRAG on the header that does not pan a
zoomed BIG_PNG image nor dismiss the viewer.
- The gesture seams: a wheel over the toolbar zooms neither the image nor the
inert page behind it, with a control wheel over the stage that DOES zoom.
Every leg was mutation-checked against this worktree's built binary (revert the
impl line, rebuild, confirm the test fails, restore) — wheel exclusion,
peek-permission (mutationGate canEdit && !peeking), header name, delete advance,
toolbar-render, and the header pointer-exclusion. The wheel and header
mutation-checks each surfaced a false-pass that was fixed (a zoom-out clamped to
fit; a too-small image with no pan bound).
The FALLBACK arm + no-bytes invariant is a documented test.fixme: it is not
reachable through the real producers (they snapshot the viewer set at open and
filter unsafe MIME before it reaches the viewer), so it is jsdom-proven
(TASK-2476, via direct prop mutation). The peeked-side no-Delete VIEWER is
similarly jsdom-proven (TASK-2474): the content click that opens a viewer
re-activates (un-peeks) that side under the invisible-freeze model.
Two existing modal-spec trap tests were updated: the toolbar added focusable
controls, so the "last control" is derived (focusViewerLastControl) rather than
named, and the wrap is still asserted by name at both edges.
https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu
|
||
|
|
10b99dec2e |
feat(attachments): viewer deletion subscription — advance or close by identity (TASK-2477)
The image Lightbox subscribes to the deletion bus and reconciles a delete by IDENTITY (DR-5c): the shown image is tracked by id, the index derived, so a delete advances to a survivor or closes — never lands on a position that now names a different member. - The index-based `current` state is replaced by `shownId` (the shown image's id) + a `tombstones` Set. `survivors` = navigable minus tombstones (composes with D's unsafe/unresolved exclusions — a fallback-arm entry is deletable too); `shownIndex` derives from `shownId` (falls to 0 when it dangles); prev/next write `shownId`. Tombstones are per-instance and never reset — every producer keys the mount, so a reopen is a fresh empty set (no cross-open leakage). - handleDeletion(uuid): idempotent; a delete NOT in the surviving set (unsafe/ unresolved, another item's attachment, a dangling shown id) is tombstoned and ignored (no advance, no closing an already-empty viewer). An in-set delete advances `shownId` to the entry that followed the deleted one (wrap when the last) when the SHOWN one went, closes when zero survive, and leaves `shownId` put otherwise (identity, not index — deleting an earlier image keeps the same one shown). The id-keyed zoom-reset fires exactly on a real advance. - ONE path for both origins: the toolbar's own Delete announces on the bus (the descriptor's `announceAttachmentDeleted`), identical to an external delete, so the survivor logic can't tell them apart. The C1 close-on-delete latch is retired (the viewer had no survivor logic then); the toolbar ctx now omits `onDeleted` (that stays the panel's). The listener is disposed in the same teardown that releases the backdrop lease. Tests: the DR-5c case table (only/zero-left → close, one-left, deleting-shown → advance, wrap-around, deleting-earlier → same image by identity, reopen clears tombstones), plus delete-during-drag, delete racing the confirm drill-down, a non-image fallback delete, the dangling-id / empty-viewer guard cases, and a two-image toolbar integration in the strip suite (confirm → api → announce → advance). The strip's events mock now fans `announceAttachmentDeleted` out to listeners, matching production. Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu |
||
|
|
c6133af961 |
feat(attachments): content slot with icon fallback and no-bytes arm (TASK-2476)
The image Lightbox stage becomes a two-arm slot governed by a defined renderer seam. A mid-view unsafe entry now shows a NO-BYTES icon fallback instead of being dropped, while the 3a open gate (unsafe/unresolved AT OPEN → refused) is held through 3c-i. - NEW surfaceRenderers.ts: getSurfaceRenderer(mime): SurfaceRendererId | null — 'raster-image' for exactly the DR-16 raster allowlist (wraps canOpenInViewer, one source of truth), null otherwise. The id union is PLAN-2393's seam. - The old canOpenInViewer last-mile FILTER is replaced by: unsafeAtOpenIds (a mount-time snapshot of resolved-unsafe ids — the open gate, held); navigable (keeps safe + mid-view-unsafe, drops unresolved + unsafe-at-open); shownRenderer (the stage arm). Behavior matrix per PLAN-2392: safe → img arm; unsafe-resolved → refused at open / fallback mid-view (new — was dropped); unresolved → refused; added-while-open unsafe → fallback, unresolved → refused. Fallback entries are navigable + counted. - THE NO-BYTES INVARIANT: the fallback arm mounts no <img>; the load effect DISPOSES the loader on any non-raster arm; loadKey joins shownRenderer so a same-id safe→unsafe flip re-fires the effect and reaches the dispose; a releaseImg action clears the detached <img>'s src on unmount (aborts the native request rather than leaking it to GC); bitmapPresent is gated on the raster arm so zoom/pan/keys are disabled on the fallback; a reactive cancelGesture tears down a live drag the instant the bitmap vanishes (plus a pointerup bitmapPresent guard). The loader's own start() DR-16 gate is the backstop. Focus re-homes across the arm swap (the handoff effect tracks the arm). - The fallback arm renders the large family icon, the display name (full value in title, DR-13), type · size, and "No preview available"; same chrome, modal contract and lease as the raster arm. Tests: the behavior matrix rewritten cell-by-cell (unsafe mid-view → fallback, at-open unsafe/unresolved → refused with no fallback asserted), the no-bytes leg (no <img>, detached src cleared, loader disposed), fallback navigable/counted, zoom disabled, focus re-homed, the reactive drag-cancel, and getSurfaceRenderer units. The generation fence stays covered by viewerImageLoader's unit tests. Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu |
||
|
|
9cf86cb8c2 |
feat(attachments): viewer metadata header (TASK-2475)
The image Lightbox gains a filename / type / size header for the shown image, seeded from the LightboxImage and completed by the TASK-2473 surfaceMetadata (B) module — open immediately, fill what is null after (DR-2). - Display-name chain: filename ?? alt ?? 'Attachment'. A blank/whitespace filename is normalized to null once, at ingestion (the viewable .map), so the chain falls through to alt rather than rendering an empty string — this also fixes the pre-existing C1 case where "" ?? alt yielded "". The chain is shared by the header, the download attribute and the delete prompt (displayFilename is retired from the viewer). - Type via describeAttachmentType only when MIME is known (a viewer image always cleared the MIME gate, so it always shows); size via formatBytes only when size_bytes is a number — never fed null, absent beats "0 B". Seed values win; the fetch fills nulls but never overwrites a non-null seed. The fetch fires iff size is null (the HEAD never returns a filename), through the B module. - A transient failure renders the DR-10 inline "Couldn't load details · Retry" BESIDE the name it already knows; Retry revalidates (never replays). Retry unmounting on success re-homes focus into the viewer (the focus-handoff effect tracks the transient flag). - Bottom-left, clear of the top toolbar/Close (DR-18); a label like the counter (excluded from pan/zoom via .lightbox-meta in all three gesture lists, does not close), width capped to clear the centered counter in LTR and RTL. DR-13: truncated with the full value in title, logical properties, min-width:0. Tests: the precedence chain across every null/blank/whitespace combination, the size-null fetch fill + seed-wins merge (exact, distinct fetched MIME), the transient retry + focus re-home, and the 200-char full-value preservation. The strip/timeline producer mocks now export the metadata functions the mounted Lightbox needs. Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu |
||
|
|
c0ec3adc4b |
feat(attachments): viewer toolbar with host-supplied context on every mount (TASK-2474)
The image Lightbox gains an action toolbar rendered from the shared `attachmentActionsFor(ctx)` descriptor list (DR-5) — anchor/button per the `element` discriminant, icon-only on desktop, labeled on phone (DR-18). Delete reuses TASK-2473's shared confirm module: the module owns the confirmation GATE (request()), the descriptor (actions.ts) still owns the delete + announce. Context threading — one vocabulary at every Lightbox origin: - Lightbox gains mutationsEnabled (default false → read-only toolbar), getItemContent, getLiveContent. - AttachmentViewerHost, ItemAttachmentStrip and ItemTimeline each gain and forward the same three to their direct Lightbox mount. - ItemDetail supplies all three from its own values: mutationsEnabled is canEdit && !peeking (NEVER the timeline's own canEdit, which ignores peeking), so a peeked pane's viewer shows no Delete. - actions.ts AttachmentActionSubject.mime_type relaxed to string|null; Open needs a positively-previewable MIME (null → not offered), the rest ignore it. Toolbar integrates with the 3b gesture machinery: registered in the pointerdown, double-click AND wheel control-exclusion lists so a press/dblclick/wheel over it never pans or zooms; z-index within the <100000 viewer sweep. The delete drill-down is a role="menu" of role="menuitem" rows with roving tabindex (arrows navigate, Tab exits to chrome), focus enters the first row on open, Escape backs out before closing the viewer, and image-nav is suppressed while it is up. A confirmed delete closes the viewer (guarded on identity + a destroyed latch); a subject change or set-shrink abandons a pending confirmation. Tests: toolbar renders on all three origins; the peeked-pane leg (timeline canEdit true, mutationsEnabled false → no Delete); anchor fidelity; delete drill-down + permission-withdrawn abandon; and the review-hardening cases (wheel exclusion, role=menu roving, Escape back-out, image-change abandon). Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu |
||
|
|
9c40a5f141 |
refactor(attachments): extract surface metadata + delete-confirm machines (TASK-2473)
Lift AttachmentDetailsPanel's metadata machine and delete-confirmation
machinery verbatim into two shared Svelte 5 rune modules so the panel and
the converged image viewer (phase 3c-i) share ONE implementation:
- surfaceMetadata.svelte.ts — createSurfaceMetadata(address, {onSubjectChange}):
owns the three (workspace, attachment) fences, the seed-then-HEAD metadata
effect, retry (invalidate-then-fetch), dispose, and the phase/fields/slow
getters a renderer branches on.
- surfaceDeleteConfirm.svelte.ts — createDeleteConfirm(deps): owns the
'root'|'delete' sub-view state, the pending resolver, and the
permission-withdrawn abandon effect. Owns the confirmation GATE only; the
delete descriptor (actions.ts) still owns the delete itself via
ctx.confirmDelete, preserving its identity snapshot + permission re-check.
The panel becomes a consumer + renderer (322 lines lighter). Behavior is
unchanged: the 28-test AttachmentPanelHost oracle passes UNMODIFIED, and a
new grep-gate (AttachmentDetailsPanel.extraction.test.ts) proves the panel no
longer defines the machinery locally.
Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu
|
||
|
|
4f57a974c7 |
feat(attachments): action icons — MenuItem snippet path, actions.ts to SVG ids (TASK-2472)
Phase 3c-i task A (PLAN-2392). The attachment action descriptors carried glyph strings (⇗ ⇩ 🔗 🗑) rendered as MenuItem's text `icon`; this moves them to the shared monochrome SVG icon set so the toolbar the next tasks build renders real, forced-colors-safe icons. - The icon registry (attachments/icons) gains four ACTION icons — action-open, action-download, action-copy-link, action-delete — in the same inline-path, currentColor, stroke-only style as the file-family set, so both render paths (AttachmentIcon.svelte, iconSvg) draw them from one table. New ACTION_ICON_IDS + the IconId union (family ∪ action); a broad isIconId narrow (the family-only isAttachmentIconId stays for MIME→family mapping) so iconSvg renders action ids while keeping its generic fallback for unknown strings. - The action descriptor's `icon` field narrows from string to ActionIconId, and the four glyphs become the ids. AttachmentIcon's `id` widens from string to IconId — an unknown id is now a compile error rather than a silent generic-file fallback. - The details panel renders each action's icon through MenuItem's `iconSnippet` with AttachmentIcon (a per-iteration snippet closing over the row's id), not the text `icon` prop. MenuItem's `.mi-icon` slot centres its content on both axes so the block SVG isn't left-aligned — text/emoji consumers unchanged. - Two stale comments corrected (the "toolbar arrives in phase 3a" note → 3c-i). Falsifiable acceptance: a test asserts every panel action row renders an SVG and none of the four glyph characters survive (fails if the panel passes strings — mutation-verified); a drift-guard asserts the four action ids exist in the registry and render non-empty paths. Out of scope (3c-ii): the delete confirm's own rows. Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu |
||
|
|
d4da83d2cd |
fix(attachments): final-pass fixes — re-clamp on same-id reload, inert error state (PLAN-2392 3b)
Three fixes from the final full-diff review of phase 3b. 1. RE-CLAMP on a same-id decode. `loadKey` includes the pixel dimensions, so an async dimension fill re-runs the load — but the reset effect keys on `img.id` only and the ResizeObserver watches only the stage, so neither re-clamps the transform. The concrete failure: the unknown-dims desktop cell loads the original; dims fill; the policy switches to thumb-md; the thumb's smaller natural size lowers actualScale and MAX_SCALE, stranding the scale above the new ceiling with out-of-bounds pan. Re-clamp (clampState + rebaseDrag — not a reset; a valid zoom survives) in the <img> onload handler, where geometry is first measurable, fenced to the live element (el === imgEl) so a stale detached decode can't drive the current zoom. 2. INERT error state. `errored()` flips only the phase and leaves `displaySrc` set, so the broken <img> still satisfied readGeometry and every gesture entry point acted over the error UI with nothing decoded. `bitmapPresent` now also requires `phase !== 'error'`, and the wheel, double-click and live drag-move paths (which read geometry directly) bail on it too — a drag armed before the error aborts on its next move. All re-enable after a successful retry. 3. Three stale doc comments updated to the assembled contracts: the Lightbox header no longer claims original-only loading, and the "upload event carries only four fields" notes in Lightbox and ItemAttachmentStrip now reflect UploadedAttachment carrying the pixel dimensions since TASK-2459. Three new mutation-checked unit tests (each fails when its fix is reverted); zoom e2e spec re-run green on both projects. Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu |
||
|
|
872612360a |
test(attachments): browser proof for viewer zoom — desktop and mobile (TASK-2461)
Phase 3b's final task: the browser-level proof of the attachment viewer's zoom/pan/loading behaviour that jsdom structurally cannot give (no layout, no CSS, no gestures). New Playwright spec e2e/attachment-viewer-zoom.spec.ts runs on desktop-chromium and the Pixel 7 (mobile-chromium) project, with shared fixtures/helpers added to e2e/lib/attachment-viewer.ts. Desktop legs prove the RENDERED transform moves under wheel / ctrl-wheel / keyboard / double-click; the anchored point stays under the cursor; pan clamps in two legs (an in-bounds drag moves by the delta, an over-drag stops at the edge with no further movement); a press-drag-return-to-start over the backdrop does NOT dismiss while a plain backdrop click does; a click on blank stage space inside the stage box but outside the image dismisses (the letterbox is pointer-transparent); close/nav stay hit- testable and Tab still cycles at maximum zoom; enlarging the window clamps the stranded scale down to the new maximum (still zoomed, not reset); reduced-motion suppresses the animation while normal mode keeps it; and forced-colors keeps the image boundary visible. Loading legs prove the thumb->original swap (the thumb response finishes before the original is requested) and that a rapid A->B->A with a slow original leaves the live image correct (the switch-safety end-state; Chromium aborts detached img loads, so the detached-late-error fence itself stays unit-covered). The mobile leg proves the DR-5b deferred cell issues no automatic request until a real tap, then loads exactly one original. Every assertion is mutation-checked against the binary the Playwright webServer launches: each test fails when its implementation line is reverted. Selectors are class-qualified or by accessible name, never a bare [role=dialog]. Controls are addressed by accessible name; shared constants live in the lib. Also lands the DR-4 forced-colors CSS in Lightbox.svelte (deferred in the zoom tasks): a system-colour border on the image boundary (its box-shadow is stripped under forced-colors) plus explicit ButtonText borders on the controls — the contract the browser proof verifies. Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu |
||
|
|
e79a9bbb95 |
feat(attachments): DR-5b mobile policy — tap-to-load and fetch-on-zoom-past-fit (TASK-2460)
Own the mobile cells of the DR-5b decision table (the classifier landed in TASK-2459). Add a `deferred` loader phase for the mobile large/unknown cell — no automatic request; the viewer shows a tap-to-load placeholder sized to the image's aspect ratio (a neutral box when dimensions are unknown), with explicit `pointer-events: auto` and focus-handoff since it is replaced by the image it loads. A single deduplicated `loadOriginal` serves BOTH triggers — the tap (deferred cell) and zoom-past-fit (`scale > 1`, the painted thumb cell) — so a tap racing a zoom, a retry, or a later pinch is one fetch, not two. The fallback detector now runs on every first decode, desktop and mobile: when `thumb-md` was served the original (fresh upload, WebP/AVIF), it clears the deferred original so zoom-past-fit cannot re-download bytes already held. The zoom-past-fit trigger gates on a `painted` flag (tracked, never written by loadOriginal, so no self-write) — a zoom made before the thumb paints upgrades the instant it does, rather than stranding the user on the thumbnail. Zoom is disabled, not just no-op, while nothing is decoded: the zoom keys short-circuit and drag no longer arms/captures over the deferred placeholder. Retry after an on-demand original failure re-requests the original directly rather than reverting to the affordance. Breakpoint flips do not retro-fetch (platform is captured at load). Every idle cleanup funnels through one `toIdle()` so the `painted === false in idle` invariant can't drift, and every request edge restates the DR-16 gate. Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu |
||
|
|
7826866728 |
feat(attachments): DR-5b classification and desktop thumb-then-original loading (TASK-2459)
Add the memory-safety half of DR-5b: a pure pixel classifier (viewerLoading.ts) and a loader lifecycle (viewerImageLoader.svelte.ts) that paints a bounded thumb-md first and background-upgrades to the original on desktop, while a phone auto-pulls nothing (TASK-2460 tap). Classification is on pixels never bytes; null width OR height is a third 'unknown' class, not an alias for large. The fallback detector keys off the decoded long edge exceeding THUMB_LONG_EDGE (a thumb-md is bounded at 1024 by definition), so a thumb request silently served the original (fresh upload, WebP/AVIF) is caught without mirroring the server decoder set; the unknown-dims desktop cell requests the original directly since a bounded fallback would defeat the detector. Wire it into Lightbox: the <img> is keyed on a per-request loadToken (a fresh element per load/retry; reused across the thumb->original upgrade), DR-10 spinner + retryable error with an explicit pointer-events:auto retry that hands off focus before it disappears, and DR-16 as a loading gate at the request chokepoint (no request for unsafe/unresolved MIME). Staleness is fenced by a generation captured as a frozen data-gen DOM attribute plus the src, so a detached element's late load/error in an A->B->A navigation (same URL) can't clobber the live image. The load key includes dimensions so an async metadata fill re-runs the policy. Thread the width/height toUploadedAttachment used to drop. Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu |
||
|
|
90c5992600 |
feat(attachments): double-click zoom toggle and drag-to-pan (TASK-2458)
Desktop single-pointer gestures for the attachment viewer (3d keeps two-pointer pinch, double-tap and touch semantics), modelled on the captured-drag house pattern in graph/ItemGraph.svelte. - Double-click toggles fit <-> actual size, anchored at the pointer (toggleFitOrActual). Excludes presses/double-clicks on the close/nav controls, and stands down while a pan's click is being suppressed. - Primary-button pointer drag pans while zoomed, clamped by TASK-2454. Arm on pointerdown (capture only once the 4px threshold is crossed, so dblclick survives); the pan is origin + total delta, rebased whenever an external zoom (wheel / +/-/0 keys / resize) moves the transform mid-gesture so it never snaps. draggable=false + user-select:none stop native image-drag / text selection; the transform transition is dropped while dragging so the image tracks the pointer. - Drag-vs-click: a below-threshold press still closes on the backdrop; a past-threshold drag suppresses its synthesized click (owned single timer); a plain backdrop click still closes. - Arbitration: every pointer entry point (drag start, double-click, backdrop click) carries onKeydown's isViewerFrontmost / !isBlockedByModal gates, and the WHOLE gesture re-checks on every move — a drag that straddles a stacked-viewer or native-modal transition aborts (releases capture, leaves the transform). The gesture is owned by its pointerId, so a touch / second pointer can't engage, hijack, or terminate a live mouse drag; state fully resets so no gesture leaks into the next pointerdown. Tests cover the positive-pan-then-clamp acceptance, two anchored double-clicks returning (realistic click/click/dblclick on the image), all three disambiguation legs, stacked-viewer AND native-modal aborts mid-capture, and the gesture-hygiene edges (wheel/keyboard-mid-drag rebase, buttons-released teardown, lostpointercapture, stale-arm, pointer ownership, transition-off-while-dragging). |
||
|
|
ab340863a6 |
feat(attachments): pointer-anchored wheel zoom; restoration ignores viewer input (TASK-2457)
Wheel zoom for the attachment viewer plus a scroll-restoration guard so the
viewer's own input can't strand a page restore underneath it.
Lightbox:
- Plain AND ctrl/cmd wheel zoom (DR-4) via TASK-2454's zoomTo, anchored at the
cursor (stage-local coords; the stage is untransformed). Registered
imperatively on the viewer ROOT with { passive: false } so preventDefault
works — the inert page must not scroll and ctrl/cmd+wheel must not trigger the
browser's page zoom — and so a wheel over the backdrop is consumed too. Also
stopPropagation (belt to the restoration guard). Same frontmost /
blocked-by-modal gates as onKeydown. A horizontal-only wheel (deltaY 0) is
consumed but does not zoom.
Scroll restoration (shared route infra, scroll/restore.svelte.ts):
- Its passive wheel/touchmove/keydown listeners aborted a pending restore on ANY
such event — so the viewer's own arrow-nav (shipped 3a) stranded a restore
today, and wheel-zoom would too. New exported isModalViewerScrollInput ignores
events that are defaultPrevented OR originate inside the frontmost viewer;
generalized across wheel/key/touch (touch stays native until 3d, so it is not
defaultPrevented and only the origin check catches it). Purely additive to the
user-input branch — BUG-1425's anchor handling / one-shot / budget untouched.
A genuine non-viewer scroll still aborts, as before.
Tests: wheel anchor asserted against the module (nonzero stage offset), direct
preventDefault + stopPropagation, frontmost/blocked no-op each with a positive
control, horizontal-wheel no-op (seeded so a spurious zoom-out is observable);
restore integration tests drive a real restore and assert a viewer-originated
wheel/key/touch does NOT abort it while a genuine event does, plus
isModalViewerScrollInput unit tests. New fixture RestoreHarness.svelte.
|
||
|
|
07b9a0430e |
fix(attachments): hand off focus before a viewer control disappears (TASK-2456)
The attachment viewer claims aria-modal but dropped focus to <body> behind
its own inerted app when a focused, conditionally-rendered control unmounted:
when a MIME resolves unsafe and the set shrinks to one, the focused 'Next
image' button is removed and nothing moved focus (the Tab trap only repairs on
a later Tab; the restore only runs at teardown). A pre-existing 3a defect that
every control 3b adds inherits.
- New exported helper paneFocus.ts::handoffFocus keeps focus inside a modal
surface when a focused control leaves it. Two shapes: reactive (no departing
arg — repair after a Svelte {#if} drops the control, within the same flush)
and imperative (pass the departing control — blur it and hand off BEFORE the
caller removes/disables it, the house pattern from attachment-image.ts). The
departing control is excluded from fallback candidates so it is never
re-selected and then dropped on disable; the fallback (first tabbable, else
the tabindex=-1 container) is verified to have taken focus, else the container
backstops it. Shaped for TASK-2459 retry / TASK-2460 tap-to-load reuse.
- Lightbox wires it via an $effect keyed on the nav-visibility signal, guarded
to the frontmost, non-blocked viewer so a background viewer can't steal focus.
Reads only derived/element state and mutates DOM focus (no $state) — CONVE-1688 safe.
Tests: handoffFocus unit tests (reactive/imperative/disable/only-tabbable/
inert-refusal/no-op) with an explicit without-handoff control leg; Lightbox
same-instance shrink handoff, background-viewer non-theft, and Tab-cycles-at-
max-zoom.
|
||
|
|
e334e86340 |
feat(attachments): wire zoom transform into Lightbox (TASK-2455)
Wire TASK-2454's pure zoom/pan math into the attachment viewer: - Stage + transform wrapper around the <img> per the module's coordinate system (92vw x 92vh stage, object-fit: contain image carrying translate/scale). pointer-events: none on the stage lets letterbox clicks reach the backdrop; the image re-enables them; controls sit above via z-index (kept < the viewer's 100000 sweep bound). - +/- zoom about the stage centre, 0 resets, all INSIDE the existing onKeydown gates (defaultPrevented / isViewerFrontmost / isBlockedByModal). Ctrl/Cmd/Alt are left untouched (no act, no preventDefault) so browser page-zoom and OS shortcuts keep working; bare =, shift, and numpad forms accepted. - Transform resets when the shown image changes (arrow nav, or the set shrinking under current), keyed on image id via a plain-let sentinel so no $effect reads a $state it also writes (CONVE-1688). - ResizeObserver on the stage re-clamps SCALE first, then pan (clampState), since enlarging the viewport lowers the geometry-dependent MAX_SCALE. - Global inert ResizeObserver shim in setup-jsdom.ts; reduced-motion suppresses the transition only. Adds zoom-key, arbitration, reset-on-navigate, resize-clamp and centre-anchor tests to Lightbox.svelte.test.ts. |
||
|
|
27de6221c4 |
feat(attachments): add pure zoom/pan math module (TASK-2454)
The DOM-free half of PLAN-2392 phase 3b's desktop zoom: scale/translate state, anchored zoom, pan clamping and the fit<->actual toggle, with no imports at all so the math is testable without layout. jsdom reports all-zero rects, so anything that reads geometry is unprovable there — keeping the numbers in a pure module is what makes them assertable. Scale is relative to fit, so FIT is the constant 1 rather than a derived quantity: the CSS (92vw/92vh + object-fit: contain) stays the fit engine and the transform multiplies from there, which keeps the module and the stylesheet in ONE coordinate system. clampState composes scale-then-pan in that order because a resize can lower MAX_SCALE beneath a currently valid scale, and clamping pan first would bound it against a scale that is about to change. Reviewed adversarially in three rounds. Round 3 took the numerical robustness angle and found two real defects, both fixed here and both pinned by tests that fail against the pre-fix code: - extent * scale could overflow to Infinity at extreme finite geometry, making the pan bound infinite and silently disabling the clamp — the one failure mode that lets blank stage show past an image edge. - a subnormal fitted extent divided to an infinite actualScale, which zoomTo's clampScale then rejected as invalid and replaced with fit, turning the double-click toggle into exactly the silent no-op that TOGGLE_SMALL_SCALE exists to prevent. The fit-epsilon boundary is asymmetric (FIT + EPSILON reads as at-fit, FIT - EPSILON does not, a few ulps of binary rounding). Left as is and documented rather than papered over: every emitted scale has been through clampScale, whose floor is FIT, so a sub-fit state cannot arise. Both legs are pinned so a future change to the floor cannot make it matter silently. Also deliberately generalises the spec's "when actualScale === 1" rule to "within FIT_EPSILON of 1". At fractional devicePixelRatio a 1:1 image measures 1.0000000004, and strict equality makes the toggle a no-op in precisely the case the rule exists to prevent. 88 unit tests; 23 hand-built mutants killed. npm run check clean; full web suite 1180 tests green. |