fix(desktop): bound thread /query and surface load errors, not false-empty - #6447
fix(desktop): bound thread /query and surface load errors, not false-empty#6447wpfleger96 wants to merge 12 commits into
Conversation
…empty Long threads sometimes never loaded (permanent skeleton) or silently rendered a failed fetch as "No replies in this branch yet". Two defects: - The shared reqwest client sets no timeout, so a stalled/half-open /query HTTP request hangs forever. Add a 30s per-request deadline on both /query builders (scoped per-request, not client-level, because the client also serves STT/TTS downloads, builderlab auth, and the media proxy). Set above the 25s WS history timeout so a slow-but-live relay is not cut off early. Timeouts classify to the stable "relay unreachable: request timed out" string. - ChannelScreen consumed only isPending/data; a terminal error fell through to the empty state with no recovery. Plumb isError + refetch through to MessageThreadPanel and paint an explicit "Couldn't load replies" + Retry card. A pure selectThreadRepliesSurface helper pins the precedence so a terminal error never resolves to empty and cached rows stay visible non-destructively under a later error. To stay under the desktop file-size ratchet, move relay.rs's inline test module to relay/tests.rs and extract the reply empty/error cards (MessageThreadReplyState) and the per-row branch-highlight derivation (selectThreadRowHighlight) out of the panel, each with unit coverage. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The prior tests could stay green while their production fixes were reverted. Route both /query builders through one send_query_request helper that owns the per-request timeout, and drive that real helper from the stalled-loopback test under an outer tokio timeout guard so a lost timeout hangs and the guard fails fast. Extract the panel's terminal reply surface into an exported ThreadRepliesTerminalCard and mount-test error/empty/pending + Retry, so reverting the panel breaks the import. Move two pure branch-guide helpers to threadPanel.ts to keep the panel under the file-size ratchet. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The mount tests exercised the exported ThreadRepliesTerminalCard directly but never observed the panel's call site, so the panel could drop the card for an unconditional empty state — restoring the false-empty load bug — with every test still green. Add a source tripwire that fails if the terminal branch stops rendering ThreadRepliesTerminalCard fed by both repliesSurface and onRetryThreadReplies. Full panel mount can't reach that JSX (Tiptap composer / React Query stack), so the source is the binding. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
A source-text guard on the panel's terminal card proved the tag existed but could not prove the terminal branch reached it — wrapping the intact card in a dead conditional restored the false-empty bug while the guard stayed green. Move the surface→content dispatch out of MessageThreadPanel into an exported ThreadReplyRegion, fed the live surface, retry callback, and render callbacks for the heavy skeleton/list branches. The error≠empty decision now lives inside a cheap-to-mount unit, so the mount test exercises the real branching and an unwire drops the whole region instead of leaving a silently dead card. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…gion The panel previously computed a ThreadRepliesSurface and passed it to ThreadReplyRegion, leaving a falsifiable seam: a static/wrong surface prop at the panel boundary could silently restore the false-empty bug on every fetch failure while unit tests stayed green (they mounted the region directly and never observed the handoff). Move both the surface selection (selectThreadRepliesSurface + selectDeferredListRenderState) and the surface->content dispatch into ThreadReplyRegion, which now takes only raw query/render state (pending/error flags, deferred vs. live reply counts, huddle flag). The panel has no precomputed surface prop left to mis-set, so the seam class is gone by construction. The mount test drives raw state through the unit so both selection and dispatch are covered. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Unit tests mount ThreadReplyRegion in isolation and cannot mount the
full panel (Tiptap/React-Query hang the runner), so they never observe
the production panel->region handoff. A one-token call-site edit
(isError={false}) could ship the original false-empty regression with
all unit armor green. This smoke E2E drives the real panel wiring
through the mock bridge: it forces a terminal get_thread_replies failure
at the IPC boundary, opens the thread, and asserts the error/Retry card
renders and never the false-empty empty card, then recovers on Retry.
Immune to source restructuring because it observes what users see.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
wesbillman
left a comment
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes’s GitHub account.
Verdict: REQUEST CHANGES
Reviewed: exact head 04b76b7615caaa8938b5f86e3c587e00cfead5c9 against exact base / merge-base 2edacde4d4c01490834725774aa878dbc373c41d.
Blocking findings
[P2] Projects conversations still turn a failed thread load into an authoritative empty thread
ProjectConversationPanel is a second producer of the shared MessageThreadPanel, but this PR only wires the new query failure state through ChannelScreen.
The Projects path calls the same useThreadReplies query (desktop/src/features/projects/ui/ProjectConversationPanel.tsx:83), then treats any fetched result, including a terminal error, as sufficient to commit the empty reply expansion (:134-142). When it renders the panel, it hard-codes threadRepliesPending={false} and passes neither threadRepliesError nor onRetryThreadReplies (:249-284). With no cached replies, a /query failure therefore reaches ThreadReplyRegion as not pending, not errored, and empty, which renders “No replies in this branch yet” with no recovery. That is the same user-facing defect this PR fixes in ordinary channel threads.
Please propagate threadRepliesQuery.isPending, .isError, and .refetch() through this producer too, and add a regression that drives the Projects conversation surface through terminal failure and retry.
[P2] A response-body timeout is mislabeled as a malformed relay response
The request deadline correctly covers connect through full response-body consumption, but the timeout classification only wraps RequestBuilder::send() (desktop/src-tauri/src/relay.rs:394-406). Once a relay sends successful headers, send() resolves; if the body then stalls until the request deadline, the error is raised by response.json() in parse_json_response. That function maps every body read/decode failure to MALFORMED_RESPONSE_MESSAGE (relay.rs:225-249), discarding reqwest::Error::is_timeout().
So the new bounded request no longer hangs, but a half-open body produces “relay returned malformed response” rather than the promised stable “relay unreachable: request timed out” classification. The added test only stalls before headers (desktop/src-tauri/src/relay/tests.rs:269-314), so it cannot catch this branch.
Please preserve timeout classification while consuming the body and add a loopback regression that sends valid 2xx JSON headers, then stalls the body beyond the deadline.
[P2 accessibility] The asynchronous load failure is silent to assistive technology
ThreadRepliesErrorCard appears only after the query and retry lifecycle reaches a terminal failure, but its newly inserted container has no role="alert", aria-live, status semantics, or focus management (desktop/src/features/messages/ui/MessageThreadReplyState.tsx:24-49). A screen-reader user can remain in the composer without learning that history failed or that Retry became available. Buzz’s product contract requires WCAG 2.1 AA.
Please give the asynchronous error announcement appropriate alert/live-region semantics and assert that contract in the mounted component test.
Validation and notes
- Exact-head GitHub CI is green;
git diff --checkis clean. - Current fetched
main(fc2ce6728b3b4805040c0a2f2cc5c15f1c1806ce) merge-tree is conflict-free. - The scoped
/querytimeout, shared builder helper, ordinary channel error/retry wiring, and branch-highlight extraction otherwise trace correctly. - I am not treating cached rows remaining visible under a failed background refetch as a blocker: that is an explicit non-destructive behavior in this PR. A secondary stale-data warning/retry could improve it, but it is not the original false-empty failure.
- Local Tauri test execution was blocked before compilation because the clean review worktree lacks
desktop/src-tauri/binaries/buzz-acp-aarch64-apple-darwin; CI provides the broad exact-head evidence here.
…out label, and a11y
Address three review findings on the thread-load reliability fix:
- ProjectConversationPanel, a second producer of the shared thread panel,
hard-coded threadRepliesPending={false} and passed no error/retry, so a
terminal /query failure in a Projects conversation still painted
"No replies in this branch yet" with no recovery. Propagate isPending/
isError/refetch; add an E2E regression that drives the real Projects
wiring through failure -> error card -> Retry -> recovery.
- parse_json_response routed a body-consumption timeout (send() resolves on
headers, body stalls past the deadline) into the malformed-response bucket,
discarding is_timeout(). Route timeouts through classify_request_error for
the stable "relay unreachable: request timed out" label; add a loopback
regression that stalls the body after valid 2xx headers.
- ThreadRepliesErrorCard appeared asynchronously with no live-region
semantics; add role="alert" and assert it in the mounted test.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes’s GitHub account.
Verdict: REQUEST CHANGES
Reviewed: exact head 22783090fde967ca6dd252334c797f6b6b8ddbfa against exact base / merge-base 2edacde4d4c01490834725774aa878dbc373c41d.
The three blockers from my prior review are fixed on this head: ordinary and Projects conversation panels now propagate terminal query state and Retry, successful-header body stalls retain timeout classification, and the asynchronous error card is an alert live region. One deadline path remains incomplete.
Blocking finding
[P2] Non-2xx response-body stalls discard the timeout classification
The new per-request deadline covers the full /query request, but send_query_request sends every non-success response to relay_error_message (desktop/src-tauri/src/relay.rs:411-419). That helper consumes the response with response.text().await.unwrap_or_default() (relay.rs:272-290). If a relay sends 500 or 429 headers and then stalls its declared body past the deadline, the timed-out reqwest::Error is discarded; the caller receives relay returned 500 or a generic rate-limit message rather than the promised stable relay unreachable: request timed out classification.
The new body-stall regression covers only a successful 2xx response parsed through parse_json_response (desktop/src-tauri/src/relay/tests.rs:325-375), so this branch remains untested. Please preserve is_timeout() when consuming error bodies and add a non-2xx stalled-body regression through send_query_request.
Non-blocking follow-ups
useThreadRepliesForRootsstill drops aggregate terminal errors, so Huddle and Projects-agent transcripts can silently omit a failed reply subtree. This behavior predates the PR and is distinct from the dedicated panel's false-empty card, so I am not blocking this diff on it.- A deep-linked thread whose head is absent from the channel window can remain on the head-resolution skeleton independently of the reply-query error. That separate head-load path is also outside this PR's stated fix.
Validation
- Exact head remained unchanged and
git diff --checkis clean. - Applicable GitHub CI is green, including Desktop Core and all four Desktop Smoke E2E shards.
- I did not duplicate CI-equivalent broad suites locally.
…se bodies A non-success relay response (500/429) routes through relay_error_message, which consumed the body with response.text().await.unwrap_or_default() — silently discarding a body-consumption timeout and surfacing a bare status label instead of the stable "relay unreachable: request timed out" classification the frontend connectivity classifier keys on. Only the 2xx path preserved is_timeout(). Extract that decision into a shared classify_body_timeout helper both body-consuming paths route through so they cannot drift, and preserve the timeout on the error-body path. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…-empty
useThreadRepliesForRoots' combine returned only { events, isPending }, so a failed reply subtree contributed zero rows and vanished silently — the same false-empty class the single-root thread panel guards against, on the multi-root Huddle/Projects surfaces. Expose aggregate isError/error plus a refetch that re-runs only the failed subtrees, extracted into a pure combineThreadRepliesResults so the contract is unit-testable. The Projects agent conversation now renders the shared Couldn't-load-replies + Retry card when a subtree fails.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
wesbillman
left a comment
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes’s GitHub account.
Verdict: REQUEST CHANGES
Reviewed: exact head be94db695bd6c21e5c1d12827ce1db4d474a97fd against exact base / merge-base 2edacde4d4c01490834725774aa878dbc373c41d.
The prior /query timeout/classification blockers and the Projects conversation failure/retry wiring are fixed on this head. One multi-root consumer still violates the new aggregate error contract.
Blocking finding
[P2] Huddle transcripts still silently omit a failed reply subtree
combineThreadRepliesResults now preserves successful events while setting aggregate isError and exposing a failed-only refetch when any root query fails (desktop/src/features/messages/useThreadReplies.ts:142-161). Its stated contract is that a multi-root consumer must not silently present a partial transcript as complete.
The Huddle path consumes the same aggregate at desktop/src/features/channels/ui/useHuddleChannelMessages.ts:54-69, but reads only .events and returns only { resolvedMessages, threadSummaries }. ChannelScreen therefore receives no Huddle reply error or retry state (desktop/src/features/channels/ui/ChannelScreen.tsx:245-251). If one summarized root times out while another succeeds, that root contributes zero rows; the remaining channel messages and successful reply subtrees render with no warning and no recovery action. The Projects-agent consumer correctly renders ThreadRepliesErrorCard beside partial rows, but the other direct consumer remains unwired.
Please propagate the aggregate error/refetch state into the mounted Huddle transcript, show a non-destructive retry alert alongside any successful rows, and add a consumer-level regression. The new aggregate unit test proves the hook reports failure; it cannot catch a consumer discarding that report.
Validation and non-blocking notes
- Rust
/queryconstruction, request deadline, header/body timeout classification for 2xx and non-2xx responses, and cancellation trace correctly. Exact-head Desktop Core, Rust, unit, mobile, integration, and three smoke shards passed. - Desktop Smoke E2E shard 3 failed only in the unrelated existing
message-feedback-snapshots.spec.tsprofile-hover CSS assertion after all retries; the new Projects failure E2E passed. The aggregateDesktopjob merely reflects that shard failure. - Cached single-root rows remain visible after a failed background refetch without an inline stale-data warning. Preserving the rows is an explicit behavior already covered by tests; a warning/Retry would be safer but is not a blocker for this fix.
- Current fetched
origin/mainisd97780b4777f2fe3430b4e30a7d47fc6837ee059;git merge-tree --write-tree HEAD origin/mainsucceeds, andgit diff --checkis clean at the reviewed head.
The Huddle transcript's useThreadRepliesForRoots fan-out reported an aggregate isError/refetch, but useHuddleChannelMessages consumed only .events and discarded it. One summarized root failing left the partial transcript presenting as complete with no warning or recovery, violating the aggregate-error contract this PR established for its other consumer. Propagate the aggregate failure through ChannelScreen into ChannelPane and render the shared ThreadRepliesErrorCard as a non-destructive banner above the timeline: successful rows stay visible and onRetry re-runs only the failed subtrees. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…ake-fix * origin/main: (33 commits) perf(desktop): make the Projects surface render-cheap (#6460) refactor(acp): clarify agent prompt sections (#6501) Add mobile Huddles voice MVP (#6056) feat(desktop-messages): keep agents addressed across messages (#6315) fix(desktop): remove Buzz entity link previews (#6512) fix(composer): preserve caret when inserting mentions mid-message (#6531) chore(deps): update rust crate async-trait to v0.1.92 (#6094) chore(deps): update dependency sonner to v2.0.8 (#6093) chore(deps): update rust crate http-body-util to v0.1.4 (#5452) chore(deps): update rust crate http to v1.4.2 (#5451) chore(deps): update rust crate futures-util to v0.3.33 (#5448) chore(deps): update rust crate futures to v0.3.33 (#5445) chore(deps): update dependency @tauri-apps/api to v2.11.1 (#5444) chore(deps): update ubuntu:24.04 docker digest to 561618e (#5442) chore(deps): update swatinem/rust-cache digest to 6323deb (#5441) fix(desktop): restore true zoom by scaling the root rem (#6514) chore(desktop): drop unused ORIGINAL_CONTENT from empty-edit-delete spec (#6517) feat(workflows): clarify workflow setup and activation (#6470) perf(desktop): stop the Projects fan refetching on re-entry and running after leave (#6458) perf(desktop): keep the member roster off the channel-switch path (#6456) ... Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Long threads in the desktop app sometimes never load (stuck on a skeleton until you close and reopen the panel), and a failed load silently renders as "No replies in this branch yet" — presenting a broken fetch as an authoritative empty thread with no way to recover. This fixes both, the two IMPORTANT findings from the thread-load investigation.
Defect 1 — unbounded
/queryrequestThe shared
reqwest::Clientinrelay.rssets no timeout, and neither/queryrequest builder set a per-request.timeout(...). A stalled or half-open connection (headers or body never arrive) leaves the request pending forever, so a thread-history load hangs on the skeleton indefinitely.Fix: a 30s per-request deadline on both
/querybuilders, funnelled through onesend_query_requesthelper so the timeout can never be applied to one builder and dropped from the other. Scoped per-request rather than client-level because the same client also serves STT/TTS model downloads, builderlab auth, and the media proxy — a client-level timeout would cut those off. The deadline sits above the 25s WSHISTORY_TIMEOUT_MSso a slow-but-live relay isn't cut off before the WebSocket path would be. A timeout surfaces throughclassify_request_erroras the stable"relay unreachable: request timed out"string.send()resolves as soon as response headers arrive, so a relay that returns headers and then stalls the body trips the deadline during body consumption, not atsend()— and that consumption happens on two paths:parse_json_responsefor 2xx, andrelay_error_messagefor a non-success status (500/429/…). Both paths route their body-consumption error through one sharedclassify_body_timeouthelper so they can't drift: a stalled body surfaces the stable"relay unreachable: request timed out"string on either path rather than the malformed-response bucket (2xx) or a bare"relay returned 500"status label (non-2xx). A genuinely non-stalled error still keeps its status classification.Defect 2 — terminal error painted as empty
ChannelScreenconsumed onlyisPending/datafrom the thread-replies query. Once React Query exhausted its one retry,isPendingwas false and the zero-length data fell throughselectDeferredListRenderStateto the"empty"state — indistinguishable from a genuinely empty branch, with no retry affordance.Fix: plumb
isError+refetchthroughChannelScreen→ChannelPane→MessageThreadPanel. A pureselectThreadRepliesSurfacehelper decides the paint in strict precedence — the load-bearing invariant is that a terminal error never resolves to"empty", and cached replies stay visible non-destructively under a later error (the error card only surfaces when there is nothing to show). The panel renders an explicit "Couldn't load replies" + Retry card (testidsmessage-thread-replies-error/message-thread-replies-retry).ProjectConversationPanelis a second producer of the same shared panel and used to hard-codethreadRepliesPending={false}with no error/retry, so a failed load in a Projects conversation still painted the false-empty. It now propagates the sameisPending/isError/refetchfrom itsuseThreadRepliesquery.The multi-root
useThreadRepliesForRootshook (the Huddle transcript and Projects-agent conversation surfaces) had the same gap in itsuseQueriescombine: it returned only{ events, isPending }, so a failed reply subtree contributed zero rows and vanished. The combine is now a pure, unit-testablecombineThreadRepliesResultsthat exposes aggregateisError/errorplus arefetchthat re-runs only the failed subtrees. Both multi-root consumers render the shared "Couldn't load replies" + Retry card when a subtree fails: the Projects-agent conversation after its transcript, and the Huddle transcript as a non-destructive banner above the timeline.useHuddleChannelMessagesused to read only.eventsand discard the aggregate state, so one summarized root failing left the flattened transcript presenting as complete; it now propagatesthreadRepliesError/onRetryThreadRepliesthroughChannelScreenintoChannelPane, where successful rows stay visible andonRetryre-runs only the failed subtrees.The error card carries
role="alert"so its asynchronous appearance is announced to assistive tech — without a live region a screen-reader user parked in the composer never learns the load failed or that Retry became available.Tests
stalled_query_request_times_out_with_classified_error— a loopback server that never responds; asserts the stable classified timeout string.stalled_response_body_times_out_with_classified_error— a loopback server that writes valid 2xx JSON headers then stalls the body past the deadline; asserts the classified timeout string, not the malformed bucket.stalled_error_response_body_times_out_with_classified_error— a loopback that writes500headers promising a body it never sends; asserts the classified timeout string rather than the500status label.non_stalled_error_response_yields_status_message— a promptly-served500still surfaces"relay returned 500 Internal Server Error", pinning that timeout preservation is scoped to actual timeouts.selectThreadRepliesSurface— pending→skeleton, terminal error→error (never empty), page-2 failure never empty, cached rows stay visible under error, successful-empty→empty, retry-success→list, streaming→pending, and huddle-transcript collapse.MessageThreadReplyStatemounted test — terminal error renders the error card (assertingrole="alert"), never the empty card.combineThreadRepliesResults— multi-root aggregation/order, a failed subtree surfaces the aggregate error and never drops rows, aggregate pending, refetch re-runs only failed queries, all-success yields no error.thread-load-failure.spec.ts(smoke E2E) — binds the real channel-thread panel wiring: forces a terminalget_thread_repliesfailure at the IPC boundary, asserts the error card renders (never the false-empty) and Retry recovers.project-conversation-load-failure.spec.ts(smoke E2E) — the same guard for the Projects conversation producer, driven through the Projects Channels-tab row.huddle-thread-load-failure.spec.ts(smoke E2E) — the consumer-level guard the combine unit test can't provide: drives the real Huddle wiring (useHuddleChannelMessages→ChannelScreen→ChannelPane) with two summarized roots, fails one subtree's fetch at the IPC boundary, asserts the surviving root's reply stays visible while the retry alert surfaces, then Retry recovers the failed subtree and clears the alert.Structure
To stay under the desktop file-size ratchet,
relay.rs's inline test module moved torelay/tests.rs, and two pure pieces were extracted from the panel: the empty/error reply cards (MessageThreadReplyState) and the per-row branch-highlight derivation (selectThreadRowHighlight).