Repository navigation
Conversation
Browsers allow six HTTP/1.1 connections per host across all tabs, and every long-lived SSE response pins one. Each omp-web tab held up to four (running badges and notifications, cross-session host tools, the attached chat, the watched file), so two or three tabs starved every other request until tabs were closed. Add one SSE stream that carries any number of channel subscriptions (running, host-tools, session, file) attached via POST, so a single connection can serve every tab. A running subscription that names the tab's notification client id also carries that tab's notifications. Session frames keep the per-subscription message_update coalescing under backpressure; an unserializable frame now ends only its own subscription instead of the shared socket. File-access authorization, including the read-path resolution (`~/`, root-relative paths) and the config-root credential denial, is extracted so /api/files and the file channel share one allow-list gate.
…Worker Each tab opened its own long-lived SSE responses (sidebar running badges and notifications, cross-session host tools, the attached chat, the watched file). Browsers cap HTTP/1.1 at six connections per host across every tab, so two or three working tabs pinned all of them and every other request from every tab queued forever. A SharedWorker (public/event-stream-worker.js; a dedicated Worker where SharedWorker is unavailable) now holds the only /api/events EventSource and multiplexes every tab's subscriptions over it. Tabs use openEventStream() from lib/event-stream.ts, which keeps EventSource semantics so the sidebar, shell, chat hook and file viewer only swap their constructor. Dead tabs are detected via a lifetime Web Lock, and the worker URL is content-hashed so a changed worker never serves stale tabs. The per-stream routes (/api/agent/[id]/events, /api/agent/running/events, /api/agent/host-tools/events, /api/files?type=watch) are removed; their tests move to the hub and worker suites.
Notifications rode the sidebar's running stream because, before the multiplexed socket, every extra stream cost one of the browser's six connections. That reason is gone: a channel costs a POST, not a socket. The piggyback also tied notifications to the sidebar's lifetime. The sidebar unmounts while Settings is open, so that tab stopped receiving toasts and stopped counting as reachable, which let other devices take over a notification the user was present for. Add a `notifications` channel keyed by the tab's client id (validated before anything attaches) and subscribe to it from useNotifications in AppShell, next to host-tools. The running channel goes back to sidebar state only, and the window-event relay between the sidebar and useNotifications is removed.
abread
force-pushed
the
concurrent-tabs
branch
from
October 13, 2026 10:40
24842b5 to
c1ff102
Compare
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Browsers allow only six HTTP/1.1 connections per host, shared by every tab, and each long-lived SSE response holds one open for as long as it lives. Each omp-web tab opened up to four:
/api/agent/running/events?clientId=/api/agent/host-tools/eventsAppShell(cross-sessionopen_url/notify/open_file)/api/agent/[id]/events/api/files/...?type=watchTwo or three working tabs used up the limit. After that, every other request from every tab (sending a message, loading a session, reading a file) waited in the browser's queue until a tab was closed. The app looked frozen.
Fix
Use only one connection per browser, carrying many subscriptions:
flowchart LR T1[Tab 1] --> W T2[Tab 2] --> W T3[Tab N] --> W W[SharedWorker] -->|"one SSE: GET /api/events"| S[Server hub] W -->|"POST /api/events: subscribe/unsubscribe"| S S --> C1[running] S --> C2[notifications] S --> C3[host-tools] S --> C4[session] S --> C5[file]/api/events(lib/event-stream-hub.ts,lib/event-stream-protocol.ts): a single SSE response that carries any number of channel subscriptions, added and removed withPOST /api/events. Each subscription's frames arrive asevent: <subId>;connection control frames arrive as
event: mux(hello/open/end).public/event-stream-worker.js: a SharedWorker that holds the browser's only/api/eventsEventSource and multiplexes every tab's subscriptions over it. Where SharedWorker is missing (e.g. Chrome for Android) it runs as a dedicated Worker, soeach tab still uses a single connection. Closed tabs are detected with a Web Lock held for the tab's lifetime. The worker URL includes a content hash, so a new version never serves tabs still running the old one.
lib/event-stream.ts:openEventStream(channel)returns an object that behaves like an EventSource (onerrorwhile CONNECTING means it's reconnecting on its own; CLOSED means it gave up). The sidebar, shell, chat hook, file viewer andnotifications hook only needed their constructor swapped.
Channels are split by who subscribes, how long it lives, what it's keyed on and which access check applies:
runningnotificationsclientId)useNotificationsinAppShellhost-toolsAppShellsessionfile/api/files)Comparison with behaviour from the old streams:
message_updateframes for a session are still merged so only the latest is sent.lib/file-authorization.ts: resolving~/and workspace-relative paths, and refusing omp's credential files under the config folder./api/filesand thefilechannel now run the same check.ModelsConfig) and the clone progress stream (DirectoryPicker) still open their own connections. Each lasts only for one user action, so they don't pile up across tabs./api/agent/[id]/events,/api/agent/running/events,/api/agent/host-tools/events,/api/files?type=watch, and the window event the sidebar used to relay notifications touseNotifications.User-visible changes
now have their own channel, mounted in the shell.
Testing
npm test: 1409 pass, 0 fail, at the tip and at commit 2 on its own. Typecheck is clean; lint has no new warnings.sharing between tabs, reconnects, server restarts, and closed tabs.
/api/eventsconnection carrying every tab's subscriptions, and the pages themselves opened no EventSources. Switching a tab to Settings dropped itsrunningsubscription but kept
notificationsandhost-tools.