Skip to content

feat(agent): share sidepanel sessions across agent tabs#462

Open
felarof99 wants to merge 1 commit intomainfrom
feat/show_agent_in_every_tab_codex
Open

feat(agent): share sidepanel sessions across agent tabs#462
felarof99 wants to merge 1 commit intomainfrom
feat/show_agent_in_every_tab_codex

Conversation

@felarof99
Copy link
Contributor

Summary

  • add a shared sidepanel session registry so agent-targeted tabs join the originating sidepanel session
  • mirror active sidepanel conversations into extension-local storage so child tabs can restore and stay in sync with the same conversation
  • open the sidepanel on every tool-targeted tab, clean up tab/session links on tab close, and cover the pure session state transitions with unit tests

Testing

  • bun test apps/agent/lib/sidepanel/shared-sidepanel-session-state.test.ts
  • bunx biome check apps/agent/lib/browseros/toggleSidePanel.ts apps/agent/entrypoints/background/index.ts apps/agent/entrypoints/sidepanel/index/useNotifyActiveTab.tsx apps/agent/entrypoints/sidepanel/index/useChatSession.ts apps/agent/lib/sidepanel/shared-sidepanel-conversation.ts apps/agent/lib/sidepanel/shared-sidepanel-session.ts apps/agent/lib/sidepanel/shared-sidepanel-session-state.ts apps/agent/lib/sidepanel/shared-sidepanel-session-state.test.ts

Notes

  • full @browseros/agent TypeScript typecheck hit a Node heap OOM locally before returning diagnostics, so verification here is limited to the targeted test and Biome pass.

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 10, 2026

Greptile Summary

This PR wires up a shared sidepanel session registry so that every tab the agent opens in a tool call joins the same conversation as the originating sidepanel, and mirrors the active conversation messages into chrome.storage.local so child tabs can restore and stay in sync without a remote round-trip.

Key changes:

  • New shared-sidepanel-session-state.ts — pure, well-tested state machine for the session ↔ tab bookkeeping (tabs/sessions maps, upsert, remove).
  • New shared-sidepanel-session.ts — storage layer wrapping the state machine; exposes ensureSharedSidepanelSession, linkTabToSharedSidepanelSession, removeTabFromSharedSidepanelSession, and a watch helper.
  • New shared-sidepanel-conversation.ts — mirrors active conversation messages into local:shared-sidepanel-conversations so child tabs can restore without a network call.
  • useChatSession.ts — tracks currentTabId, resolves sharedConversationId for the active tab, uses it as a fallback to conversationIdParam for restore, saves messages to the shared store on every update, and calls ensureSharedSidepanelSession after restore completes.
  • useNotifyActiveTab.tsx — extended to link all tool-targeted tabs into the shared session and open the sidepanel on each one.
  • background/index.ts — cleans up session entries when a tab is closed via chrome.tabs.onRemoved.

Two concrete bugs were found:

  • updateSharedSidepanelState performs a non-atomic read-modify-write; concurrent calls from the background service worker and sidepanel contexts can lose each other's writes.
  • toolTabIds (a freshly-allocated array each render) is included in a useEffect dependency array in useNotifyActiveTab, causing the effect — which calls linkTabToSharedSidepanelSession and openSidePanel in a loop — to fire on every render during streaming.

Confidence Score: 2/5

  • Not safe to merge — two concrete logic bugs need to be fixed before landing.
  • The pure state machine is solid and well-tested, but the storage layer has a non-atomic read-modify-write that can corrupt session state under concurrent access (background + sidepanel), and the toolTabIds array instability in useEffect deps will cause redundant storage writes and Chrome API calls on every render during a streaming response.
  • apps/agent/lib/sidepanel/shared-sidepanel-session.ts (race condition) and apps/agent/entrypoints/sidepanel/index/useNotifyActiveTab.tsx (unstable dep array).

Important Files Changed

