fix: keep agent activity visible in thread composer and after mobile resume - #13
Conversation
Signed-off-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-Authored-By: Oscar Le <oscar.lehuu@gmail.com>
Signed-off-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-Authored-By: Oscar Le <oscar.lehuu@gmail.com>
Signed-off-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-Authored-By: Oscar Le <oscar.lehuu@gmail.com>
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
Signed-off-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-Authored-By: Oscar Le <oscar.lehuu@gmail.com>
Desktop verificationBuilt with After the fix, Before the fix, with A turn in another thread in the same channel lights only the channel bar; the open thread remains clean, confirming conversation scoping. Also covered: root-based thread identity rather than nested reply IDs; opening an optimistic Not covered: mobile end-to-end behavior (no emulator/relay run) or a real ACP agent; agent state came from the mock bridge. |
Projects now leads with outcomes instead of plumbing. The landing is a grid of outcome cards — needs-you / ready / in-flight / shipped-30d counts, quiet projects dimmed, repository-less projects first-class — sorted by what needs the user. A project opens as a page (breadcrumb Projects › name) with a ship log of merged PRs, an in-flight section, crew/upstream contributor split, and the old workspace tabs collapsed into a Plumbing section, closed by default. Clicking an in-flight thread opens the production MessageThreadPanel in place on the project page (locked spike decision #13): the panel owns its chrome (no nested shells), loads the root independently by event id and replies through useThreadReplies — roots outside the channel window render fine — with skeleton and error states, and the composer send forwards threadHeadId so replies root correctly. The channel send path is untouched (fallback only applies when a caller supplies it). needsYou counts come from #74's store via a new useNeedsYouForChannels hook with per-channel-set cached snapshots and generation invalidation. Three grok-4.5 review rounds: r1 caught a goChannel redirect and a nonexistent PR.needsYou source; r2 caught the nested-panel shell, channel-window-only reply loading, and count-based cache invalidation; r3 verified every fix including no regression to channel thread sends. Fixes #83 Signed-off-by: oscarlehuu <oscar.lehuu@gmail.com>
Projects now leads with outcomes instead of plumbing. The landing is a grid of outcome cards — needs-you / ready / in-flight / shipped-30d counts, quiet projects dimmed, repository-less projects first-class — sorted by what needs the user. A project opens as a page (breadcrumb Projects › name) with a ship log of merged PRs, an in-flight section, crew/upstream contributor split, and the old workspace tabs collapsed into a Plumbing section, closed by default. Clicking an in-flight thread opens the production MessageThreadPanel in place on the project page (locked spike decision #13): the panel owns its chrome (no nested shells), loads the root independently by event id and replies through useThreadReplies — roots outside the channel window render fine — with skeleton and error states, and the composer send forwards threadHeadId so replies root correctly. The channel send path is untouched (fallback only applies when a caller supplies it). needsYou counts come from #74's store via a new useNeedsYouForChannels hook with per-channel-set cached snapshots and generation invalidation. Three grok-4.5 review rounds: r1 caught a goChannel redirect and a nonexistent PR.needsYou source; r2 caught the nested-panel shell, channel-window-only reply loading, and count-based cache invalidation; r3 verified every fix including no regression to channel thread sends. Fixes #83 Signed-off-by: oscarlehuu <oscar.lehuu@gmail.com>



Summary
Two independent "the agent is running but the UI says nothing" bugs.
Desktop — thread composer showed typing only. The channel composer resolves activity through the unified working signal (observer-derived turns primary, typing fallback), but the thread composer was wired to typing entries alone:
So while an agent worked in a thread, the active turn sat in
activeAgentTurnsStoreand the channel view knew about it, but the bar under the thread composer went blank as soon as typing TTL lapsed.The store already exposes
getActiveAgentsForConversation(), keyed by the turn'sconversationId. The thread panel only knows the NIP-10 root event ID, so this addsderiveAgentConversationId()— a TS port ofbuzz-acp's derivation:Raw bytes, no version/variant fixup — matching
crates/buzz-acp/src/conversation.rs. Test vectors are taken from the Rust implementation, not hand-computed.deriveAgentConversationIdOrNull()returnsnullfor malformed input (e.g. an optimisticoptimistic-<uuid>root that has no relay event ID yet) so the render path can't throw.Mobile — resume trusted a dead socket.
onAppPaused()only schedules the disconnect 5s out; a suspended isolate never runs that timer, so the session stayedconnected, andonAppResumed()early-returned onstate.status == connected— no reconnect, noREQreplay. Combined with iOS/Android silently invalidating the socket in the background, the app sat on a half-open connection until force-killed.Ping, invisible to the channel stream, so a healthy idle subscription would be killed), this usesIOWebSocketChannel's protocol-levelpingInterval: 30s, which closes the connection when pongs stop.Out of scope, worth a follow-up: on relay
CLOSEDmobile drops the subscription from_liveSubscriptionswith no retry path (relay_session.dart:496-523), and the desktop observer ingest silently discards frames on four branches without logging (observerRelayStore.ts:350-379).Related issue
None found.
Testing
Both bugs were first reproduced with throwaway tests asserting the broken behaviour, then those tests were inverted into the regression tests in this PR:
agentWorkingSignal.test.mjs— a thread turn is visible to the conversation-scoped signal; channel scope unaffected.conversationId.test.mjs— Rust-derived vectors, plus malformed channel/event IDs returningnull.relay_session_test.dart— short healthy resume keeps the socket; stale-socket resume reconnects and re-sendsREQ.Gates:
just desktop-check,just desktop-test(3889 pass),just mobile-test(907 pass),just fix-all,flutter analyze.Ruled out along the way, so the fix isn't aimed at the wrong layer: cold start with
turn_startedoutside the 300s window,limit: 1000truncation, terminal frames missingturnId, liveness pausing during permission prompts or long tool calls, and desktop socket staleness (desktop already has a 60s stall watchdog). Real liveness frames carryturnId+channelIdat a 10s cadence and resurrect a pruned turn on their own — the failure was purely the UI scope.No screenshots: the change is a conditional on an existing bar, and reproducing it needs a live agent working inside a thread.
Link to Devin session: https://app.devin.ai/sessions/0826f5720c94404fa8e327398aac6176
Requested by: @oscarlehuu