Skip to content

feat(desktop): port channel Files tab from upstream #4316 - #11

Merged
wiggdevin merged 20 commits into
zs/mainfrom
port/4316
Sep 5, 2026
Merged

wiggdevin merged 20 commits into
zs/mainfrom
port/4316

Conversation

@wiggdevin

@wiggdevin wiggdevin commented Sep 5, 2026

Copy link
Copy Markdown

feat(desktop): port channel Files tab from upstream block#4316

Summary

Ports upstream PR block#4316 (mismai-li:feat/channel-files-tab) into
desktop/src/features/channel-files/: a Files tab on the channel screen listing the
channel's attachments, with folders, bulk select, and drag-and-drop, alongside a shared
ChannelScreen.tsx tab 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-strip
layout 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.ts sticky-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/limit queries, mutable folder ids, multi-write renames and deletes,
one shared key across all a user's attachments). Per fix-spec.md D1, this port replaces it
before 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, content
NIP-44-encrypted to the user's own key the same way readState does it, payload {v:1, folders:[{id, name, parent}], files:{<fileKey>: folderId}}. D2–D6 (queued single-write
mutations 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.md for 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

  • Added one change the findings did not name: folderStore.ts withFolderCreated returns
    the 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.
  • Falsification method: guards were removed with file copies rather than git checkout after
    an early checkout reverted the in-progress useFileFolders.ts edit; the edit was
    re-applied and verified in the committed diff. No history was rewritten and nothing was
    pushed.

Defect checklist

  1. Cap relay/user strings at the DTO and test the cap — MET. boundedImeta.ts already caps
    url, 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 (the
    tag-scan bound) with its own test.
  2. Order multi-step writes so every prefix is consistent — MET. A folder mutation is still
    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.
  3. Gates bound the quantity that costs — MET by finding 2: the budget now bounds tags walked,
    which is what the per-message main-thread projection pays for, not the count of imeta tags
    found.
  4. A test that fails when each guard is removed — MET. Each of the four guards was removed
    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.
  5. No swallowed failure — MET. The superseded path either returns success on proof the write
    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):

