Skip to content

Fix stale Pear message panes after idle#86

Merged
miyaontherelay merged 4 commits into
mainfrom
fix/pear-stale-message-reconciliation
Jun 5, 2026
Merged

Fix stale Pear message panes after idle#86
miyaontherelay merged 4 commits into
mainfrom
fix/pear-stale-message-reconciliation

Conversation

@miyaontherelay
Copy link
Copy Markdown
Contributor

@miyaontherelay miyaontherelay commented Jun 5, 2026

User description

Summary

  • add bounded channel/DM message reconciliation through pear.broker.reconcileMessages(input) backed by SDK message list APIs
  • refresh/rebind Pear's existing harness-driver event stream on focus/status/reconnect triggers using public APIs only
  • route broker events to the current session window and add debug-gated main/renderer diagnostics for receipt vs display
  • wire a renderer reconciliation hook with store-level id dedup/update merge and repro automation

Validation

  • git diff --check
  • npx vitest run src/main/broker.test.ts src/renderer/src/hooks/use-message-reconciliation.test.ts
  • npm test
  • npm run build

Notes: npm run build still prints existing Vite dynamic/static import warnings for auth.ts, broker.ts, and integrations.ts, but completes successfully.


CodeAnt-AI Description

Keep chat panes in sync after idle, tab switches, and reconnects

What Changed

  • When a chat room is reopened or the app regains focus, Pear now refreshes the active message stream and fills in missed channel or DM messages.
  • Incoming reconciled messages are merged into the existing chat view by message id, so updated content replaces stale copies instead of creating duplicates.
  • Broker events now follow the current window after a session moves to a different window, which prevents messages from showing in the wrong place.
  • Added diagnostics for message stream receipts and rebind attempts, plus a repro script for stale message issues.

Impact

✅ Fewer stale chat panes after idle
✅ Fewer duplicate messages after reconnect
✅ Messages stay in the active window after tab or window changes

🔄 Retrigger CodeAnt AI Review

💡 Usage Guide

Checking Your Pull Request

Every time you make a pull request, our system automatically looks through it. We check for security issues, mistakes in how you're setting up your infrastructure, and common code problems. We do this to make sure your changes are solid and won't cause any trouble later.

Talking to CodeAnt AI

Got a question or need a hand with something in your pull request? You can easily get in touch with CodeAnt AI right here. Just type the following in a comment on your pull request, and replace "Your question here" with whatever you want to ask:

@codeant-ai ask: Your question here

This lets you have a chat with CodeAnt AI about your pull request, making it easier to understand and improve your code.

Example

@codeant-ai ask: Can you suggest a safer alternative to storing this secret?

Preserve Org Learnings with CodeAnt

You can record team preferences so CodeAnt AI applies them in future reviews. Reply directly to the specific CodeAnt AI suggestion (in the same thread) and replace "Your feedback here" with your input:

@codeant-ai: Your feedback here

This helps CodeAnt AI learn and adapt to your team's coding style and standards.

Example

@codeant-ai: Do not flag unused imports.

Retrigger review

Ask CodeAnt AI to review the PR again, by typing:

@codeant-ai: review

Check Your Repository Health

To analyze the health of your code repository, visit our dashboard at https://app.codeant.ai. This tool helps you identify potential issues and areas for improvement in your codebase, ensuring your repository maintains high standards of code health.

@codeant-ai
Copy link
Copy Markdown

codeant-ai Bot commented Jun 5, 2026

CodeAnt AI is reviewing your PR.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Jun 5, 2026

Review Change Stack

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Free

Run ID: a92ab504-0d45-4c3c-8721-ef397e506fbc

📥 Commits

Reviewing files that changed from the base of the PR and between bf9e51b and 51c6e60.

📒 Files selected for processing (3)
  • src/renderer/src/hooks/use-message-reconciliation.test.ts
  • src/renderer/src/hooks/use-message-reconciliation.ts
  • src/renderer/src/stores/agent-store.ts
💤 Files with no reviewable changes (2)
  • src/renderer/src/hooks/use-message-reconciliation.test.ts
  • src/renderer/src/hooks/use-message-reconciliation.ts

📝 Walkthrough

Walkthrough

Adds broker-backed message reconciliation: shared IPC types and handlers, session-scoped event-stream rebind/diagnostics, Relay message normalization and reconcile API, renderer debounced reconciler merging messages into the agent store, tests, preload/App wiring, and a CLI repro plus config updates.

Changes

Message Reconciliation Implementation

Layer / File(s) Summary
IPC contracts and preload/handlers
src/shared/types/ipc.ts, src/main/ipc-handlers.ts, src/preload/index.ts
Adds BrokerReconciledChatMessage, BrokerReconcileMessagesInput, BrokerEventStreamDiagnostic, IPC handlers broker:reconcile-messages/broker:refresh-event-stream, and exposes reconcileMessages, refreshEventStream, and onEventStreamDiagnostic to the renderer.
Broker core: event-stream rebinding & reconciliation
src/main/broker.ts
Adds per-session last-event and rebind metadata, refreshEventStream()/rebindSessionEventStream() with cooldown, session-key-aware publish routing, Relay message normalization, and reconcileMessages() fetching normalized channel/DM messages.
Broker tests: routing & refresh behaviors
src/main/broker.test.ts
Adds tests for per-session window routing, destroyed-window suppression, disconnect/reconnect resume behavior, replacement-listener on error during refresh, and global refresh isolation.
Renderer reconciler and App wiring
src/renderer/src/hooks/use-message-reconciliation.ts, src/renderer/src/App.tsx
Implements request construction, MessageReconciler with debounce/single-flight/queued rerun semantics, useMessageReconciliation hook wired into lifecycle/broker diagnostics, and invokes it from App.
Renderer reconciler tests
src/renderer/src/hooks/use-message-reconciliation.test.ts
Adds unit tests for request shaping (channel/DM), debounce behavior, in-flight queued rerun, skip-on-null, merge dedupe/overwrite, and store integration.
Agent store reconciliation & receipt logging
src/renderer/src/stores/agent-store.ts
Adds conversationId to ChatMessage, imports reconciled message type, implements reconcileChatMessages merging/deduping/preserving threads+reactions, reconcileMessages API, and conditional broker receipt logging on inbound events.
Preload surface & window API
src/preload/index.ts
Re-exports new IPC types and attaches reconcileMessages, refreshEventStream, and onEventStreamDiagnostic to window.pear.broker.
Repro script and tooling
scripts/repro-stale-message-reconciliation.mjs, tsconfig.web.json, vitest.config.mjs
Adds a runnable CLI repro script that posts and validates messages via agent-relay and broker websocket; excludes tests from web TS build; and adds Vitest aliases plus renderer test include patterns.

🎯 4 (Complex) | ⏱️ ~60 minutes

🐰 I hopped through websockets and Relay trails,
debounced my hops and queued the tails,
merged messages neat and bright,
kept windows routed just right,
then nibbled bugs until they sailed.


Note

🎁 Summarized by CodeRabbit Free

Your organization is on the Free plan. CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please upgrade your subscription to CodeRabbit Pro by visiting https://app.coderabbit.ai/login.

Comment @coderabbitai help to get the list of available commands and usage tips.

@codeant-ai codeant-ai Bot added the size:XXL This PR changes 1000+ lines, ignoring generated files label Jun 5, 2026
Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a message reconciliation mechanism and event stream rebinding for the agent-relay broker, allowing the renderer to fetch and merge canonical chat messages upon window focus, visibility changes, or broker status updates. The feedback highlights several opportunities to prevent potential runtime crashes by adding defensive guards around message.from and reaction.agents in the main process, as well as guarding window.pear and window.pear.broker references in the renderer hook.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread src/main/broker.ts
Comment on lines +511 to +513
function senderNameFromRelayMessage(message: RelayMessage): string {
return message.from.name || message.from.id || 'unknown'
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Guard against message.from being null or undefined to prevent runtime TypeError crashes during message reconciliation.

function senderNameFromRelayMessage(message: RelayMessage): string {
  return message.from?.name || message.from?.id || 'unknown'
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked at HEAD 2e94a15. Fixed — see src/main/broker.ts:512. Sender fallback now uses optional chaining on message.from before reading name/id.

Comment thread src/main/broker.ts
Comment on lines +564 to +568
reactions: message.reactions?.map((reaction) => ({
emoji: reaction.emoji,
count: reaction.count,
reactedByHuman: reaction.agents.some(isHumanSenderName)
}))
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Guard against reaction.agents being undefined or not an array. If reaction.agents is missing, calling .some() will throw a runtime error.

    reactions: message.reactions?.map((reaction) => ({
      emoji: reaction.emoji,
      count: reaction.count,
      reactedByHuman: Array.isArray(reaction.agents) && reaction.agents.some(isHumanSenderName)
    }))

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked at HEAD 2e94a15. Fixed — see src/main/broker.ts:567. Reaction agent checks now guard reaction.agents with Array.isArray(...) before .some(...).

Comment on lines +209 to +213
function refreshEventStream(reason: string): void {
const projectId = useProjectStore.getState().activeProjectId || undefined
const broker = window.pear.broker as PearAPI['broker'] & BrokerWithMessageReconciliation
void broker.refreshEventStream?.(projectId, reason).catch(() => undefined)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

In Electron renderers, window.pear or window.pear.broker might be undefined during early initialization or in testing environments. Guarding against this prevents potential runtime crashes.

Suggested change
function refreshEventStream(reason: string): void {
const projectId = useProjectStore.getState().activeProjectId || undefined
const broker = window.pear.broker as PearAPI['broker'] & BrokerWithMessageReconciliation
void broker.refreshEventStream?.(projectId, reason).catch(() => undefined)
}
function refreshEventStream(reason: string): void {
const projectId = useProjectStore.getState().activeProjectId || undefined
const broker = window.pear?.broker as (PearAPI['broker'] & BrokerWithMessageReconciliation) | undefined
void broker?.refreshEventStream?.(projectId, reason).catch(() => undefined)
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked at HEAD 2e94a15. Fixed — see src/renderer/src/hooks/use-message-reconciliation.ts:200. Refresh now uses window.pear?.broker?.refreshEventStream?.(...) and safely ignores missing preload.

Comment on lines +236 to +237
reconcileMessages: (input) =>
(window.pear.broker as PearAPI['broker'] & BrokerWithMessageReconciliation).reconcileMessages(input),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Add a defensive guard for window.pear?.broker to prevent TypeError crashes if the preload API is not fully loaded or mocked.

Suggested change
reconcileMessages: (input) =>
(window.pear.broker as PearAPI['broker'] & BrokerWithMessageReconciliation).reconcileMessages(input),
reconcileMessages: (input) => {
const broker = window.pear?.broker as (PearAPI['broker'] & BrokerWithMessageReconciliation) | undefined
return broker ? broker.reconcileMessages(input) : Promise.resolve([])
},

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked at HEAD 2e94a15. Fixed — see src/renderer/src/hooks/use-message-reconciliation.ts:226. Reconcile now reads window.pear?.broker?.reconcileMessages and falls back to an empty resolved list when unavailable.

Comment on lines +284 to +291
useEffect(() => {
return window.pear.broker.onStatus((status) => {
if (BROKER_CONNECTED_STATUSES.has(status.status)) {
refreshEventStream(`broker:${status.status}`)
reconciler.schedule(`broker:${status.status}`)
}
})
}, [reconciler])
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Guard the onStatus subscription against an undefined window.pear?.broker to ensure the hook does not crash on mount.

Suggested change
useEffect(() => {
return window.pear.broker.onStatus((status) => {
if (BROKER_CONNECTED_STATUSES.has(status.status)) {
refreshEventStream(`broker:${status.status}`)
reconciler.schedule(`broker:${status.status}`)
}
})
}, [reconciler])
useEffect(() => {
return window.pear?.broker?.onStatus((status) => {
if (BROKER_CONNECTED_STATUSES.has(status.status)) {
refreshEventStream(`broker:${status.status}`)
reconciler.schedule(`broker:${status.status}`)
}
})
}, [reconciler])

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked at HEAD 2e94a15. Fixed — see src/renderer/src/hooks/use-message-reconciliation.ts:276. Status subscription now uses optional chaining around window.pear?.broker?.onStatus?.(...).

Comment on lines +293 to +301
useEffect(() => {
const broker = window.pear.broker as PearAPI['broker'] & BrokerWithMessageReconciliation
if (!broker.onEventStreamDiagnostic) return
return broker.onEventStreamDiagnostic((event) => {
if (EVENT_STREAM_RECONCILED_STATUSES.has(event.status)) {
reconciler.schedule(`event-stream:${event.status}`)
}
})
}, [reconciler])
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Guard the onEventStreamDiagnostic subscription against an undefined window.pear?.broker to prevent crashes.

Suggested change
useEffect(() => {
const broker = window.pear.broker as PearAPI['broker'] & BrokerWithMessageReconciliation
if (!broker.onEventStreamDiagnostic) return
return broker.onEventStreamDiagnostic((event) => {
if (EVENT_STREAM_RECONCILED_STATUSES.has(event.status)) {
reconciler.schedule(`event-stream:${event.status}`)
}
})
}, [reconciler])
useEffect(() => {
const broker = window.pear?.broker as (PearAPI['broker'] & BrokerWithMessageReconciliation) | undefined
if (!broker?.onEventStreamDiagnostic) return
return broker.onEventStreamDiagnostic((event) => {
if (EVENT_STREAM_RECONCILED_STATUSES.has(event.status)) {
reconciler.schedule(`event-stream:${event.status}`)
}
})
}, [reconciler])

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked at HEAD 2e94a15. Fixed — see src/renderer/src/hooks/use-message-reconciliation.ts:285. Event-stream diagnostic subscription now guards window.pear?.broker and onEventStreamDiagnostic before subscribing.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Jun 5, 2026

Caution

Failed to replace (edit) comment. This is likely due to insufficient permissions or the comment being deleted.

Error details
{"name":"HttpError","status":500,"request":{"method":"PATCH","url":"https://api.github.com/repos/AgentWorkforce/pear/issues/comments/4629780926","headers":{"accept":"application/vnd.github.v3+json","user-agent":"octokit.js/0.0.0-development octokit-core.js/7.0.6 Node.js/24","authorization":"token [REDACTED]","content-type":"application/json; charset=utf-8"},"body":{"body":"<!-- This is an auto-generated comment: summarize by coderabbit.ai -->\n<!-- review_stack_entry_start -->\n\n[![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/AgentWorkforce/pear/pull/86?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)\n\n<!-- review_stack_entry_end -->\n<!-- This is an auto-generated comment: review in progress by coderabbit.ai -->\n\n> [!NOTE]\n> Currently processing new changes in this PR. This may take a few minutes, please wait...\n> \n> <details>\n> <summary>⚙️ Run configuration</summary>\n> \n> **Configuration used**: Organization UI\n> \n> **Review profile**: CHILL\n> \n> **Plan**: Free\n> \n> **Run ID**: `2347b87e-4f99-4723-9889-7936f0e53759`\n> \n> </details>\n> \n> <details>\n> <summary>📥 Commits</summary>\n> \n> Reviewing files that changed from the base of the PR and between 979c3038fabf88cfc2abc17bbd0c89bffcbbf85c and 863efa20631c58ef58cd96896760422eaa661a2d.\n> \n> </details>\n> \n> <details>\n> <summary>📒 Files selected for processing (12)</summary>\n> \n> * `scripts/repro-stale-message-reconciliation.mjs`\n> * `src/main/broker.test.ts`\n> * `src/main/broker.ts`\n> * `src/main/ipc-handlers.ts`\n> * `src/preload/index.ts`\n> * `src/renderer/src/App.tsx`\n> * `src/renderer/src/hooks/use-message-reconciliation.test.ts`\n> * `src/renderer/src/hooks/use-message-reconciliation.ts`\n> * `src/renderer/src/stores/agent-store.ts`\n> * `src/shared/types/ipc.ts`\n> * `tsconfig.web.json`\n> * `vitest.config.mjs`\n> \n> </details>\n> \n> ```ascii\n>  ________________________________\n> < Goodbye, diff induced despair. >\n>  --------------------------------\n>   \\\n>    \\   (\\__/)\n>        (•ㅅ•)\n>        /   づ\n> ```\n\n<!-- end of auto-generated comment: review in progress by coderabbit.ai -->\n\n<!-- tips_start -->\n\n---\n\n> [!NOTE]\n> <details>\n> <summary>🎁 Summarized by CodeRabbit Free</summary>\n> \n> Your organization is on the Free plan. CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please upgrade your subscription to CodeRabbit Pro by visiting <https://app.coderabbit.ai/login>.\n> \n> </details>\n\n\n<sub>Comment `@coderabbitai help` to get the list of available commands and usage tips.</sub>\n\n<!-- tips_end -->"},"request":{"retryCount":3,"signal":{},"retries":3,"retryAfter":16}}}

Comment on lines +122 to +125
if (inFlight) {
debug({ kind: 'skipped', reason })
return inFlight
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Reconciliation triggers are dropped while a fetch is already running: when runNow sees an inFlight promise it returns early and does not queue a follow-up run. If a new trigger (like tab switch/focus/status) happens during a slow fetch, the later trigger can be lost and the UI may remain stale until another external trigger occurs. Keep a "pending rerun" flag or reschedule once inFlight completes. [race condition]

Severity Level: Critical 🚨
- ❌ Active chat tab can show stale message history.
- ⚠️ Tab switch during slow fetch may not refresh content.
- ⚠️ Reconnect/focus triggers can be dropped under load.
- ⚠️ Debug logs show skipped runs without follow-up reconcile.
Steps of Reproduction ✅
1. Open the renderer app so `App` mounts, which calls `useMessageReconciliation()` at
`src/renderer/src/App.tsx:14-30` (line 30).

2. `useMessageReconciliation()` constructs a reconciler via `createMessageReconciler` at
`src/renderer/src/hooks/use-message-reconciliation.ts:109-181`, wiring `reconcileMessages`
to `window.pear.broker.reconcileMessages` and `mergeMessages` to `mergeReconciledMessages`
(lines 227-241).

3. With an active channel/DM tab selected, a trigger such as `activeRoomKey` change,
window focus, or broker status causes `reconciler.schedule(...)` to be called from the
effects at `use-message-reconciliation.ts:246-248`, `251-254`, `258-270`, `285-289`, or
`293-299`; after the debounce delay, `schedule()` calls `runNow(reason)` (lines 158-167).

4. Assume the first reconciliation run is slow (e.g., high latency in
`deps.reconcileMessages(request)` at `use-message-reconciliation.ts:136`) so `inFlight` is
set non-null at line 133 and remains pending; a second trigger (tab switch, focus, broker
reconnect) fires while this promise is still in-flight, causing `schedule()` to enqueue
another run.

5. When the second scheduled run fires, `runNow()` executes and hits the `if (inFlight)`
check at `use-message-reconciliation.ts:122-125`, logs a `skipped` debug event, and
`return inFlight` without performing a fresh `getRequest()`/`reconcileMessages()`; the
timer was already cleared in `schedule()` at line 165 and no new timer is set.

6. After the original in-flight reconciliation resolves, no additional run is triggered
for the later reason (new active room, refreshed broker status, etc.), so the currently
active chat room's messages can remain stale until some future external trigger causes
another `reconciler.schedule(...)` call.

Fix in Cursor | Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** src/renderer/src/hooks/use-message-reconciliation.ts
**Line:** 122:125
**Comment:**
	*Race Condition: Reconciliation triggers are dropped while a fetch is already running: when `runNow` sees an `inFlight` promise it returns early and does not queue a follow-up run. If a new trigger (like tab switch/focus/status) happens during a slow fetch, the later trigger can be lost and the UI may remain stale until another external trigger occurs. Keep a "pending rerun" flag or reschedule once `inFlight` completes.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked at HEAD 2e94a15. Fixed — see src/renderer/src/hooks/use-message-reconciliation.ts:119 and src/renderer/src/hooks/use-message-reconciliation.ts:155. In-flight triggers now set pendingRerun and run again after the current fetch; regression covered in src/renderer/src/hooks/use-message-reconciliation.test.ts:145.

Comment on lines +346 to +355
if (previous) {
byId.set(next.id, {
...previous,
...next,
threadReplies: next.threadReplies || previous.threadReplies,
reactions: next.reactions || previous.reactions
})
changed = true
continue
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The merge path marks state as changed for every existing message id even when incoming content is identical, so every reconciliation call rebuilds/sorts the full message array and triggers unnecessary Zustand updates/rerenders. Only mark changed when at least one field actually differs before replacing the existing entry. [performance]

Severity Level: Major ⚠️
- ⚠️ Chat message list rerenders on every reconciliation call.
- ⚠️ Direct-message room derivation recomputes unnecessarily.
- ⚠️ More React work during frequent focus/reconnect events.
- ⚠️ Potential UI jank with large message histories.
Steps of Reproduction ✅
1. Mount the renderer app so `App` runs `useMessageReconciliation()` at
`src/renderer/src/App.tsx:20-30`, which wires reconciliation to the broker and message
store.

2. A reconciliation trigger (e.g., window focus or broker status) calls
`reconciler.schedule(...)` from
`src/renderer/src/hooks/use-message-reconciliation.ts:246-248,251-254,258-270,285-289,293-299`,
eventually running `deps.reconcileMessages(request)` and passing the results into
`mergeReconciledMessages` at `use-message-reconciliation.ts:133-141,183-201`.

3. `mergeReconciledMessages()` calls
`useAgentStore.getState().reconcileMessages(messages)` at `agent-store.ts:183-187`, which
in turn uses `reconcileChatMessages(state.messages, messages)` inside the Zustand `set`
callback at `agent-store.ts:632-645`.

4. In `reconcileChatMessages` at `agent-store.ts:331-365`, for each incoming message that
matches an existing `id`, the merge path at lines 345-355 (`const previous =
byId.get(next.id); if (previous) { byId.set(next.id, { ...previous, ...next, ... });
changed = true; }`) unconditionally sets `changed = true` and replaces the map entry with
a new object, even when all fields (`body`, `timestamp`, `reactions`, `threadReplies`,
etc.) are identical to `previous`.

5. Because `changed` is set whenever any incoming message is seen, the function always
returns a new array instance via `Array.from(byId.values()).sort(...)` at
`agent-store.ts:362-365` instead of returning the original `existingMessages` reference.

6. The store's `reconcileMessages` then sees `nextMessages !== state.messages` and returns
`{ messages: nextMessages }` at `agent-store.ts:632-644`, forcing a Zustand state update
and downstream rerenders on every reconciliation call, even when the broker returns only
messages that are bitwise-identical to what is already in the UI.

7. Features that derive direct message rooms from `messages`, such as
`deriveDirectMessageRooms` in `src/renderer/src/lib/direct-messages.ts:11-48`, will
repeatedly recompute and rerender under frequent reconciliations, incurring avoidable CPU
and rendering work.

Fix in Cursor | Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** src/renderer/src/stores/agent-store.ts
**Line:** 346:355
**Comment:**
	*Performance: The merge path marks state as changed for every existing message id even when incoming content is identical, so every reconciliation call rebuilds/sorts the full message array and triggers unnecessary Zustand updates/rerenders. Only mark changed when at least one field actually differs before replacing the existing entry.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked at HEAD 2e94a15. Fixed — see src/renderer/src/stores/agent-store.ts:393 and src/renderer/src/hooks/use-message-reconciliation.test.ts:233. Existing-id merges now use field-scoped equality before marking changed. Note: the equality check is field-scoped (body, timestamp, reactions, threadReplies, plus displayed routing fields); reverting just the equality check restores the over-marking behavior and makes the new no-op identical re-merge assertion go RED. Codex-2 earlier non-vacuity for genuine updates is preserved.

@miyaontherelay
Copy link
Copy Markdown
Contributor Author

Review of #86 — Fix stale Pear message panes after idle

Verdict: APPROVE

The PR cleanly addresses every clause of the Phase 3 acceptance bar in /tmp/ws-pear-display-stale-contract.md. Bug surface is real, fix is well-scoped, tests are non-vacuous, and CI is green end-to-end (checks + packaged-mcp-smoke + CodeRabbit all SUCCESS, CLEAN/MERGEABLE).

Acceptance bar — line-by-line

Clause Status Evidence
Bounded channel/DM reconciliation on canonical events (start, broker-connected, focus, reconnect) src/renderer/src/hooks/use-message-reconciliation.ts covers focus / visibilitychange / pageshow / online / broker:status / event-stream-diagnostic triggers. 750ms debounce + in-flight dedup. Bounded DEFAULT_RECONCILE_LIMIT = 50.
Pear-side event-stream liveness/rebind using existing harness-driver APIs BrokerManager.refreshEventStreamrebindSessionEventStream does unsubEvent() + client.disconnectEvents() + attachClient() re-register + client.connectEvents(lastEventSeq). No harness-driver patch needed. 5s cooldown prevents storms (EVENT_STREAM_REBIND_COOLDOWN_MS).
BrowserWindow lifecycle fix publishBrokerEvent + PTY-chunk fast path both route via windowForSession(sessionKey, fallback) which prefers the session's current window over the captured closure. Plus the rebind path updates session.window and re-registers the listener. Belt-and-suspenders.
Diagnostics for main-vs-renderer receipt emitEventStreamDiagnostic emits structured events (received / rebind-started / rebound / rebind-skipped / rebind-error) including eventId, seq, reconnects. received is gated by pear-broker-debug localStorage flag to avoid noise; lifecycle events emit unconditionally.
No new IPC unless required ✅ (justified) Three new APIs: pear.broker.reconcileMessages, pear.broker.refreshEventStream, pear.broker.onEventStreamDiagnostic. All load-bearing for the fix; no scope creep.
Triggers event-driven, not polling Confirmed — no setInterval polling anywhere in the hook. All triggers are events.
Non-vacuity proofs See §Non-vacuity below.

Non-vacuity (verified empirically)

I reverted each fix in isolation on the PR branch and re-ran tests:

  1. Stubbed refreshEventStream to a no-oprefreshEventStream rebinds the harness stream from the last seen sequence test FAILED (disconnectEvents expected once, got 0). Test is genuinely measuring the rebind behavior. ✅
  2. Removed changed = true from the existing-id branch of reconcileChatMessagesmerges reconciled messages into the Zustand store and dedups by id test FAILED (body stayed "missed while idle" instead of "updated canonical body"). Test is genuinely measuring the canonical-update propagation that codex-2 caught during dev. ✅
  3. Reverted windowForSession lookup in publishBrokerEvent to use win directly → window-swap test still passed because the rebind path also re-registers the listener with the new window. That's belt-and-suspenders, not a vacuity issue: the test proves the END behavior (events route to current window), and the rebind path alone is sufficient to ensure that. The windowForSession defensive guard is dormant coverage for the case where a window is destroyed without a rebind (e.g. crashed renderer). Minor coverage gap, see follow-ups.

Locally re-ran

  • npx vitest run src/main/broker.test.ts → 14/14 ✓
  • npx vitest run src/renderer/src/hooks/use-message-reconciliation.test.ts → 6/6 ✓
  • npm test → 28/28 ✓
  • Inspected electron-builder.mcp-resources.yml is still in sync (this PR doesn't touch the dep closure).

CI checks on the PR are also green; CodeRabbit pass.

Code quality notes

Strengths

  • Clean separation: pure createMessageReconciler factory (testable in isolation, no React/Electron deps) + thin useMessageReconciliation hook on top. Easy to unit-test.
  • Real bug caught during dev: codex-2 noticed that the initial store dedup didn't mark state changed when canonical fields changed; test caught it; codex-1 fixed it. That's the right loop.
  • Smart rebind seam: keeps the fix entirely in Pear by using disconnectEvents() + connectEvents(lastSeq). No upstream harness-driver PR needed — exactly what the contract escalation rule was designed for.
  • Multiple triggers, single debounce + in-flight guard: focus+visibility+online firing together when waking from sleep collapse into one fetch.
  • 5s rebind cooldown: prevents the renderer's focus-happy triggers from storming reconnect on alt-tab-happy users.

Minor follow-ups (non-blocking, don't need to land in this PR)

  1. Remove compat shim in mergeReconciledMessages (use-message-reconciliation.ts:190-200). Both halves (store action + IPC surface) land together in this PR; the handleBrokerEvent fallback can go after merge.
  2. AgentRelay SDK client is constructed per reconcileMessages call (broker.ts:1816). On a focus storm (visibilitychange + focus + online firing together), that's still bounded by the 750ms debounce → at most one construction per debounce window. Acceptable, but a cached/lazy SDK client per session would shave allocations. Trivial.
  3. windowForSession defensive guard lacks a dedicated test. Add a test that destroys the captured window without re-binding the session, then fires an event, and asserts no crash + no send to destroyed window. Would harden the belt-and-suspenders coverage.
  4. packaged-mcp-smoke CI job runs dist:mac and the MCP smoke but this PR doesn't touch any of those paths. Could be skipped for non-MCP PRs via a path filter. Out of scope for this PR; just noting as future CI hygiene.

What I'd ask for

Nothing required. Ship it.

The follow-ups above are genuinely optional — drop them or schedule them separately. The PR meets every clause of the contract and has empirical non-vacuity for the load-bearing behavior.

Comment thread src/main/broker.ts
Comment on lines +1737 to +1739
if (win && !win.isDestroyed()) {
session.window = win
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Rebinding unconditionally overwrites the session window when a win is passed, and this runs for each session being refreshed. When refresh is invoked without a specific project, one renderer can steal event routing ownership for other sessions/windows; only update session.window for explicitly targeted sessions or when session ownership is validated. [stale reference]

Severity Level: Major ⚠️
- ❌ Broker events for a project routed to wrong window.
- ⚠️ PTY terminal output may appear in incorrect project tab.
- ⚠️ Event-stream diagnostics misattribute project to wrong renderer.
Steps of Reproduction ✅
1. Start Pear and open at least two projects so that `useProjectStore.ensureBroker()`
(src/renderer/src/stores/project-store.ts:136–156) causes `pear.broker.start()` to run for
each, creating multiple `BrokerSession` entries in `BrokerManager.sessions`
(src/main/broker.ts:1077–1087, 1121–1180).

2. In any renderer window, call the public preload API
`window.pear.broker.refreshEventStream(undefined, 'manual-debug')`, which forwards via
`invoke('broker:refresh-event-stream', projectId, reason)` in `preload/index.ts`
(src/preload/index.ts:252–253).

3. The IPC handler `ipcMain.handle('broker:refresh-event-stream', ...)`
(src/main/ipc-handlers.ts:266–269) receives `projectId` as `undefined`, resolves `win`
from `BrowserWindow.fromWebContents(event.sender)`, and calls
`brokerManager.refreshEventStream(projectId, reason || 'renderer-request', win ||
undefined)`.

4. In `BrokerManager.refreshEventStream()` (src/main/broker.ts:1720–1726), because
`projectId` is falsy, it builds `sessions = Array.from(this.sessions.values())` (all
projects) and for each session calls `rebindSessionEventStream(sessionKeyFor(session),
session, reason, win)` with the same `win`.

5. Inside `rebindSessionEventStream()` (src/main/broker.ts:1730–1739), the guard `if (win
&& !win.isDestroyed()) { session.window = win }` overwrites `session.window` for every
session, so all projects' sessions now point to the calling window's `BrowserWindow`
regardless of which window originally owned them.

6. Subsequent broker events use `windowForSession()` and `publishBrokerEvent()`
(src/main/broker.ts:1677–1681, 1927–1943) to choose the target renderer based on
`session.window`; as a result, events for other projects (including `broker:event`,
`broker:event-stream-diagnostic`, and `broker:pty-chunk`) are routed only to the stealing
window, leaving the original project windows with stale terminals and message panes.

Fix in Cursor | Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** src/main/broker.ts
**Line:** 1737:1739
**Comment:**
	*Stale Reference: Rebinding unconditionally overwrites the session window when a `win` is passed, and this runs for each session being refreshed. When refresh is invoked without a specific project, one renderer can steal event routing ownership for other sessions/windows; only update `session.window` for explicitly targeted sessions or when session ownership is validated.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked at HEAD 2e94a15. Fixed — see src/main/broker.ts:1720 and src/main/broker.ts:1725. Global refreshes no longer pass a caller window into session rebind; only project-scoped refreshes can update session.window.

Comment thread src/main/broker.ts Outdated
Comment on lines +1768 to +1771
session.unsubEvent()
session.client.disconnectEvents()
session.unsubEvent = this.attachClient(sessionKey, session.client, session.window)
session.client.connectEvents(session.lastEventSeq)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The rebind flow tears down the existing listener before ensuring a new listener is successfully attached and connected. If attachClient or connectEvents throws, the session is left unsubscribed and stops receiving broker events until another successful rebind; keep the old subscription until reattach succeeds, or restore it in the catch path. [logic error]

Severity Level: Critical 🚨
- ❌ Broker session can permanently lose event-stream updates.
- ❌ Chat and terminal panes freeze after rebind failure.
- ⚠️ Message reconciliation stops reflecting latest relay messages.
Steps of Reproduction ✅
1. Launch Pear so `App` mounts and `useProjectStore.load()` runs
(src/renderer/src/App.tsx:51–53), which in turn calls `ensureBroker()`
(src/renderer/src/stores/project-store.ts:136–156) and invokes `pear.broker.start(...)` to
create a `BrokerSession` with an active `client.onEvent` subscription via
`BrokerManager.attachClient()` (src/main/broker.ts:1844–1889).

2. In a renderer, `useMessageReconciliation()` triggers an event-stream refresh by calling
`broker.refreshEventStream(projectId, reason)`
(src/renderer/src/hooks/use-message-reconciliation.ts:209–213), which flows through the
IPC handler `broker:refresh-event-stream` (src/main/ipc-handlers.ts:266–269) to
`BrokerManager.refreshEventStream(projectId, reason, win)` (src/main/broker.ts:1720–1726).

3. For the target session, `refreshEventStream()` calls
`rebindSessionEventStream(sessionKey, session, reason, win)`
(src/main/broker.ts:1725–1727), which executes the rebind sequence: `session.unsubEvent()`
and `session.client.disconnectEvents()` (src/main/broker.ts:1768–1769) to tear down the
existing listener, then `session.unsubEvent = this.attachClient(sessionKey,
session.client, session.window)` and `session.client.connectEvents(session.lastEventSeq)`
(src/main/broker.ts:1770–1771) to attach a new one.

4. If either `attachClient(...)` or `session.client.connectEvents(...)` throws—for example
due to a transient broker error or harness-driver failure—the control jumps to the `catch`
block (src/main/broker.ts:1785–1793), which only emits a `BrokerEventStreamDiagnostic`
with `status: 'rebind-error'` and rethrows, without restoring the previous `onEvent`
subscription or reconnecting events.

5. After such a failure, the `BrokerSession` has already run `session.unsubEvent()` and
`disconnectEvents()`, but no replacement listener is attached, leaving the session with no
active `client.onEvent` handler; downstream, `useBrokerEvents()`
(src/renderer/src/hooks/use-broker-events.ts:64–76, 80–92) no longer receives
`broker:event`, `broker:status`, or PTY chunk updates for that project, so chat and
terminal UIs stop updating until a later successful rebind or app restart.

Fix in Cursor | Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** src/main/broker.ts
**Line:** 1768:1771
**Comment:**
	*Logic Error: The rebind flow tears down the existing listener before ensuring a new listener is successfully attached and connected. If `attachClient` or `connectEvents` throws, the session is left unsubscribed and stops receiving broker events until another successful rebind; keep the old subscription until reattach succeeds, or restore it in the catch path.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked at HEAD 2e94a15. Fixed — see src/main/broker.ts:1769 and src/main/broker.ts:1799. Rebind now keeps the previous listener available while wiring the next listener, and restores the previous subscription if attach/connect fails; regression covered in src/main/broker.test.ts:455.

Comment thread src/main/ipc-handlers.ts
Comment on lines +266 to +269
ipcMain.handle('broker:refresh-event-stream', async (event, projectId?: string, reason?: string) => {
const win = BrowserWindow.fromWebContents(event.sender)
await brokerManager.refreshEventStream(projectId, reason || 'renderer-request', win || undefined)
})
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The IPC handler accepts an optional projectId and forwards it directly, so renderer calls without a project end up refreshing every broker session. Focus/online/status-triggered refreshes can therefore disconnect and reconnect unrelated projects, creating avoidable churn and cross-project side effects; require a concrete project id (or scope to sender-bound project) before calling refresh. [logic error]

Severity Level: Major ⚠️
- ⚠️ Global refresh from one window rebinds all broker sessions.
- ⚠️ Unrelated projects see unnecessary event-stream disconnects.
- ⚠️ Rebind failures in other projects affect uninvolved windows.
Steps of Reproduction ✅
1. Run Pear and open multiple projects so that `useProjectStore.ensureBroker()` ultimately
calls `brokerManager.start(projectId, ...)` via `ipcMain.handle('broker:start', ...)`
(src/main/ipc-handlers.ts:175–183), creating multiple `BrokerSession`s in
`BrokerManager.sessions` (src/main/broker.ts:1077–1087, 1121–1180).

2. From any renderer window, invoke the public preload API without a project scope, e.g.
in DevTools run `window.pear.broker.refreshEventStream(undefined, 'manual-global')`, which
calls `invoke('broker:refresh-event-stream', projectId, reason)`
(src/preload/index.ts:252–253) with `projectId` as `undefined`.

3. The IPC handler `ipcMain.handle('broker:refresh-event-stream', ...)`
(src/main/ipc-handlers.ts:266–269) receives the request, derives `win` from
`BrowserWindow.fromWebContents(event.sender)`, and passes `projectId` (undefined), `reason
|| 'renderer-request'`, and `win` to `brokerManager.refreshEventStream(...)`.

4. Inside `BrokerManager.refreshEventStream()` (src/main/broker.ts:1720–1724), because
`projectId` is falsy, it computes `sessions = Array.from(this.sessions.values())` and
iterates all broker sessions—local and cloud—calling
`rebindSessionEventStream(sessionKeyFor(session), session, reason, win)` for each, even
for projects unrelated to the calling window.

5. Each unrelated session undergoes a disconnect/reconnect cycle (`session.unsubEvent()`,
`disconnectEvents()`, then `connectEvents()` in src/main/broker.ts:1768–1772), exposing
them to the failure mode described in broker.ts:1785–1793 (rebind error leaves them
unsubscribed) and causing unnecessary event-stream churn across projects when the caller
likely intended to refresh only its own active project.

Fix in Cursor | Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** src/main/ipc-handlers.ts
**Line:** 266:269
**Comment:**
	*Logic Error: The IPC handler accepts an optional `projectId` and forwards it directly, so renderer calls without a project end up refreshing every broker session. Focus/online/status-triggered refreshes can therefore disconnect and reconnect unrelated projects, creating avoidable churn and cross-project side effects; require a concrete project id (or scope to sender-bound project) before calling refresh.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked at HEAD 2e94a15. Fixed — see src/main/ipc-handlers.ts:266. IPC now trims and requires a non-empty project id before calling refreshEventStream, preventing renderer-triggered global rebinds.

@codeant-ai
Copy link
Copy Markdown

codeant-ai Bot commented Jun 5, 2026

CodeAnt AI finished reviewing your PR.

@miyaontherelay miyaontherelay merged commit 182d463 into main Jun 5, 2026
3 checks passed
@miyaontherelay miyaontherelay deleted the fix/pear-stale-message-reconciliation branch June 5, 2026 09:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL This PR changes 1000+ lines, ignoring generated files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant