Conversation
…drop Adds a Files tab to the channel view for browsing and organizing all files shared in a channel. Core Files Tab: - List view showing filename, type, size, dimensions, date, and sender - Category filter tabs: All / Images / Videos / Documents / Other - Search by filename or message caption - Sort by newest, oldest, name, or size - SHA-256 verification badge on hashed files - Message caption (first line of body text) displayed per file - Jump-to-message navigation (click to scroll to original message) Synced File Folders (kind:30078): - Create, delete, and nest folders stored as Nostr replaceable events - Folders sync across team members via the relay - Drag-and-drop files into folders - Drag folders onto other folders to nest them - Drag nested folders to the main area to un-nest - Child folders hidden when parent is collapsed - X button to remove files from folders Bulk Select: - Always-visible checkboxes on every file row - Shift-click for range selection - Bulk action bar with count, Select All, Clear - Move to folder dropdown for bulk organization - Remove from folder for bulk un-filing The tab bar (Chat | Files) sits below the channel header chrome, rendered once as a shared element for both tabs. Signed-off-by: mismai-li <mi@mismai.li> Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>
Pull the imeta-parsing loop and the folder tag-mutation logic out of useChannelFiles/useFileFolders' hook bodies into exported pure functions (parseChannelFiles, buildFileFolderMap, withFileAddedToFolder, etc.). The hooks call these directly, so tests bind to the same production code path instead of a reimplementation. Also fixes drift against the current relayClient/signRelayEvent APIs (publishEvent now takes timeout/error messages, fetchEvents replaces the now-private requestHistory) surfaced by tsc after the port/4316 cherry-pick, awaits every publish before invalidating the folder query so a failed write can't leave a stale cache, and surfaces every publish failure to the user via toast instead of swallowing it. Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>
useChannelFiles.test.mjs covers imeta parsing, the Markdown-file-labeled- by-filename-not-.bin case, newest-first ordering, caption extraction, sortFiles and categorizeFile. useFileFolders.test.mjs covers the folder assignment round trip (create -> parse -> add file -> parse -> remove file -> parse) plus multi-file merge/no-op, rename d-tag changes, parent set/clear, and the file-folder map grouping. channel-files-tab.spec.ts (registered in the smoke allow-list) drives the ported UI end to end through the mock bridge: switches to the Files tab, asserts a Markdown attachment is labeled by its imeta filename rather than its .bin raw URL, asserts a plain message without an attachment is excluded, and follows "Jump to message" back to the Chat tab and the original message. Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>
… DTO parseChannelFiles and parseFolder now cap every relay-sourced string (filename, caption, mime type, folder name, folder d-tag) and every relay-sourced count (imeta attachments parsed from one event, file ids exposed from one folder) at the DTO boundary, so a hostile or buggy event can't hand the Files tab an unbounded string to render or an unbounded list to enumerate. withFilesAddedToFolder now derives its existing-id set from the folder's raw tags rather than the (capped-for-display) fileEventIds field — merging against the truncated view would have treated a pre-existing id past the cap as new, and worse, silently dropped real "e" tags past the cap out of the filtered result. Each cap has a guard test proving both that oversized input is capped and that removing the cap fails the test (verified locally by temporarily reverting the cap constants and confirming three assertions fail with the exact expected message). Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>
Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>
check-pubkey-truncation.mjs flagged a hand-rolled file.pubkey.slice(0, 8) in FileCard.tsx; route through the canonical helper instead. Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>
folderSlug and folderDTag became public API when the folder tag-mutation logic was extracted for testability; give them the doc comments new public API needs. Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>
…header/tab-strip layout Consolidated fix round for T3 (port/4316) review findings: - ChannelFilesTab.tsx: bulk "Remove from folder" looped one publish per selected file from a stale folder reference, so each iteration re-added files the previous one had just removed. Added a bulk primitive (withFilesRemovedFromFolder + removeFilesFromFolder in useFileFolders.ts, onRemoveFilesFromFolder prop) that publishes one kind:30078 event for the whole selection. - ChannelFilesTab.tsx: dragging a folder onto its own descendant only checked for direct self-nest, letting a cyclic parent chain strand both folders and their files. Added wouldCreateFolderCycle (useFileFolders.ts) and use it in the drop handler. - ChannelScreen.tsx: the Files tab strip rendered above wherever the channel header ended up, pushing the header (and, during a huddle transcript, the transcript content) down by the strip's own height. channelHeader now renders once above the tab strip, shared by Chat and Files, hidden during a huddle transcript and the narrow single-panel view exactly as ChannelPane's own prior gate did. The header is a zero-net-height overlay (own negative margin cancels its flow contribution), so the tab strip needed a matching top-clearing margin (channelChrome.clearHeaderMargin, chromeLayout.ts) or it was visually covered by the header, intercepting clicks meant for the Files tab. - useChannelFiles.test.mjs: added the missing revert-verified cap test for MAX_MIME_TYPE_LENGTH. Signed-off-by: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>
wiggdevin
marked this pull request as ready for review
September 5, 2026 03:17
wiggdevin
marked this pull request as draft
September 5, 2026 03:19
This comment has been minimized.
This comment has been minimized.
The Files tab port put its tab strip between the channel header and
ChannelPane. The fork's header is a measured overlay: its chrome wrapper
carries a negative bottom margin sized to its own measured height, and
everything below it re-adds exactly that one measured offset --
MessageTimeline's contentPadding, the sticky day divider's
stickyTimelineTop, and the shared blur band. A strip in between adds a
second, unmeasured offset those all miss, so they stacked below it:
channels.spec.ts:1974 measured the sticky day pill 94px (42px strip +
52px header clearance) under the header bottom instead of 8px.
Conditional offsets cannot fix this. The assertion is
stickyPill.y == chatHeaderBox.bottom + 8, so any real flow height
between the header box and the timeline fails by that height. The strip
has to stop being a sibling of the header.
ChatHeader gains an optional `tabs` slot rendered inside the header
element, under the title row, so the strip grows the header's own border
box. `py-2` moves onto a new inner row that carries chromeWrapperRef, so
the measured height stays the title row alone while the header box grows;
<header> keeps its px-5, so its inline padding is unchanged. ChannelScreen
hands the strip to the header, restores header={channelHeader} on
GuardedChannelPane, and gives the Files tab the same header element plus
one contentPadding wrapper. channelChrome.clearHeaderMargin is deleted --
nothing clears a header it no longer sits below, and chromeLayout.ts is
back to its zs/main contents.
The header is now hidden in the single-panel view only while Chat is
active: the Files tab renders no auxiliary panel, so hiding its header
there would strand the user with no way back to Chat.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>
A replaceable write that loses a same-second race is acknowledged with a successful OK whose message reads `duplicate:`, so "the publish promise resolved" is not "the update landed". Thread an optional `onOk` callback through publishEvent so a caller that maintains a replaceable aggregate can tell an applied write from a superseded one; every existing caller is unchanged. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>
Replaces the one-replaceable-event-per-folder wire format with a single snapshot per (user, channel): immutable random folder ids, names as data, and file assignments in one object, so a rename is a field edit, a delete cascades inside one write, and a move is one assignment change with exactly one owner per file at every instant. Every bound sits on the quantity that costs and is checked before the payload is materialised: the byte budget runs before JSON.parse, then folder count, assignment count, name length, and nesting depth. An over-limit, cyclic, or orphaned payload is rejected as invalid rather than truncated into something that reads as complete. Pure module with no relay, query-client, or DOM dependency, so each bound and each transform is bound by a unit test that fails if the guard goes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>
… saved The folder hook now reads and writes one encrypted kind:30078 aggregate per (user, channel), addressed by a `d` tag that hashes the channel id instead of embedding it, fetched by that exact coordinate rather than an arbitrary capped scan of the author's app-data events. Folder names, topology, and file references no longer travel as plaintext tags. Every mutation runs through a per-channel queue: it re-reads the head, applies a pure transform to it, signs with a strictly monotonic created_at, publishes, and only then updates the cache. A superseded OK is replayed once against a re-read head and otherwise surfaced as an error, so a dropped write can never be reported as a saved change. A failed read is an error state with a refetch, not an authoritative empty one, and an unreadable payload marks the state invalid and blocks mutation instead of letting a write build on a partial view. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>
…fter it The shared imeta parser walks every tag and part of an event and builds the whole map before any caller-side cap runs, and the Files tab projects every loaded message, so that scan was the hot path. A dedicated bounded parser stops after 20 attachments, limits the tags and parts it reads, validates URL scheme and length, hash grammar, size range, and dimension format before storing anything, and keeps only the capped copies rather than the raw parsed entry. Captions come from a bounded content prefix. Rows now carry a per-attachment key (message id plus a digest of the URL) so two attachments on one message are two addressable rows, and the projection caps its total row count and reports truncation instead of presenting a partial list as the whole set. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>
Rows, selection, drag payloads and folder membership are all keyed by the per-attachment file key, so selecting or dragging one attachment no longer takes its siblings on the same message with it. Selection is now a mode the user turns on: outside it the filename and thumbnail links navigate, and inside it Shift extends the range through both the pointer and the keyboard path. The per-row action cluster reveals on focus as well as hover, so keyboard focus never lands on an invisible control. Folder rows expose aria-expanded and carry a "Move to…" select, including Root, so nesting and un-nesting are reachable without a pointer. Every folder mutation goes through one helper that marks its control pending, awaits the result, and catches — a second click while a write is in flight is a no-op, and no rejection escapes unhandled. A failed file load renders a retryable error distinct from "No files yet", a broken folder payload is surfaced and disables every folder control, and the list virtualizes past 60 rows so a heavily paged channel cannot mount an unbounded DOM. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>
…etry test Extracts the Chat/Files strip into its own component implementing the ARIA Tabs pattern — roving tabIndex so the strip is one Tab stop, Arrow/Home/End to move between tabs, and explicit aria-controls and aria-labelledby links to the mounted panel. The strip still rides inside the header box, so the measured title-row height that every downstream offset clears is unchanged. The Files projection is now gated on the tab having been opened for the channel, so a Chat-only session never re-parses the whole loaded message window on each incoming live message. The e2e spec asserts the stacking order geometrically — title above tabs, tabs above the first row, no horizontal document overflow — at the default width and again narrow, so removing the measured header ref or the Files padding fails the test instead of shipping. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>
Extracts the "has this channel's Files tab been opened" gate into a pure projection so its false branch is testable, and adds falsifiable tests for the row-count threshold that turns virtualization on, the plain render below it, and the focus-within reveal on the row action cluster. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>
…nd one A publish that reaches the relay and is then re-sent after a socket failure comes back accepted with `duplicate:`, so `isSupersededOk` is true for a write that actually landed. The retry then replayed the transform against a head that already held the change: `createFolder` minted a fresh id inside the retryable transform and published the same folder twice, and `deleteFolder` told the user "That folder no longer exists." about a delete that had committed. Mint the folder id once, before `runMutation`, so the transform is the pure snapshot-to-snapshot function the retry loop assumes. After a superseded OK, force-read the head and treat `head.id === event.id` as committed. Make `withFolderCreated` idempotent by id for the narrow case where another device writes on top of our committed event before its acknowledgement arrives. The harness now models a committed-then-acknowledged-as-duplicate OK; all four new tests fail with the guards removed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>
… ones `MAX_IMETA_TAGS_SCANNED` bounded the wrong quantity: the counter was incremented only after a non-imeta tag had already been skipped, so the 64-tag budget never bounded the walk its own doc comment describes. A 512 KiB relay frame (`crates/buzz-relay/src/config.rs:14`) holds tens of thousands of minimal tags, and the Files tab re-projects every loaded event on each incoming live message once the tab has been opened. Bound the loop by its index so every tag the scan touches spends the budget, and fold the `Array.isArray` guard for a malformed tag into the same line. The new test counts index reads through a proxy and fails against the old loop. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>
Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>
wiggdevin
marked this pull request as ready for review
September 5, 2026 06:18
wiggdevin
enabled auto-merge
September 5, 2026 06:19
This comment has been minimized.
This comment has been minimized.
The Files tab port moved chromeWrapperRef off the header's outer chrome wrapper and onto a new inner title row, so --buzz-channel-content-top-padding measured the title row alone while the wrapper grew by the 42px tab strip. That variable is also the wrapper's own negative bottom margin, so the header stopped cancelling its own height: it contributed 42px of real flow and pushed every sibling in the channel column down by that much. Offsets built on the variable stayed correct -- column top + (header height - var) + var is header height either way -- which is why the sticky day divider and the Files row stacking still passed. What broke is anything sized to the column rather than offset inside it: the Chat column's drop-zone overlay is `absolute inset-0` in that shifted sibling, so it came out 42px shorter than the drop zone it must cover. file-attachment.spec.ts:450 caught it on Desktop Smoke E2E shard 2, where the overlay measured y=83/h=629 against a drop zone of y=41/h=671. chromeWrapperRef goes back on the outer wrapper, so the measured height is the whole header box again and the margin cancels it exactly. The inner py-2 row stays -- it is what keeps `tabs` flush under the title row and leaves <header>'s own inline padding alone. Every downstream offset is unchanged by construction; only the column-sized children move back. Tests: channel-files-tab.spec.ts gains a case that ties the tab strip to the Chat column -- the measured variable must equal the header box, the drop overlay must cover the drop zone edge to edge, and the drop must still reach the composer. ChannelFilesTab.test.mjs gains the Files-tab half, dragging a file row onto a folder row through the real dragstart/dragover/drop handlers (also closes a recorded follow-up). Both fail with chromeWrapperRef back on the inner row. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>
This was referenced Sep 5, 2026
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): port channel Files tab from upstream block#4316
Summary
Ports upstream PR block#4316 (
mismai-li:feat/channel-files-tab) intodesktop/src/features/channel-files/: a Files tab on the channel screen listing thechannel's attachments, with folders, bulk select, and drag-and-drop, alongside a shared
ChannelScreen.tsxtab strip. Upstream shipped no tests; this port adds unit coverage(
useChannelFiles.test.mjs,useFileFolders.test.mjs,folderStore.test.mjs,ChannelFilesTab.test.mjs,ChannelTabStrip.test.mjs) and an e2e spec(
channel-files-tab.spec.ts, registered in the smoke allow-list), plus a header/tab-striplayout fix so the tab strip sits inside the channel header box instead of pushing the header
down and stacking a second offset underneath it (
channels.spec.tssticky-divider regression,now closed).
D1 — Folders v2 is a deliberate wire-format change from upstream, not a bug. Upstream's
PR stores one kind-30078 event per folder, in plaintext, keyed by the raw channel id. That
model failed five separate findings in review (plaintext metadata and channel id on the
relay, unbounded
#d/limitqueries, mutable folder ids, multi-write renames and deletes,one shared key across all a user's attachments). Per
fix-spec.mdD1, this port replaces itbefore any folder event has ever been published (upstream block#4316 is unmerged, so there is
nothing to migrate): one kind-30078 event per
(user, channel),d = files-v2-<sha256(channelId)[0:32]>(never the raw channel id),t = file-folders, contentNIP-44-encrypted to the user's own key the same way
readStatedoes it, payload{v:1, folders:[{id, name, parent}], files:{<fileKey>: folderId}}. D2–D6 (queued single-writemutations with inspected publish results, parse-time bounds, a bounded imeta parser, ARIA
tabs, and production-seam tests) are also part of this port, not follow-on work — see
fix-spec.mdfor the full decision record.A consolidated two-round fix pass then closed every verified BLOCK from the review loop
(bulk-select races, folder-cycle bugs, the header/tab-strip stacking bug, an impure folder-create
transform that could duplicate or lose a folder on a relay-level retry, and an imeta budget that
bounded the wrong quantity) — see Defect checklist and the GPT-5.6 Sol audit below for what
is fixed and what remains open as a WARN.
Fork deviations
folderStore.tswithFolderCreatedreturnsthe snapshot unchanged when the folder id already exists. Making the transform pure
introduced this narrow torn-state case — our write commits, another device writes on top
of it before our duplicate OK arrives, the re-read head already holds our folder and
head.id != event.id, so the replay would append a duplicate id. Six lines, one unit test.git checkoutafteran early checkout reverted the in-progress
useFileFolders.tsedit; the edit wasre-applied and verified in the committed diff. No history was rewritten and nothing was
pushed.
Defect checklist
boundedImeta.tsalready capsurl, filename, mime, dim, blurhash, caption, size and attachments-per-event, each with a
test in
useChannelFiles.test.mjs. This round supplied the missing count half (thetag-scan bound) with its own test.
exactly one publish; this round removes the only path that turned one user action into two
writes (replay after a committed duplicate OK), and the idempotent create means a replay
cannot leave two folders under one id.
which is what the per-message main-thread projection pays for, not the count of imeta tags
found.
and the suite rerun: head-id check → 2 failures, id hoist → 3, scan bound → 1, idempotent
create → 1. All restored; final tree is clean and green.
landed (head id equals the signed event id), replays once, or throws "Another device
changed these folders at the same time." into the caller and its toast. The new early
return is a proven success, not a silenced error.
Gates
Tested base OID (fix round 2, builder, pre-merge):
8fea88f61db26853496fd740df4c08ef720fc28b(
origin/zs/main).Fix round 2 (builder, commits
eecf43c2a,d45bfeaea):just desktop-checkjust desktop-typecheckjust file-size-checkjust desktop-testnode --test src/features/channel-files/*.test.mjspnpm exec playwright test --project=smoke tests/e2e/channel-files-tab.spec.tsdesktop/src-tauri/Cargo.lockuntouched this round; no Rust dependency added.PR-updater gates (this session, worktree
port-4316), tested base OIDa9a529f2c992912f3fb0d8a94ea88f34bf0d5fc5(origin/zs/main, fetched fresh):git merge --signoff --no-edit origin/zs/main011309a82.merged_base=true. Brought in 56 unrelated files (agent prompt-source, MCP registry plan docs, OpenSEO smoke scripts) plus a Cargo.lock update from those branches' own dependency changes — none touchchannel-files/channels/ChannelScreen.git rev-list origin/zs/main..HEAD× grepSigned-off-by)1ce91cf58carries an extra modelSigned-off-by, left as instructed).just desktop-checkinboxReopenNavigation.test.mjs,channelMutesStorage.test.mjs,channelStarsStorage.test.mjs,terminal.css,badge.spec.ts,project-pr-review.spec.ts).just desktop-typecheckjust desktop-testjust file-size-checknode --import ./test-loader.mjs --experimental-strip-types --test src/features/channel-files/*.test.mjspnpm exec playwright test --project=smoke tests/e2e/channel-files-tab.spec.tsgit push origin port/4316(pre-push hook suite:push-head-scope,branch-skew,file-size-check,desktop-check,desktop-typecheck,rust-tests,desktop-test,desktop-tauri-checks)origin/port/4316verified== HEAD(011309a82) after push.desktop/src-tauri/Cargo.lockcarries only the merge's own legitimate changes (new deps fromthe merged branches); no gate in this session modified it further, so nothing was restored.
Gemini 3.8 Flash tester
dragstart/dragover/drop) onFileRowand folderdrop zones in
ChannelFilesTab.test.mjs.ChannelFilesTab.test.mjs.channel-files-tab.spec.ts.scratchpad/wave2/port-4316/gemini-report.md(session-local path)GPT-5.6 Sol audit
Run 1 (effort
xhigh, full pass againstorigin/zs/main...HEAD): 12 BLOCK / 6 WARN, all 18findings independently CONFIRMED by 18 separate verifiers.
Delta 1 (
verdict: OPEN) — 2 BLOCK + 5 WARN still open after the first fix round:useFileFolders.ts:241—createFolder's transform was not pure:newFolderId()ran inside the retryable transform, so a superseded-OK replay (a socketresend the relay answers
duplicate:after it already committed) minted a second randomid and published a duplicate folder while telling the user one action succeeded; the
symmetric delete case reported a committed delete as "That folder no longer exists."
boundedImeta.ts:77—MAX_IMETA_TAGS_SCANNEDbounded the wrong quantity: thecounter only incremented after
tag[0] !== "imeta"had alreadycontinued, so the declared64-tag budget never bounded the scan; a channel member could post a few hundred tag-stuffed
events and drive tens of millions of main-thread iterations per incoming message once the
Files tab had been opened.
useFileFolders.ts:70— folder-head read validated onlyevent.pubkey, no kind/d/tcheck.boundedImeta.ts:74—tag[0]indexed with noArray.isArray(tag)guard; a malformed tag could crash the render instead of hitting the new retryable error state.ChannelScreen.tsx:236— first Files-tab open could paint an authoritative "No files yet" for a frame.ChannelFilesTab.test.mjs:59— no test bound the component's handlers touseFileFolders(fix-spec D6).useFileFolders.ts:77— relay-supplied ciphertext reachednip44DecryptFromSelfwith no pre-decrypt length bound.Delta 2 (
verdict: OPEN, run after fix round 2 / commitseecf43c2a,d45bfeaea) — bothBLOCKs are resolved; 8 WARNs open, none blocking:
useFileFolders.ts:64— carried: folder head still selected onevent.pubkeyalone, no kind/d/tcoordinate check (not reachable from any client in this repo).ChannelScreen.tsx:237— carried:filesTabOpenedis set from auseEffectkeyed onactiveTab, so the first Files render can still paint "No files yet" for a frame.ChannelFilesTab.test.mjs:268— carried (fix-spec D6): still no test drives the realuseFileFolderscallbacks through the component; a no-op atChannelScreen.tsx:1117would leave every suite green.useFileFolders.ts:77— carried: unbounded relayhead.contentstill reachesnip44DecryptFromSelfbefore any size check; not a regression, every NIP-78 reader in this repo does the same.useFileFolders.ts:186— new: the conflict "force read" (readHead(true)) can be served by an in-flightqueryClient.fetchQueryrefetch instead of a fresh read, so a retry can rebuild on a pre-conflict head and publish over another device's write.FileCard.tsx:232— new: the imetaxhash is accepted on shape alone and never checked against the downloaded bytes, yet the UI renders a green "SHA-256 verified" badge.useChannelFiles.ts:117— new (absorbs the WARN-2 residual): a non-stringevent.contentor non-array tag throws inside the projection'suseMemoduring render, escaping the retryable error state this round added; reachable only from a hostile/non-conforming relay.ChannelFilesTab.tsx:334— new:handleFolderDropreadstext/plainfrom any drag source with no membership check, so a crafted external drag can persist an assignment for a file key that doesn't exist, spending the assignment budget.Final state: no verified BLOCK remains open. Per the loop's severity rubric, verified WARNs
outside the BLOCK list are follow-ups recorded below and on the fork, not a loop condition —
this PR is ready for the merge queue on that basis.
Follow-ups
useFileFolders.ts:64— bind the folder-head read tochannelFolderDTag(channelId)+t=file-folders, notevent.pubkeyalone (Sol WARN, carried).ChannelScreen.tsx:237— setactiveTabandfilesTabOpenedtogether in the tab-select handler instead of an effect, to close the first-open "No files yet" flash (Sol WARN, carried).ChannelFilesTab.test.mjs:268— add the fix-spec D6 seam test: realuseFileFolderswired to the component, signing/publish mocked, asserting publish count and order for a drop and a bulk move (Sol WARN, carried).useFileFolders.ts:77— rejecthead.contentabove the max NIP-44 ciphertext expansion before callingnip44DecryptFromSelf(Sol WARN, carried).useFileFolders.ts:186—await queryClient.cancelQueries({ queryKey })(or bypass the cache andsetQueryDatadirectly) before a conflict force-read, so it can't join an in-flight pre-conflict refetch (Sol WARN, new).FileCard.tsx:232— relabel the badge "SHA-256 provided by sender" or remove it until downloaded bytes are actually hashed and compared (Sol WARN, new).useChannelFiles.ts:117— validateid/pubkey/contentas strings andtagsas an array at the projection boundary, skip the malformed row (Sol WARN, new).ChannelFilesTab.tsx:334— carry file/folder identity on a private drag MIME type and check the key against the current row set before callingonAssignFiles(Sol WARN, new).dragstart/dragover/drop) coverage onFileRowand folder drop zones inChannelFilesTab.test.mjs(tester missing-test).ChannelFilesTab.test.mjs(tester missing-test).channel-files-tab.spec.ts(tester missing-test).feat/files-index): dedicated attachment index and bulk-upload-default change, scoped separately per the implementation plan.d-tag hides the channel id from a Block-hosted relay by design; a self-hosted relay could instead enforce channel membership server-side).Test plan
cd desktop && node --import ./test-loader.mjs --experimental-strip-types --test src/features/channel-files/*.test.mjs— 65/65 pass.just desktop-check desktop-typecheck desktop-test file-size-check— all exit 0;desktop-test6390/6390.pnpm exec playwright test --project=smoke tests/e2e/channel-files-tab.spec.ts(fromdesktop/) — 1/1 pass.git rev-list origin/zs/main..HEADDCO check — no commit missingSigned-off-by.gh pr ready 11) is the authoritative full gate, including the full e2e smoke suite and the Rust test/clippy/fmt lanes this session did not rerun locally beyond the pre-push hook's own pass.🤖 Generated with Claude Code
https://claude.ai/code/session_01E51uwemNnQ6wdrBWU9EhPE
Round 3 (CI regression)
CI run
33949461538, Desktop Smoke E2E shard 2, failedtests/e2e/file-attachment.spec.ts:450 › dropping a file on the channel column attaches it to the composeron all three attempts in ~1.0s each — a deterministiclayout failure, not the shard's known timeout flake (
custom-emoji.spec.ts:370andmentions.spec.ts:3051each failed once at ~16s and passed on retry; those areuntouched here).
Cause
desktop/src/features/chat/ui/ChatHeader.tsx:206(was:147). Commit64c7f9dfcmoved
chromeWrapperRefoff the header's outer chrome wrapper and onto a new innertitle row, so
--buzz-channel-content-top-paddingmeasured the title row alone (52px)while the wrapper grew by the 42px tab strip (94px). That variable is also the
wrapper's own negative bottom margin (
channelChrome.negativeMargin), so the headerstopped cancelling its own height: it contributed 42px of real flow and pushed every
sibling in the channel column down by that much.
Offsets built on the variable stayed correct — column top + (header height − var) +
var is header height whatever
varis — which is exactly why the sticky day divider(
channels.spec.ts:1974) and the Files-row stacking assertions the port added stillpassed, and why the regression got through review. What broke is anything sized to the
column rather than offset inside it. The Chat column's drop overlay is
absolute inset-0inside that shifted sibling (ChannelPane.tsx:806, rendered insidethe
relative isolatecontent div atChannelPane.tsx:615), so it came out 42pxshorter than the
channel-drop-zonesection it must cover:overlay
y=83 h=629against drop zoney=41 h=671.Reproduced locally at the identical numbers, and confirmed passing on a detached
origin/zs/mainworktree (file-attachment.spec.tsis untouched by this branch, sothe spec's selectors are correct for a layout the ticket did not intend to change).
Fix
chromeWrapperRefgoes back on the outer chrome wrapper, so the measured height is thewhole header box — tab strip included — and the negative margin cancels it exactly. The
header is a zero-flow overlay again, as on
zs/main. The innerpy-2row stays: it iswhat keeps
tabsflush under the title row instead of below a stale 8px gap, and itleaves
<header>'s ownpx-5inline padding alone for the header-action gapassertions.
Every downstream offset is unchanged by construction (the column-top/var trade above),
so the sticky divider, timeline padding, Files-tab content padding, and the shared blur
band all measure the same as before; only the column-sized children move back. The
Chat tab's drop-to-attach path is byte-for-byte
zs/main— no handler, noz-index,no
preventDefaultmoved — and the Files tab's folder drop zones are untouched andstill fire only while the Files tab is the mounted panel.
Tests
desktop/tests/e2e/channel-files-tab.spec.ts— new case "the tab strip grows theheader box without shrinking the Chat column": asserts the measured chrome variable
equals the
chat-headerbox while the strip is rendered, that the drop overlay coverschannel-drop-zoneedge to edge, and that the drop still reaches the composer, allwith Chat as the selected tab. This binds the strip to the Chat column, which
file-attachment.spec.ts(which never mentions tabs) does not.desktop/src/features/channel-files/ChannelFilesTab.test.mjs— new case "dragging afile row onto a folder row assigns that one file to the folder": drives the real
dragstart→dragover→drophandlers onFileRowand the folder row through aDataTransferstub, asserting the drag carries the attachment key andonAssignFilesreceives exactly that one key. Also closes the recorded follow-up "add HTML5
drag-and-drop event wiring coverage on
FileRowand folder drop zones".Falsification: with
chromeWrapperRefput back on the inner row, both new assertionsfail alongside
file-attachment.spec.ts:450; restored, all three pass.Gates
Tested base OID:
011309a82(the round-2 merge commit). Head7ff396e2b.playwright test tests/e2e/file-attachment.spec.ts --project=smoke -g "dropping a file on the channel column" --repeat-each=5playwright test tests/e2e/channel-files-tab.spec.ts --project=smokeplaywright test channels.spec.ts file-attachment.spec.ts channel-files-tab.spec.ts timeline-no-shift.spec.ts --project=smokejust desktop-checkjust desktop-typecheckjust desktop-testjust file-size-checknode --import ./test-loader.mjs --experimental-strip-types --test src/features/channel-files/*.test.mjsgit push origin port/4316(pre-push suite)origin/port/4316verified== HEAD(7ff396e2b)Not verified locally: a full local
playwright test --project=smokerun is not ausable gate on this machine — it reported 418 failures spread across nearly every spec
file, and the same specs pass in isolation (
community-rail.spec.ts+smoke.spec.ts:57/58, the one failure a 5s-budget timing flake in an unrelated keyboard-reorder case).
Local runs get Playwright's 5s expect budget and no retries, where CI uses 15s and two;
CI remains the authoritative full-suite gate, as in earlier rounds. Rust lanes were not
rerun beyond the pre-push hook's own pass.