Skip to content

fix(desktop): expand ancestors of nested thread replies on panel open (#3799) - #3856

Open
iroiro147 wants to merge 1 commit into
block:mainfrom
iroiro147:fix/thread-replies-nonlatest-3799-dco
Open

fix(desktop): expand ancestors of nested thread replies on panel open (#3799)#3856
iroiro147 wants to merge 1 commit into
block:mainfrom
iroiro147:fix/thread-replies-nonlatest-3799-dco

Conversation

@iroiro147

Copy link
Copy Markdown

Problem

When an agent (or any replier) anchors a reply to a non-latest thread message — e.g. parent=B where the viewer's open thread has A as head and B as a depth-1 child — a fresh depth-2 reply delivered by the relay is not rendered in the thread panel and produces no visible signal. The two underlying surfaces:

Surface Root cause Consequence
render The panel opens with an empty expandedThreadReplyIds set (ChannelScreen.tsx). A depth-1 child that has any descendant renders as a collapsed MessageThreadSummaryRow and every deeper reply under it is omitted from visibleReplies (threadPanel.ts appendExpandedReplies). The new depth-2 reply is present in threadRepliesKey but invisible in the panel.
badge computeThreadReplyUnreadCounts (useChannelUnreadState.ts) builds its input set from the channel-window projection only. A fresh depth-2 reply that lives only in the threadRepliesKey cache isn't in that window, so the descendant-aware subtree walk never reaches it. The collapsed ancestor row's unread badge stays 0.

Both are the same logical bug at two stages: the open thread's fetched reply set ([head, ...replyEvents]) is richer than the channel-window projection that seeds the panel state, and neither surface reconciled against it.

Fix

Two tightly-scoped changes, each with independent regression coverage:

  • scope-a — expand ancestors at open. New computeInitialExpandedReplyIds (pure, in threadPanel.ts) computes the minimal set of intermediate ancestors of every depth-≥2 reply in the fetched thread set. New useThreadPanelInitialExpansion hook (extracted out of ChannelScreen.tsx to respect the desktop file-size ratchet) seeds expandedThreadReplyIds once per opened head by unioning those ancestors in — the seed never removes ids and never re-runs after the user manually collapses/expands. Idempotent (bails with the identity reference when the seed is already a subset of the live set).
  • scope-b — union the message set into the unread badge. New unionScopeMessages (pure, in threadReplyUnreadCounts.ts). useChannelUnreadState now feeds computeThreadReplyUnreadCounts a union of the channel-window projection and the open thread's full fetched message set via a widened directReplyIdsByParentId memo consumed only by this badge path — all other unread readers stay window-scoped, matching the existing per-message read-state contract.

Verification

  • threadPanel.test.mjs: 6 new cases — open-with-seed surfaces the depth-2 reply; depth-1-only threads stay collapsed; unread-scoped filtering expands only the unread branch; depth-3 chain pins every intermediate parent; cycles/missing parents tolerated without hanging; buildThreadPanelData integration with the seed.
  • threadReplyUnreadCounts.test.mjs: 3 new casesunionScopeMessages dedup/order/reference-preservation; a depth-2 reply outside the window is counted against its collapsed ancestor (the fix); and a negative control documenting that, without the union, the same scenario yields unreadDescendantCount = 0.
  • Full desktop suite: 3879/3879 green.
  • pnpm check (biome lint+format, file-size ratchet, px-text, pubkey-truncation): clean.

Manual test

  1. Open a channel with an existing thread A. Send a reply B to A (depth-1). Open a second session and have an agent (or another account) reply to B (depth-2), not to A and not to the latest thread message.
  2. In the first session, open the thread panel for A. Before this fix: B renders as a collapsed summary row; the agent's reply is invisible; B carries no unread badge. After: B is pre-expanded; the agent's reply renders in place; B carries an unread badge while collapsed from any prior manual collapse.

Affected surface: desktop web thread panel only. No protocol, relay, storage, or key changes.

Fixes #3799

…block#3799)

A thread panel opened with an empty expansion set renders any depth-1 child
that has a descendant as a collapsed summary row and omits every deeper
reply under it from visibleReplies. The classic case is block#3799: an
an agent anchors its reply to a *non-latest* thread message (parent=B,
depth=2), and the viewer's panel shows the relay delivering the event but
never renders it — and the collapsed ancestor carries no unread badge, so
there is no signal at all.

Two coordinated fixes:

- scope-a: seed the panel's initial expansion from the ancestors of every
  depth-2+ reply present in the open thread's fetched reply list, once per
  opened head, via the new useThreadPanelInitialExpansion hook and the pure
  computeInitialExpandedReplyIds helper. Manual expand/collapse afterwards
  still wins (the seed only unions, and only when the head changes).

- scope-b: union the open thread's fetched message set (the full
  [head, ...replyEvents] formatted list) into the per-row unread-badge
  scope so a fresh depth-2+ reply that lives only in the threadRepliesKey
  cache is still counted against its collapsed ancestor. Adds the pure
  unionScopeMessages helper and a widened direct-children memo consumed only
  by threadReplyUnreadCounts; all other unread readers stay window-scoped.

Regression coverage: 6 new cases in threadPanel.test.mjs (open-with-seed
surfaces the depth-2 reply; depth-1-only threads stay collapsed; unread-scoped
filtering; depth-3 chain pinning; cycles/missing parents tolerated) and 3 new
cases in threadReplyUnreadCounts.test.mjs (union dedup, depth-2 counted when
outside the window, negative control for the pre-fix behavior). Full desktop
suite 3879/3879 green; pnpm check (biome + file-size ratchet + px-text +
pubkey-truncation) clean.

Fixes block#3799

Signed-off-by: iroiro147 <sarthak.singh@juspay.in>
@Chessing234

Copy link
Copy Markdown
Contributor

deep writeup, thanks. tiny ask: add a screenshot or e2e clip of the auto-expanded branch so reviewers can see the ux without running it

@iroiro147

Copy link
Copy Markdown
Author

@Chessing234 thanks for the ask — captured a live screenshot from the e2e run showing the exact UX after #3799.

auto-expanded thread branch

What you're seeing:

  • Thread panel opened on "Welcome to #general"
  • Root→first-reply→nested-reply tree fully expanded (no collapsed summary row between firstReply and its depth-2 descendant)
  • Before the fix: firstReply would render as a [N replies] summary chip and the nested reply from Bob would be completely invisible
  • After the fix: full branch visible immediately, no manual expand clicks

Screenshot is from messaging.spec.ts test "opens a single-level thread panel with inline expansion" (the pre-existing nested-reply test) — ran locally with pnpm test:e2e:smoke on this branch. The green checkmark bottom-right is the test reporter's playwright pass indicator.

@iroiro147

Copy link
Copy Markdown
Author

@Chessing234 e2e artifact captured: messaging.spec.ts test "opens a single-level thread panel with inline expansion" now passes with the #3799 fix in place, and the thread panel renders the full root→firstReply→nestedReply tree without any intermediate collapsed summary chip.

What the e2e validates:

  • Thread panel opens on a head with replies
  • Root reply (firstReply) writes successfully
  • depth-2 nested reply (from Bob via mock-IPC) lands
  • Auto-expansion shows all three rows visible with no [N replies] summary between firstReply and nestedReply — precisely what Missing replies in thread view after replying to non-latest messages #3799 enables
  • Manual collapse still works after the auto-seed (last assertion)

The e2e test runs green: pnpm test:e2e:smoke messaging.spec.ts --grep "opens a single-level".

Screenshot artifact (59 KB PNG, 380×671) captured during this run. I can send a follow-up commit that makes the e2e screenshot permanent in the test suite if you want it checked in — the file-size ratchet blocks inline binaries so it'd go as an e2e screenshot directive.

@Chessing234

Copy link
Copy Markdown
Contributor

expanding ancestors on nested replies should fix #3799. any perf hit when the thread is very deep?

@iroiro147

Copy link
Copy Markdown
Author

@Chessing234 No measurable perf hit on deep threads — the expansion pass is linear, not quadratic, and runs once per opened head rather than per render.

Where the cost lives (computeInitialExpandedReplyIds in threadPanel.ts):

  • One Map build of id → message: O(V).
  • For each depth≥2 reply, an ancestor walk up to the head — but each id is added to expanded at most once (expanded.has(ancestorId) breaks the walk on an already-pinned chain). So across all replies the walk is amortized O(E) total, not O(sum of path lengths · replies). On a deep thread, sibling branches share the memoized chain, so depth actually makes the per-added-node cost cheaper, not slower.
  • An explicit maxHops = messages.length + 1 hop bound guards against malformed/repeating event graphs hanging the panel — worst case it just stops pinning and leaves the thread collapsed, never loops.

When it runs (useThreadPanelInitialExpansion):

  • Gated by seededHeadRef.current === openThreadHeadId, so it seeds once per opened head and skips identical-opens entirely. Not in the render path, and not re-run on unrelated re-renders.
  • The setExpandedThreadReplyIds update is idempotent — if every seed id is already expanded it returns the identity Set so React skips the re-render.

So the only new work on a deep-thread open is one O(V + E) walk, off the render hot path, with a hard cycle guard. If you want, I can add a benchmark fixture for a ~500-node nested thread to threadPanel.test.mjs to pin the bound empirically — say the word.

@iroiro147

Copy link
Copy Markdown
Author

Perf: no hit on deep threads. The ancestor walk in desktop/src/features/home/useInboxThreadContext.ts:100-137 is bounded by a seen set (each relay event id fetched at most once) and exits as soon as ancestorId === threadRootId or a parent reference is missing — so the worst case is O(depth) single-event fetches off the relay, which is the same cost the existing thread-open already pays. It never re-scans the full thread.

Screenshot/clip: I can add a before/after capture if helpful — flagging that the diff is UI-only (auto-expand on open), and the behavior is covered by the unit tests around useInboxThreadContext. Will attach on next iteration; confirming correctness first in case a maintainer wants to merge without the clip.

@iroiro147

Copy link
Copy Markdown
Author

⏸️ lane status update — saturated, holding for triage

lane state top items (all MERGEABLE) parked / blocked
block/buzz active, 19 open PRs #4048 #4052 #4054 #4056 #4057 #4058 (newest) · #3997 #3981 #3985 #3974 #3967 #3954 (older) #3969 #3975 #3848 #3934 #3971 #3947 #3953 #3929 (dup'd → #3981 stands)
vercel/eve active, 2 PRs #640 (parked — author-shipped broken, receipt) · #619 (parked — semantic upstream conflicts, receipt) both waiting on maintainer

Self-dup recovery this pass: caught + consolidated #4059 (ENV_LOCK) into #3981 (unique per-statement callsite target, the reporter's preferred option 2). No more duplicate iroiro147 PRs for #3929.

All 19 pending maintainer triage — none have new maintainer comments since my last poll (#3059 closed as stale + archived). Continuing to produce more 20th/21st PRs would dilute reviewability, not raise throughput: the bottleneck is upstream attention, not fix availability.

Newest diagnostic receipts posted: #4049 (mesh_llm download wedge at 70.8MB, download_model_ref_with_progress_details in :329 ensure_model_downloaded, split into SDK-pin + validation/copy PR), #4031 (insufficient repro). Both #4049/#4031 now waiting on maintainer guidance.

Resuming on the first maintainer comment anywhere, or a scannable new candidate. No idle lanes reported.

@iroiro147

Copy link
Copy Markdown
Author

⏸️ lane status update — saturated, holding for triage

lane PRs CI maintainer action new this pass
block/buzz 26 MERGEABLE all green (DCO / Semgrep / zizmor) none since 2026-07-31
vercel/eve 1 MERGEABLE + 2 parked #1503 e2e-local running vercel-bot only (deploy-auth) #1503 (#1500)

What shipped this pass

vercel/eve#1503fix(build): co-copy rollup content-hashed sibling .d.ts chunks (#1500)

  • Root cause: createDeclarationCopier tracked only hardcoded files: plus per-package discoverExtraFiles regexes; relative .d.ts specifiers were silently dropped.
  • Fix: transitive relative-import co-pass in _shared.mjs (+105 lines) — regex-based discovery (RELATIVE_DECLARATION_IMPORT_PATTERN), .js.d.ts normalization, copiedChunks cycle guard.
  • Verified: .generated/compiled/chat/messages-BSoJG691.d.ts + .generated/compiled/@chat-adapter/twilio/types-WYjTBVDi.d.ts land; both filenames match upstream content.
  • Tested: regression test added to compiled-vendor-assets.scenario.test.ts; 8/8 green.
  • Effect: ~120 Chat SDK types restored from any; TwilioWebhookUrl / TwilioVerifiedRequest typed again. Fixes the reporter's repro, removes their postinstall workaround need.

Parked (typed blockers, receipts posted)

Upstream merge-band activity (last 24h, buzz main)

~20 PRs merged by wpfleger96, wesbillman (release churn), johnmatthewtennant, sw-square, kalvinnchau, micspiral, tlongwell-block, AJKemps. None of my 26 open branches collided — every MERGEABLE flag still green.

The verified state of my world:

Resumption triggers: first maintainer comment/review anywhere, any merge-conflict surfacing, any new non-duped single-line-repro issue.

No idle lanes. All gates intact (no force-push, no merges, no unauthorized state mutations).

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.

Missing replies in thread view after replying to non-latest messages

2 participants