Command Exit Notes
just desktop-check 0
just desktop-typecheck 0
just file-size-check 0
just desktop-test 0 6353/6353
node --test src/features/channel-files/*.test.mjs 0 65/65
pnpm exec playwright test --project=smoke tests/e2e/channel-files-tab.spec.ts 0 1/1

desktop/src-tauri/Cargo.lock untouched this round; no Rust dependency added.

PR-updater gates (this session, worktree port-4316), tested base OID
a9a529f2c992912f3fb0d8a94ea88f34bf0d5fc5 (origin/zs/main, fetched fresh):

Command Exit Notes
git merge --signoff --no-edit origin/zs/main 0 Clean merge, no conflicts; merge commit 011309a82. 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 touch channel-files/channels/ChannelScreen.
DCO check (git rev-list origin/zs/main..HEAD × grep Signed-off-by) 0 Nothing missing (known older commit 1ce91cf58 carries an extra model Signed-off-by, left as instructed).
just desktop-check 0 4 pre-existing warnings / 5 pre-existing infos, all in files this branch never touched (inboxReopenNavigation.test.mjs, channelMutesStorage.test.mjs, channelStarsStorage.test.mjs, terminal.css, badge.spec.ts, project-pr-review.spec.ts).
just desktop-typecheck 0
just desktop-test 0 6390/6390
just file-size-check 0
node --import ./test-loader.mjs --experimental-strip-types --test src/features/channel-files/*.test.mjs 0 65/65
pnpm exec playwright test --project=smoke tests/e2e/channel-files-tab.spec.ts 0 1/1
git 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) 0 950.9s total; origin/port/4316 verified == HEAD (011309a82) after push.

desktop/src-tauri/Cargo.lock carries only the merge's own legitimate changes (new deps from
the merged branches); no gate in this session modified it further, so nothing was restored.

Gemini 3.8 Flash tester

  • Verdict: PASS
  • Repro: none
  • Dirty files after run: none
  • Missing tests noted (follow-ups, not FAILs):
    1. HTML5 drag-and-drop event wiring (dragstart/dragover/drop) on FileRow and folder
      drop zones in ChannelFilesTab.test.mjs.
    2. Bulk toolbar actions (Select all, Clear, Move-to-folder dropdown) in
      ChannelFilesTab.test.mjs.
    3. E2E folder creation and file assignment against the mock bridge in
      channel-files-tab.spec.ts.
  • Report: scratchpad/wave2/port-4316/gemini-report.md (session-local path)

GPT-5.6 Sol audit

Run 1 (effort xhigh, full pass against origin/zs/main...HEAD): 12 BLOCK / 6 WARN, all 18
findings independently CONFIRMED by 18 separate verifiers.

Delta 1 (verdict: OPEN) — 2 BLOCK + 5 WARN still open after the first fix round:

  • BLOCK useFileFolders.ts:241createFolder's transform was not pure:
    newFolderId() ran inside the retryable transform, so a superseded-OK replay (a socket
    resend the relay answers duplicate: after it already committed) minted a second random
    id 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."
  • BLOCK boundedImeta.ts:77MAX_IMETA_TAGS_SCANNED bounded the wrong quantity: the
    counter only incremented after tag[0] !== "imeta" had already continued, so the declared
    64-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.
  • WARN useFileFolders.ts:70 — folder-head read validated only event.pubkey, no kind/d/t check.
  • WARN boundedImeta.ts:74tag[0] indexed with no Array.isArray(tag) guard; a malformed tag could crash the render instead of hitting the new retryable error state.
  • WARN ChannelScreen.tsx:236 — first Files-tab open could paint an authoritative "No files yet" for a frame.
  • WARN ChannelFilesTab.test.mjs:59 — no test bound the component's handlers to useFileFolders (fix-spec D6).
  • WARN useFileFolders.ts:77 — relay-supplied ciphertext reached nip44DecryptFromSelf with no pre-decrypt length bound.

Delta 2 (verdict: OPEN, run after fix round 2 / commits eecf43c2a, d45bfeaea) — both
BLOCKs are resolved; 8 WARNs open, none blocking:

  • WARN useFileFolders.ts:64 — carried: folder head still selected on event.pubkey alone, no kind/d/t coordinate check (not reachable from any client in this repo).
  • WARN ChannelScreen.tsx:237 — carried: filesTabOpened is set from a useEffect keyed on activeTab, so the first Files render can still paint "No files yet" for a frame.
  • WARN ChannelFilesTab.test.mjs:268 — carried (fix-spec D6): still no test drives the real useFileFolders callbacks through the component; a no-op at ChannelScreen.tsx:1117 would leave every suite green.
  • WARN useFileFolders.ts:77 — carried: unbounded relay head.content still reaches nip44DecryptFromSelf before any size check; not a regression, every NIP-78 reader in this repo does the same.
  • WARN useFileFolders.ts:186 — new: the conflict "force read" (readHead(true)) can be served by an in-flight queryClient.fetchQuery refetch instead of a fresh read, so a retry can rebuild on a pre-conflict head and publish over another device's write.
  • WARN FileCard.tsx:232 — new: the imeta x hash is accepted on shape alone and never checked against the downloaded bytes, yet the UI renders a green "SHA-256 verified" badge.
  • WARN useChannelFiles.ts:117 — new (absorbs the WARN-2 residual): a non-string event.content or non-array tag throws inside the projection's useMemo during render, escaping the retryable error state this round added; reachable only from a hostile/non-conforming relay.
  • WARN ChannelFilesTab.tsx:334 — new: handleFolderDrop reads text/plain from 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 to channelFolderDTag(channelId) + t=file-folders, not event.pubkey alone (Sol WARN, carried).
  • ChannelScreen.tsx:237 — set activeTab and filesTabOpened together 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: real useFileFolders wired to the component, signing/publish mocked, asserting publish count and order for a drop and a bulk move (Sol WARN, carried).
  • useFileFolders.ts:77 — reject head.content above the max NIP-44 ciphertext expansion before calling nip44DecryptFromSelf (Sol WARN, carried).
  • useFileFolders.ts:186await queryClient.cancelQueries({ queryKey }) (or bypass the cache and setQueryData directly) 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 — validate id/pubkey/content as strings and tags as 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 calling onAssignFiles (Sol WARN, new).
  • Add HTML5 drag-and-drop event wiring (dragstart/dragover/drop) coverage on FileRow and folder drop zones in ChannelFilesTab.test.mjs (tester missing-test).
  • Add bulk toolbar action coverage (Select all, Clear, Move-to-folder dropdown) in ChannelFilesTab.test.mjs (tester missing-test).
  • Add an e2e case for folder creation and file assignment against the mock bridge in channel-files-tab.spec.ts (tester missing-test).
  • T3b (feat/files-index): dedicated attachment index and bulk-upload-default change, scoped separately per the implementation plan.
  • T3c: relay-side channel scoping for folder events, if the team ever self-hosts a relay (Folders v2's 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-test 6390/6390.
  • pnpm exec playwright test --project=smoke tests/e2e/channel-files-tab.spec.ts (from desktop/) — 1/1 pass.
  • git rev-list origin/zs/main..HEAD DCO check — no commit missing Signed-off-by.
  • CI on Blacksmith (triggered 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, failed
tests/e2e/file-attachment.spec.ts:450 › dropping a file on the channel column attaches it to the composer on all three attempts in ~1.0s each — a deterministic
layout failure, not the shard's known timeout flake (custom-emoji.spec.ts:370 and
mentions.spec.ts:3051 each failed once at ~16s and passed on retry; those are
untouched here).

Cause

desktop/src/features/chat/ui/ChatHeader.tsx:206 (was :147). Commit 64c7f9dfc
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 (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 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 whatever var is — which is exactly why the sticky day divider
(channels.spec.ts:1974) and the Files-row stacking assertions the port added still
passed, 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-0 inside that shifted sibling (ChannelPane.tsx:806, rendered inside
the relative isolate content div at ChannelPane.tsx:615), so it came out 42px
shorter than the channel-drop-zone section it must cover:
overlay y=83 h=629 against drop zone y=41 h=671.

Reproduced locally at the identical numbers, and confirmed passing on a detached
origin/zs/main worktree (file-attachment.spec.ts is untouched by this branch, so
the spec's selectors are correct for a layout the ticket did not intend to change).

Fix

chromeWrapperRef goes back on the outer chrome wrapper, so the measured height is the
whole 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 inner py-2 row stays: it is
what keeps tabs flush under the title row instead of below a stale 8px gap, and it
leaves <header>'s own px-5 inline padding alone for the header-action gap
assertions.

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, no z-index,
no preventDefault moved — and the Files tab's folder drop zones are untouched and
still 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 the
    header box without shrinking the Chat column"
    : asserts the measured chrome variable
    equals the chat-header box while the strip is rendered, that the drop overlay covers
    channel-drop-zone edge to edge, and that the drop still reaches the composer, all
    with 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 a
    file row onto a folder row assigns that one file to the folder"
    : drives the real
    dragstartdragoverdrop handlers on FileRow and the folder row through a
    DataTransfer stub, asserting the drag carries the attachment key and onAssignFiles
    receives exactly that one key. Also closes the recorded follow-up "add HTML5
    drag-and-drop event wiring coverage on FileRow and folder drop zones".

Falsification: with chromeWrapperRef put back on the inner row, both new assertions
fail alongside file-attachment.spec.ts:450; restored, all three pass.

Gates

Tested base OID: 011309a82 (the round-2 merge commit). Head 7ff396e2b.

Command Exit Notes
playwright test tests/e2e/file-attachment.spec.ts --project=smoke -g "dropping a file on the channel column" --repeat-each=5 0 5/5
playwright test tests/e2e/channel-files-tab.spec.ts --project=smoke 0 2/2
playwright test channels.spec.ts file-attachment.spec.ts channel-files-tab.spec.ts timeline-no-shift.spec.ts --project=smoke 0 113/113 — the geometry-sensitive set, incl. the sticky-divider assertions
just desktop-check 0 same 4 pre-existing warnings / 5 infos, none in files this branch touches
just desktop-typecheck 0
just desktop-test 0 6391/6391
just file-size-check 0
node --import ./test-loader.mjs --experimental-strip-types --test src/features/channel-files/*.test.mjs 0 66/66
git push origin port/4316 (pre-push suite) 0 188.4s; origin/port/4316 verified == HEAD (7ff396e2b)

Not verified locally: a full local playwright test --project=smoke run is not a
usable 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.

mismai-li and others added 8 commits September 4, 2026 16:09
…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
wiggdevin marked this pull request as ready for review September 5, 2026 03:17
@wiggdevin
wiggdevin marked this pull request as draft September 5, 2026 03:19
@blacksmith-sh

This comment has been minimized.

wiggdevin and others added 11 commits September 4, 2026 20:51
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
wiggdevin marked this pull request as ready for review September 5, 2026 06:18
@wiggdevin
wiggdevin enabled auto-merge September 5, 2026 06:19
@blacksmith-sh

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>
@wiggdevin
wiggdevin added this pull request to the merge queue Sep 5, 2026
Merged via the queue into zs/main with commit 01e5e1b Sep 5, 2026
47 checks passed
@wiggdevin
wiggdevin deleted the port/4316 branch September 5, 2026 08:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants