feat(desktop): dedicated attachment index for the Files tab with bounded, recency-aware retention (T3b) - #25
Merged
Merged
Conversation
The Files tab projected the loaded message window, which is top-level only and 200 events per page, so a file posted in a thread or older than the window was simply absent (issue block#4428). Add the tab's own model instead. `createChannelFilesIndexController` opens the live subscription first and only then walks the channel's history in keyset pages over the reconnect-repair command — replies, deletions and edits included, no `top_level` filter — so backfill and live overlap instead of leaving a gap (AGENTS.md review-proven rule 2). Every page is ingested before its cursor advances, so an interrupted walk resumes on a consistent prefix, and everything is keyed by event id, so an event that arrives through both paths is indexed once. Relay input is bounded where it enters the index, not where it renders: imeta tags per event, parts per tag, part and content length, pubkey length, indexed events, deletions and edits, pages per run, and the relay detail carried into an error message. An edit may only rewrite the attachments of a message signed by the same key, and a forged edit is dropped outright once its target is known. `useChannelFiles` keeps its pure projection; its hook is superseded by `useChannelFilesIndex` and goes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>
Dragging a selection into a folder is a batch of relay writes started by a gesture that is easy to make by accident. It is now off by default, turned on from the tab's own "Drag selection" toggle while selecting, and capped at 20 files: a larger selection is refused whole and says so, rather than silently moving the first twenty. The tab also grows the two affordances the index needs. A partial failure — a history page that failed, or live updates that stopped — banners over the rows it did load instead of blanking the list, and a history walk that stopped at its page bound offers "Load older files" so the user is never left with no way to continue. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>
ChannelScreen feeds the tab from `useChannelFilesIndex`, so the list is the channel's attachments rather than the ones inside the loaded message window, and passes through the partial-failure message and the load-older control. `channel-files-index.spec.ts` (registered in the smoke allow-list) seeds 250 attachments — the last of them a thread reply, which the window projection could never see — and asserts the tab lists all 250 and reaches that state in under 500 ms on each of three runs. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>
…file backfill `fetchPage` returns whatever `get_channel_reconnect_repair` sent. A page of the wrong shape, or an event whose fields cannot be read, threw out of the backfill loop and became an unhandled rejection under the hook's `void controller.start()`: the history walk stopped, the cursor kept its old value, and the Files tab showed a partial list with no error and no way back. Reading a page is now allowed to fail. The failure is surfaced through the same `historyError` the fetch path uses, and it returns before the cursor advances, so a retry refetches the failed page instead of skipping it. Both halves get a test that fails when the guard is removed: a page that is not a list is refused by name, and a page whose events cannot be read leaves the cursor where it was and re-requests it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>
…guards A refused live subscription was caught, banner-ed and then never retried. It was opened only from `start()`, which latched after one call, and the banner's Retry ran `loadMore()`, which returns as soon as the history walk is complete — so the Files tab stopped following the channel for the life of the tab and its only recovery affordance did nothing (AGENTS.md review-proven rules 1 and 6). Live state is now tracked apart from history state. `retryLive()` re-opens the subscription and is idempotent on every state that makes a second relay call wrong; `retry()` — what the banner's Retry runs — drives the re-subscription and the history walk, because either failure alone leaves the other standing. The banner is cleared only by a subscription that actually opened, and the attempt budget is the terminal state rule 4 asks for (every attempt after the first is a user gesture that schedules nothing, so there is no self-amplifying loop to delay). The activation gate is keyed by channel id instead of a latched boolean. The index effect commits before the tab-reset effect, so on a channel switch the old boolean was still true and opened a subscription for a channel whose Files tab was never opened. `isFilesIndexEnabled` is the seam ChannelScreen calls and the test binds. Tests added for the guards that survived mutation: the pubkey cap and the timestamp check in `boundIndexSource`, the index retention cap reaching the tab's partial-list notice, newest-edit-wins, a deleted edit, the projection sort tie-break, the edit id check, the cursor's timestamp check, dispose awaiting an in-flight subscription, `hasMore` after dispose, the out-of-order edit cleanup and the projection's own authorship check, the hook's error and loading shape, and that nothing reaches the relay before Files is opened. `loadMore`'s duplicate complete gate is removed rather than tested: `backfill` owns it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>
… subscription The controller took its live subscription through a call that resolves for every outcome. A relay that answered the REQ with CLOSED -- `error: too many subscriptions` is the likely answer once this branch adds a per-channel subscription on top of the ones a channel view already opens -- still produced an unsubscribe closure, so the tab recorded a refusal as an open subscription: no banner, no Retry, and no further event for the life of the channel view. A terminal CLOSED arriving after the readiness window was quieter still, because the shared client deletes the subscription and told nobody. Both halves are now reported. `subscribeLive` carries an `onTerminalClose` alongside `onReady`; a terminal CLOSED fires it once, guarded so a throwing handler cannot abandon the rest of the inbound frame dispatch. The Files controller subscribes through that seam, treats readiness "closed" as a failed attempt, and takes the subscription down on a later terminal CLOSED -- both leaving a capped banner and a working Retry. A malformed live event no longer throws out of the controller's callback. The relay's EVENT payload is JSON.parse'd and cast, so `pubkey` or `content` can be a number and reading one as a string threw a TypeError into the relay's shared dispatcher, which flushes one buffer for every subscription in a bare loop -- discarding the rest of that batch for the timeline, unread counts, typing and huddles too. Every relay-supplied string is now type-checked and capped at the DTO, and the callback body is wrapped so a failure becomes the tab's live-error banner instead. Tests bind the production wiring to a real RelayClient and feed it relay frames: a refused REQ, a capped CLOSED message, a terminal CLOSED after EOSE, a retryable CLOSED that the client still recovers from on its own, and a malformed event followed by a good one. Seven mutations across the new guards were run and all seven are killed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>
…g new ones The Files index caps stopped at "refuse": once a channel reached MAX_INDEXED_ATTACHMENT_EVENTS the index took nothing further, so a channel with more than 5000 attachment events dropped every later live arrival while the tab said it was showing the most recent attachments. The deletion and edit caps had the same shape, so past them a new deletion no longer hid its file and a new edit no longer rewrote its row. The caps now keep the newest instead. Past a cap, an entry newer than the retention floor — the oldest retained entry — evicts that floor and takes its place; only an entry older than the floor is refused, which is the entry a "keep the newest N" cap exists to leave out. Evicting a message takes its edit and deletion entries with it, so no overlay outlives its row. The retention order is oldest-first with the entry id breaking ties, the same total order the projection sorts into, so an eviction cannot shuffle the page a caller is reading. The caps still bound the entries retained, which is the memory the index costs. Deletions now carry the timestamp of the deletion that removed them, so they can be ordered; an unusable relay timestamp normalises to 0 rather than being retained raw, because a NaN compares false against everything and a retained one would be an entry nothing could displace and a cap nothing could evict from again. Both the refusal and the eviction path set `truncated`, and the banner now names what is missing rather than only that something is. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Z6iidtozXxgx58BUZUKnu Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>
…chment The eviction round bounded the deletion cap by recency alone, so a full cap gave up its oldest deletion entry with no regard for what that entry was doing. A deletion entry is the only thing hiding a deleted row: if its message was still indexed, dropping it put the file back on screen — an attachment its author deleted, returning silently, while the banner talked only about older attachments. Deletions of reactions, edits and ordinary messages all consume the cap, so a channel with a few hundred attachments and thousands of deletion markers reaches this on the next live delete. Eviction now ranks rather than only orders. An entry that still describes a message the index holds outranks one that describes nothing, and within a class the newer outranks the older; the cap gives up the lowest-ranked entry and refuses an arrival only when the arrival is lowest of all. For deletions that means orphan markers go first and a deletion doing real work is never the victim; if only real pairs are left, the message goes with its deletion, so the file is dropped rather than revealed. The edit cap was bounded on the same wrong population: a burst of edits for messages the index never saw could refuse a visible row's genuine edit for good. It now counts edits for retained sources and drops the rest first. Two smaller repairs: forgetting an evicted message now also drops a deletion keyed by the id of the edit that rewrote it, which the projection honours as well; and the truncation banner no longer claims the channel has more attachments than the tab keeps, since the edit and deletion caps set the same flag — it now says the oldest entries are dropped and names what may be missing. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Z6iidtozXxgx58BUZUKnu Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>
Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
feat(desktop): dedicated attachment index for the Files tab with bounded, recency-aware retention (T3b)
Summary
T3b builds a dedicated attachment index over the landed T3 Files tab. Today's
useChannelFilesonly reads the loaded message window(
useChannelMessagesQuery), which is top-level only and 200 messages per page.This ticket adds
channelFilesIndex.ts: open the channel's live subscriptionfirst, then paginate its full history including reply-kind messages, keep only
events carrying
imeta, overlay edits and deletions on top of the retainedset, and expose deterministic pagination over the result. Bulk drag-drop moves
behind a new setting that defaults off, with a batch cap of 20.
This session's own change, on top of that base: the index used to refuse
every new attachment, edit, or deletion once its retention cap (5000 entries
each) was full, so a channel past the cap silently dropped later live arrivals
while the banner claimed "Showing the most recent attachments only." The caps
now evict instead of refuse — recency- and relevance-ranked, not plain
oldest-first, so evicting to make room never un-hides an attachment whose
deletion was the thing evicted, and never permanently loses a genuine edit to
a flood of edits for messages the index never retained.
Fork deviations
From
build.md(fix round) and the repair round, pluspr-inputs.md:PR feat(desktop): add Files tab with folders, bulk select, and drag-and-drop block/buzz#4316 text, and no longer opens with "Showing the most recent
attachments only" because that is false when only the edit or deletion cap
fired.
channel-files-tab.spec.tsdoes not assert the string.FilesIndex.deletionschanged type fromReadonlySet<string>toReadonlyMap<string, number>(deletion event timestamp) so the cap canorder by recency. Exported type change; no consumer outside
channelFilesIndex.tsreads the field,.has/.sizecallers stayedsource-compatible, and
desktop-typecheckis clean.boundEventTimestamp(an unusable relay timestamp normalises to0)so a
NaNcreated_atcannot jam a cap by comparing false againsteverything.
admitNewestrenamedadmitRetainedwith anIsRelevantparameter;oldestRetainedbecamelowestRanked; the source cap passesALWAYS_RELEVANT.source when only real pairs remain) is implemented but currently
unreachable while
MAX_INDEXED_DELETIONS >= MAX_INDEXED_ATTACHMENT_EVENTS;the blind critic forced it with a reduced cap and confirmed it is correct.
It ships without a dedicated test — see follow-up 2.
Defect checklist
(Verbatim from
build.md's last fix round.)boundIndexSource,boundIndexEditcap pubkey, content and imeta parts). This round addsboundEventTimestampfor the one new relay-sourced field the retentionorder reads; test 6 binds it.
is published only at the end of
ingestIndexEvents, and an evicted sourcetakes its edit and deletion with it in the same draft, so no published
snapshot carries an overlay for a row that is gone.
(sources, deletion targets, edits), which is the memory the index costs;
eviction changes which entries are kept, not how many.
build.md; six mutations in thefix round, each caught, plus four more in the repair round, each caught.
the user through
truncated-> the banner, which now names what wasdropped;
ChannelFilesTab.test.mjsasserts that wording.Gates
Tested base OID:
d478fcf4a3c81dfeb7b19276e68333124e610aa1(origin/zs/mainbefore this branch's merge). This PR's tip merges that base in at
3d91302ad65fe948fd77ff2f121a857de60d9431(clean, no conflicts).On
cbfea5a9b(pre-merge tip), all exit 0:just fmt-check clippy desktop-check desktop-tauri-fmt-check desktop-tauri-clippy file-size-checkjust desktop-typecheckjust desktop-test(6521 tests) under the gate lockchannel-files-tab.spec.tsandchannel-files-index.spec.ts(3 passed) under the gate lockdesktop-e2e-smokeon481ed97f0: 1360 passed, four unrelated failures shownflaky or pre-existing on the clean base
(
workflow-local-controls.spec.ts:148reproduces with the diff stashed).After merging
origin/zs/main(93 files changed via the merge, so the fastgates were rerun per the merge policy), on merge commit
3d91302ad6, all exit 0:just fmt-check clippy desktop-check desktop-tauri-fmt-check desktop-tauri-clippy file-size-checkjust desktop-typecheckPre-push hook (full gate suite, this session, first attempt):
push-head-scope,branch-skew,file-size-check,desktop-check,desktop-typecheck,desktop-test,desktop-tauri-checks,rust-tests— all green, including all12 Rust test suites (
buzz-core,buzz-auth,buzz-voice,buzz-cli,buzz-db,buzz-conformance,buzz-push-gateway,buzz-backend-kubernetes,buzz-agent,buzz-acp,buzz-secret-store,buzz-mcp-launch).Gemini 3.8 Flash tester
Ran in an earlier session against this branch; not rerun this session (report
was session-scoped, see the earlier workflow journal,
wf_d128b3c8-44d).Blind critic
Fresh-context Claude blind critic, run twice against this branch:
481ed97f0): FAIL. One BLOCK-class defect: an evicteddeletion entry could un-hide an attachment that was still retained
(
secret.pngreappeared after one newer, unrelated deletion evicted itsdeletion marker), plus follow-ups on the edits cap ranking the wrong
population, an edit-keyed deletion surviving source eviction, and banner
copy false for two of the three caps. Also confirmed: caps still bound
retained entries under a 7000×3 stress ingest; the retained set is exactly
the newest N under any arrival order (shuffled 5500-event probe); pagination
stays deterministic after eviction; a bad relay timestamp cannot jam a cap.
cbfea5a9b, after the repair round): PASS. Notes: bothreproductions from round 1 are dead (
secret.pngstays hidden;orig.pngrenames correctly); under sustained pressure 500/500 deleted files stay
hidden and 2000/2000 new deletions land; the claimed-unreachable
pair-eviction branch verified genuinely unreachable at current cap values;
both new relevance guards are falsifiable. Full report:
critic.mdin thissession's scratch directory (not committed to the repo).
GPT-5.6 Sol audit
Three rounds in the earlier session, ending block-open: a full cap
refused every new attachment/edit/deletion arrival while the banner claimed
recency, which this session's fix round (
481ed97f0) closed for the case Solnamed. This session ran two further rounds (the fix round and the repair
round after the blind critic's round-1 FAIL) plus the blind critic's own
recheck, which is what closes the loop here — no fourth Sol run (the
three-run cap was already reached in the earlier session).
Follow-ups
channelFilesIndex.ts:431-447(new, from the repair round): an editarriving before its message is "irrelevant" at that instant, so at an
edits cap full of relevant edits it is refused and lost; the row later
shows the un-edited version. Needs 5000 edited attachments in one channel;
costs an overlay, exposes nothing.
channelFilesIndex.ts:400-407: the pair-eviction safety net is unreachableonly because
MAX_INDEXED_DELETIONS >= MAX_INDEXED_ATTACHMENT_EVENTSandnothing binds that coupling; add a static assertion or a reduced-cap test.
channelFilesIndex.ts:270-300:lowestRankedis an O(size) scan perarrival once a cap is full (0.53 ms per live deletion at 5000; a
30000-event backfill costs 0.8-0.9 s); bounded by
MAX_BACKFILL_PAGES×page size; an ordered index would make it O(log n).
ChannelFilesTab/e2e: no e2e spec asserts the banner copy.build.md): theO(size)-per-arrival scan was originally described as free ("the backfill
fills once and hits the floor check without a scan"); that claim was wrong
(a refusal scans too) and has been corrected in
build.md— see follow-up3 above for the accepted, measured cost.
Test plan
cd desktop && node --import ./test-loader.mjs --experimental-strip-types --test src/features/channel-files/*.test.mjs— 132 tests, 0 fail.just desktop-typecheck— clean (theFilesIndex.deletionstype change).just fmt-check clippy desktop-check desktop-tauri-fmt-check desktop-tauri-clippy file-size-check— all exit 0.just desktop-testunder the gate lock — 6521 tests, 0 fail.channel-files-tab.spec.tsandchannel-files-index.spec.tsunder the gate lock — 3 passed (seeds 250 attachments incl. a thread reply, asserts sub-500ms over three runs per the plan's bar).just desktop-e2e-smoke— 1360 passed; four failures are flaky/pre-existing on the clean base, not a regression from this diff.🤖 Generated with Claude Code
https://claude.ai/code/session_012Z6iidtozXxgx58BUZUKnu
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.