Filename Overview
apps/agent/lib/sidepanel/shared-sidepanel-session.ts New storage layer for the session registry — contains a non-atomic read-modify-write in updateSharedSidepanelState that is susceptible to concurrent write races between the background service worker and sidepanel contexts.
apps/agent/entrypoints/sidepanel/index/useNotifyActiveTab.tsx Refactored to collect all tool tab IDs and open sidepanels for each; toolTabIds array is included in useEffect deps causing spurious re-runs on every render during streaming, flooding storage and Chrome APIs.
apps/agent/lib/sidepanel/shared-sidepanel-conversation.ts New module to mirror active conversations into chrome.storage.local for cross-tab sync; has no eviction/cleanup logic so the store grows unboundedly, and duplicates the haveMessagesChanged utility defined in useChatSession.ts.
apps/agent/lib/sidepanel/shared-sidepanel-session-state.ts Pure state-machine for session/tab bookkeeping — well-structured, immutable updates, clean separation of concerns. Well covered by the accompanying tests.
apps/agent/lib/sidepanel/shared-sidepanel-session-state.test.ts Comprehensive unit tests for the pure state transitions covering create, link, update, re-link, and remove scenarios. No issues found.
apps/agent/entrypoints/sidepanel/index/useChatSession.ts Integrates shared session/conversation sync — logic is mostly sound, but isResolvingSharedConversation initial state and the interplay between conversationIdToRestore and restoredConversationId guards adds significant complexity to the restore flow.
apps/agent/entrypoints/background/index.ts Adds chrome.tabs.onRemoved listener to clean up session entries on tab close; error is silently swallowed which is acceptable here. Change is minimal and correct.
apps/agent/lib/browseros/toggleSidePanel.ts Extracts isSidePanelOpen into its own exported function and reuses it inside openSidePanel — clean refactor with no issues.

Sequence Diagram

sequenceDiagram
    participant SP as Sidepanel (origin tab)
    participant BG as Background SW
    participant ST as SharedSidepanelSession<br/>(chrome.storage.local)
    participant CV as SharedSidepanelConversation<br/>(chrome.storage.local)
    participant CT as Child Tab Sidepanel

    SP->>ST: ensureSharedSidepanelSession({ tabId, conversationId })
    Note over ST: Creates/updates session entry

    SP->>SP: Agent streams messages (tool calls targeting child tabs)
    SP->>ST: linkTabToSharedSidepanelSession({ sourceTabId, targetTabId })
    SP->>SP: openSidePanel(targetTabId)

    SP->>CV: saveSharedSidepanelConversation(conversationId, messages)
    CV-->>CT: watch() fires → setMessages(nextMessages)

    CT->>ST: watchSharedSidepanelSessionForTab(tabId) fires
    CT->>CT: setSharedConversationId(session.conversationId)
    CT->>CV: getSharedSidepanelConversation(conversationId)
    CT->>CT: Restore messages from local store

    BG->>ST: removeTabFromSharedSidepanelSession(tabId) on chrome.tabs.onRemoved
Loading

Last reviewed commit: 7391c03

Comment on lines +35 to +47
async function updateSharedSidepanelState(
updater: (state: SharedSidepanelState) => SharedSidepanelState,
): Promise<SharedSidepanelState> {
const currentState = await sharedSidepanelSessionStorage.getValue()
const safeState = currentState ?? EMPTY_SHARED_SIDEPANEL_STATE
const nextState = updater(safeState)

if (nextState !== safeState) {
await sharedSidepanelSessionStorage.setValue(nextState)
}

return nextState
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Race condition in read-modify-write

updateSharedSidepanelState performs a non-atomic read-modify-write. In a browser extension, ensureSharedSidepanelSession can be called concurrently from the sidepanel context and removeTabFromSharedSidepanelSession can be called simultaneously from the background service worker (on tab close). Both read the same state, compute their independent mutations, and the second write silently clobbers the first — meaning a tab removal or session link can be lost.

For example:

  1. Background reads state S, starts computing removeTab(S, tabId)
  2. Sidepanel reads state S, starts computing ensureSession(S, { tabId, conversationId })
  3. Background writes removeTab(S, tabId) — correct
  4. Sidepanel writes ensureSession(S, ...)overwrites step 3, the removal is lost

The wxt-dev/storage API supports a getMeta / locking pattern or you can use an optimistic compare-and-swap loop to guard against this. Alternatively, since the background and sidepanel share chrome.storage.local, you can structure writes so each call only appends/patches its own key slice rather than replacing the whole object.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/agent/lib/sidepanel/shared-sidepanel-session.ts
Line: 35-47

Comment:
**Race condition in read-modify-write**

`updateSharedSidepanelState` performs a non-atomic read-modify-write. In a browser extension, `ensureSharedSidepanelSession` can be called concurrently from the sidepanel context and `removeTabFromSharedSidepanelSession` can be called simultaneously from the background service worker (on tab close). Both read the same state, compute their independent mutations, and the second write silently clobbers the first — meaning a tab removal or session link can be lost.

For example:
1. Background reads state `S`, starts computing `removeTab(S, tabId)`
2. Sidepanel reads state `S`, starts computing `ensureSession(S, { tabId, conversationId })`
3. Background writes `removeTab(S, tabId)` — correct
4. Sidepanel writes `ensureSession(S, ...)`**overwrites step 3**, the removal is lost

The `wxt-dev/storage` API supports a `getMeta` / locking pattern or you can use an optimistic compare-and-swap loop to guard against this. Alternatively, since the background and sidepanel share `chrome.storage.local`, you can structure writes so each call only appends/patches its own key slice rather than replacing the whole object.

How can I resolve this? If you propose a fix, please make it concise.

cancelled = true
}
}, [conversationId, status, hasToolCalls, toolTabId])
}, [conversationId, hostTabId, status, hasToolCalls, toolTabId, toolTabIds])
Copy link
Contributor

Choose a reason for hiding this comment

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

toolTabIds array causes spurious effect re-runs

toolTabIds is a new array reference on every render (created by extractTabIds), so including it in the useEffect dependency array causes the effect to fire on every render whenever the component re-renders — even if the actual tab IDs haven't changed. During streaming, messages state updates frequently, causing frequent re-renders. Each spurious trigger calls linkTabToSharedSidepanelSession and openSidePanel for every tab ID in a loop, flooding the storage layer and the Chrome API with redundant calls.

Since hasToolCalls (boolean) and toolTabId (the last tab ID) are already in deps, the effect is already correctly gated on meaningful state changes for the activation/deactivation logic. The toolTabIds array is only needed inside the loop — it can be read from a ref to avoid the referential instability:

const toolTabIdsRef = useRef<number[]>([])
toolTabIdsRef.current = toolTabIds          // keep it current each render

// in the effect body use toolTabIdsRef.current instead of toolTabIds
// and remove toolTabIds from the deps array

This ensures the loop always has the latest tab IDs without making the entire effect re-run every render.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/agent/entrypoints/sidepanel/index/useNotifyActiveTab.tsx
Line: 130

Comment:
**`toolTabIds` array causes spurious effect re-runs**

`toolTabIds` is a new array reference on every render (created by `extractTabIds`), so including it in the `useEffect` dependency array causes the effect to fire on **every render** whenever the component re-renders — even if the actual tab IDs haven't changed. During streaming, `messages` state updates frequently, causing frequent re-renders. Each spurious trigger calls `linkTabToSharedSidepanelSession` and `openSidePanel` for every tab ID in a loop, flooding the storage layer and the Chrome API with redundant calls.

Since `hasToolCalls` (boolean) and `toolTabId` (the last tab ID) are already in deps, the effect is already correctly gated on meaningful state changes for the activation/deactivation logic. The `toolTabIds` array is only needed inside the loop — it can be read from a ref to avoid the referential instability:

```ts
const toolTabIdsRef = useRef<number[]>([])
toolTabIdsRef.current = toolTabIds          // keep it current each render

// in the effect body use toolTabIdsRef.current instead of toolTabIds
// and remove toolTabIds from the deps array
```

This ensures the loop always has the latest tab IDs without making the entire effect re-run every render.

How can I resolve this? If you propose a fix, please make it concise.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant