diff --git a/.changeset/try-live-panel-ready-contract.md b/.changeset/try-live-panel-ready-contract.md
new file mode 100644
index 000000000..b9b6d2a64
--- /dev/null
+++ b/.changeset/try-live-panel-ready-contract.md
@@ -0,0 +1,13 @@
+---
+'@conciv/embed': patch
+'@conciv/extension-ios': patch
+'@conciv/protocol': patch
+---
+
+`mount(el)` now resolves only once the widget can actually honor `open()` — previously the returned promise settled as soon as the app's boot sequence finished computing, which raced ahead of the root route's `onMount` (where the open-panel listener registers). Any embedder that opens the panel immediately after `mount()` resolves — including a landing page's "Try it live" button that dispatches an early click before the widget bundle finishes loading — no longer has that open silently dropped.
+
+`mountConciv(extensions)` now returns the underlying `mount()` promise instead of `void`, so a caller can `await` it (or observe a rejection) instead of the widget's readiness being unobservable outside `createConciv`. Existing fire-and-forget call sites keep working unchanged; they just ignore the returned promise.
+
+New `@conciv/protocol/event-bus` export: `createEventBus`/`createEventBusClient`, a faithful port of the TanStack Devtools in-page event-bus protocol. It eliminates the whole "sender fires before receiver is listening" class of races, not just the one above. Every emit is wrapped as an envelope (`{type: ':', payload, pluginId}`) and dispatched on one fixed bus event (`conciv-dispatch-event`); the running bus re-dispatches it as both a specific `:` event and a global `conciv-global` event, and answers the fixed `conciv-connect` handshake with `conciv-connect-success`. Clients queue emits until connected, retry on a bounded loop, and flush in order on ack.
+
+Panel commands moved onto that protocol under the `panel` plugin id, so the wire events are now `panel:open`, `panel:close` and `panel:toggle` instead of `conciv:open-panel`, `conciv:close-panel` and `conciv:toggle-panel`, and they are spoken through a bus client rather than a bare `window.dispatchEvent`. `createConciv().open()`/`close()`/`toggle()`, the landing page's "Try it live" button, and the iOS bridge's panel open/close all emit through a client; the widget's root route subscribes with `client.on()` and starts the bus once its listeners are registered. Status events (`conciv:connection-changed`, `conciv:panel-toggled`) are unchanged raw window events.
diff --git a/apps/conciv/src/app/context.ts b/apps/conciv/src/app/context.ts
index 708b836b1..4fdc60a91 100644
--- a/apps/conciv/src/app/context.ts
+++ b/apps/conciv/src/app/context.ts
@@ -28,6 +28,7 @@ export type AppContextValue = {
grabProvider?: GrabProvider
connectionGeneration: () => number
apiBase: () => string
+ notifyInteractive: () => void
}
export const AppContext = createContext()
@@ -101,3 +102,7 @@ export function useConnectionGeneration(): () => number {
export function useApiBase(): () => string {
return useAppScope('useApiBase', (app) => app.apiBase)
}
+
+export function useNotifyInteractive(): () => void {
+ return useAppScope('useNotifyInteractive', (app) => app.notifyInteractive)
+}
diff --git a/apps/conciv/src/router.tsx b/apps/conciv/src/router.tsx
index c027ed57c..2bcfa9ff3 100644
--- a/apps/conciv/src/router.tsx
+++ b/apps/conciv/src/router.tsx
@@ -33,6 +33,7 @@ export type ConcivRouterContext = {
apiBase: () => string
connectionGeneration: () => number
disposeInstances: () => void
+ notifyInteractive: () => void
}
export type ConcivRouterConfig = {
@@ -48,6 +49,7 @@ export type ConcivRouterConfig = {
grabProvider?: GrabProvider
apiBase?: () => string
connectionGeneration?: () => number
+ notifyInteractive?: () => void
}
function disposeExtensionInstances(instances: ExtensionInstance[]): void {
@@ -123,6 +125,7 @@ export function createConcivRouter(config: ConcivRouterConfig) {
apiBase,
connectionGeneration: config.connectionGeneration ?? (() => 0),
disposeInstances,
+ notifyInteractive: config.notifyInteractive ?? (() => {}),
},
})
}
diff --git a/apps/conciv/src/routes/__root.tsx b/apps/conciv/src/routes/__root.tsx
index 055dbbd53..78e103eac 100644
--- a/apps/conciv/src/routes/__root.tsx
+++ b/apps/conciv/src/routes/__root.tsx
@@ -14,12 +14,21 @@ import {showToast} from '@conciv/page'
import {createHotkey} from '@tanstack/solid-hotkeys'
import {Show, createEffect, createSignal, onCleanup, onMount} from 'solid-js'
import {makeEventListener} from '@solid-primitives/event-listener'
+import {
+ CONNECTION_CHANGED_EVENT,
+ createEventBus,
+ createEventBusClient,
+ PANEL_PLUGIN_ID,
+ PANEL_TOGGLED_EVENT,
+ type PanelCommandEventMap,
+} from '@conciv/protocol/event-bus'
import type {ConcivRouterContext} from '../router.js'
import {
AppContext,
useAppData,
useConnected,
useLayers,
+ useNotifyInteractive,
useSettings,
useSuppressed,
type AppContextValue,
@@ -113,11 +122,12 @@ function RootComponent() {
grabProvider: app.grabProvider,
connectionGeneration: app.connectionGeneration,
apiBase: app.apiBase,
+ notifyInteractive: app.notifyInteractive,
}
createEffect(() => {
const isConnected = app.connected()
- window.dispatchEvent(new CustomEvent('conciv:connection-changed', {detail: {connected: isConnected}}))
+ window.dispatchEvent(new CustomEvent(CONNECTION_CHANGED_EVENT, {detail: {connected: isConnected}}))
})
const reachability = makeEngineReachability()
@@ -157,6 +167,7 @@ function RootChrome(props: {
const layers = useLayers()
const suppressed = useSuppressed()
const connected = useConnected()
+ const notifyInteractive = useNotifyInteractive()
const router = useRouter()
const matchRoute = useMatchRoute()
const panelMatch = matchRoute({to: '/panel/$sessionId', fuzzy: true})
@@ -194,7 +205,7 @@ function RootChrome(props: {
const reportPanelState = () => {
const open = panelOpen()
window.dispatchEvent(
- new CustomEvent('conciv:panel-toggled', {
+ new CustomEvent(PANEL_TOGGLED_EVENT, {
detail: {open, connected: connected(), mascotRect: open ? null : mascotRect()},
}),
)
@@ -259,17 +270,26 @@ function RootChrome(props: {
onCleanup(() => cancelAnimationFrame(frame))
})
+ const eventBus = createEventBus()
+ const panelCommands = createEventBusClient({pluginId: PANEL_PLUGIN_ID})
+
onMount(() => {
if (settings.defaultOpen && closedMatch()) void openPanel()
- const openFromHost = () => void openPanel()
- const closeFromHost = () => {
- if (panelOpen()) closePanel()
- }
- const toggleFromHost = () => togglePanel()
makeEventListener(window, 'resize', reportPanelState)
- makeEventListener(window, 'conciv:open-panel', openFromHost)
- makeEventListener(window, 'conciv:close-panel', closeFromHost)
- makeEventListener(window, 'conciv:toggle-panel', toggleFromHost)
+ const unsubscribes = [
+ panelCommands.on('open', () => void openPanel()),
+ panelCommands.on('close', () => {
+ if (panelOpen()) closePanel()
+ }),
+ panelCommands.on('toggle', () => togglePanel()),
+ ]
+ eventBus.start()
+ notifyInteractive()
+ onCleanup(() => {
+ for (const unsubscribe of unsubscribes) unsubscribe()
+ eventBus.stop()
+ panelCommands.dispose()
+ })
})
const onKeyDown = (event: KeyboardEvent) => {
diff --git a/apps/conciv/test/helpers/pane-harness.tsx b/apps/conciv/test/helpers/pane-harness.tsx
index f74384488..384a4979b 100644
--- a/apps/conciv/test/helpers/pane-harness.tsx
+++ b/apps/conciv/test/helpers/pane-harness.tsx
@@ -66,6 +66,7 @@ export function mountPane(options: PaneMountOptions, view: (pane: PaneContextVal
connectMode: false,
connectionGeneration: () => 0,
apiBase: () => options.base,
+ notifyInteractive: () => {},
}
const pane: PaneContextValue = {
sessionId: () => options.sessionId,
diff --git a/apps/site/src/components/landing/hero.tsx b/apps/site/src/components/landing/hero.tsx
index c4169642b..d8f981fb6 100644
--- a/apps/site/src/components/landing/hero.tsx
+++ b/apps/site/src/components/landing/hero.tsx
@@ -1,5 +1,5 @@
import {Suspense, lazy} from 'react'
-import {ClientOnly} from '@tanstack/react-router'
+import {ClientOnly, Link} from '@tanstack/react-router'
import {Badge} from '@/components/ui/badge'
import {useIsMobile} from '@/lib/use-is-mobile'
import {TryLiveButton} from './try-live-button'
@@ -28,7 +28,14 @@ export function Hero() {
run your tests, without ever leaving the thing you're
building.
- {!isMobile && (
+ {isMobile ? (
+
+ The live try-it flow needs a terminal, so it's desktop-only.{' '}
+
+ Read the quick start →
+
+
+ ) : (
<>
diff --git a/apps/site/src/components/landing/try-live-button.tsx b/apps/site/src/components/landing/try-live-button.tsx
index face2d241..45ef8eefe 100644
--- a/apps/site/src/components/landing/try-live-button.tsx
+++ b/apps/site/src/components/landing/try-live-button.tsx
@@ -1,39 +1,59 @@
import {useSyncExternalStore} from 'react'
import {Button} from '@/components/ui/button'
+import {
+ CONNECTION_CHANGED_EVENT,
+ createEventBusClient,
+ PANEL_PLUGIN_ID,
+ type EventBusClientState,
+ type PanelCommandEventMap,
+ type WidgetConnectionChangedDetail,
+} from '@conciv/protocol/event-bus'
+import {tryButtonLabel} from '@/lib/try-state'
declare global {
interface WindowEventMap {
- 'conciv:connection-changed': CustomEvent<{connected: boolean}>
+ [CONNECTION_CHANGED_EVENT]: CustomEvent
}
}
let connected = false
-function subscribe(onChange: () => void): () => void {
- const handler = (event: WindowEventMap['conciv:connection-changed']) => {
+function subscribeConnection(onChange: () => void): () => void {
+ const handler = (event: WindowEventMap[typeof CONNECTION_CHANGED_EVENT]) => {
connected = event.detail.connected
onChange()
}
- window.addEventListener('conciv:connection-changed', handler)
- return () => window.removeEventListener('conciv:connection-changed', handler)
+ window.addEventListener(CONNECTION_CHANGED_EVENT, handler)
+ return () => window.removeEventListener(CONNECTION_CHANGED_EVENT, handler)
}
function useConcivConnected(): boolean {
return useSyncExternalStore(
- subscribe,
+ subscribeConnection,
() => connected,
() => false,
)
}
+const panelCommands = createEventBusClient({
+ pluginId: PANEL_PLUGIN_ID,
+ reconnectEveryMs: 500,
+ maxRetries: 60,
+})
+
+function useBusState(): EventBusClientState {
+ return useSyncExternalStore(panelCommands.subscribe, panelCommands.getState, () => 'idle')
+}
+
export function TryLiveButton() {
const isConnected = useConcivConnected()
- const open = () => window.dispatchEvent(new Event('conciv:open-panel'))
+ const busState = useBusState()
+ const open = () => panelCommands.emit('open', undefined)
return (
-
)
diff --git a/apps/site/src/lib/mount-live-widget.ts b/apps/site/src/lib/mount-live-widget.ts
index c28c88871..169302c48 100644
--- a/apps/site/src/lib/mount-live-widget.ts
+++ b/apps/site/src/lib/mount-live-widget.ts
@@ -1,6 +1,13 @@
+import {PANEL_TOGGLED_EVENT, type WidgetPanelToggledDetail} from '@conciv/protocol/event-bus'
import {dismissTry, getTrySession} from './try-session.functions'
import {shouldAutoOpen, shouldDismissOnClose} from './try-state'
+declare global {
+ interface WindowEventMap {
+ [PANEL_TOGGLED_EVENT]: CustomEvent
+ }
+}
+
function ensureWidgetMeta(defaultOpen: boolean): void {
if (document.querySelector('meta[name="pw-widget"]')) return
const meta = document.createElement('meta')
@@ -23,12 +30,11 @@ export async function mountLiveWidget(opts: {widgetOpen: boolean; tryParam: bool
import('@conciv/extension-try-it/client'),
])
if (document.querySelector('[data-conciv-root]')) return
- embed.mountConciv([terminal.default, tryItModule.tryIt({token})])
- window.dispatchEvent(new Event('conciv:widget-mounted'))
+ await embed.mountConciv([terminal.default, tryItModule.tryIt({token})])
let hasBeenOpen = false
- window.addEventListener('conciv:panel-toggled', (event) => {
- const detail = (event as CustomEvent<{open: boolean; connected: boolean}>).detail
+ window.addEventListener(PANEL_TOGGLED_EVENT, (event) => {
+ const detail = event.detail
if (!detail) return
if (detail.open) {
hasBeenOpen = true
diff --git a/apps/site/src/lib/try-state.ts b/apps/site/src/lib/try-state.ts
index dbda08cab..3370c0a2e 100644
--- a/apps/site/src/lib/try-state.ts
+++ b/apps/site/src/lib/try-state.ts
@@ -10,3 +10,9 @@ export function shouldAutoOpen(opts: {
export function shouldDismissOnClose(opts: {hasBeenOpen: boolean; connected: boolean}): boolean {
return opts.hasBeenOpen && !opts.connected
}
+
+export function tryButtonLabel(opts: {connected: boolean; pending: boolean}): string {
+ if (opts.connected) return 'Open agent panel'
+ if (opts.pending) return 'Opening…'
+ return 'Try it live'
+}
diff --git a/apps/site/test/live-connect.it.test.ts b/apps/site/test/live-connect.it.test.ts
index c821f14cc..88f00be52 100644
--- a/apps/site/test/live-connect.it.test.ts
+++ b/apps/site/test/live-connect.it.test.ts
@@ -3,6 +3,7 @@ import {expect as expectLocator} from 'playwright/test'
import {createFakeHarness} from '@conciv/harness-testkit'
import {runConnect} from '@conciv/try'
import type {Engine} from '@conciv/core/start'
+import type {Page, Locator} from 'playwright/test'
import {createSiteTest} from './site-fixture.js'
const SITE_PORT = 8787
@@ -17,12 +18,34 @@ afterAll(async () => {
await engine?.stop()
})
+async function openLandingOnConnectSteps(page: Page): Promise {
+ await page.goto(ORIGIN, {waitUntil: 'domcontentloaded'})
+ const panel = page.getByRole('dialog', {name: 'conciv chat agent'})
+ await expectLocator(panel.getByText('Drive this page with your agent.')).toBeVisible({timeout: 20_000})
+ return panel
+}
+
+async function dismissAndReload(page: Page, panel: Locator): Promise {
+ await page.getByRole('button', {name: 'Minimize conciv chat'}).click()
+ await expectLocator(panel).toBeHidden({timeout: 10_000})
+ await page.reload({waitUntil: 'domcontentloaded'})
+ await expectLocator(page.getByRole('button', {name: 'Open conciv chat'})).toBeVisible({timeout: 20_000})
+ expect(await panel.isVisible()).toBe(false)
+}
+
test.describe('widget-native live connect on the built site', () => {
+ test('opens the panel for a click that lands before the widget bundle has mounted', async ({browser}) => {
+ const page = await browser.newPage()
+ await page.goto(`${ORIGIN}/?widget=false`, {waitUntil: 'domcontentloaded'})
+ await page.getByRole('button', {name: /Try it live/i}).click()
+ await expectLocator(page.getByRole('dialog', {name: 'conciv chat agent'})).toBeVisible({timeout: 20_000})
+
+ await page.close()
+ }, 60_000)
+
test('boots the widget into connect steps and hands off in place to live chat', async ({browser}) => {
const page = await browser.newPage()
- await page.goto(ORIGIN, {waitUntil: 'domcontentloaded'})
- const panel = page.getByRole('dialog', {name: 'conciv chat agent'})
- await expectLocator(panel.getByText('Drive this page with your agent.')).toBeVisible({timeout: 20_000})
+ const panel = await openLandingOnConnectSteps(page)
const command = await panel
.getByText(/^npx @conciv\/try --token \S+$/)
@@ -65,19 +88,22 @@ test.describe('widget-native live connect on the built site', () => {
test('remembers a pre-connect dismissal, and ?try=1 forces the panel open again', async ({browser}) => {
const page = await browser.newPage()
- await page.goto(ORIGIN, {waitUntil: 'domcontentloaded'})
- const panel = page.getByRole('dialog', {name: 'conciv chat agent'})
+ const panel = await openLandingOnConnectSteps(page)
+ await dismissAndReload(page, panel)
+
+ await page.goto(`${ORIGIN}/?try=1`, {waitUntil: 'domcontentloaded'})
await expectLocator(panel.getByText('Drive this page with your agent.')).toBeVisible({timeout: 20_000})
+ await page.close()
+ }, 90_000)
- await page.getByRole('button', {name: 'Minimize conciv chat'}).click()
- await expectLocator(panel).toBeHidden({timeout: 10_000})
+ test('the button reopens the panel after a dismiss-then-reload re-entry', async ({browser}) => {
+ const page = await browser.newPage()
+ const panel = await openLandingOnConnectSteps(page)
+ await dismissAndReload(page, panel)
- await page.reload({waitUntil: 'domcontentloaded'})
- await expectLocator(page.getByRole('button', {name: 'Open conciv chat'})).toBeVisible({timeout: 20_000})
- expect(await panel.isVisible()).toBe(false)
+ await page.getByRole('button', {name: /Try it live/i}).click()
+ await expectLocator(panel).toBeVisible({timeout: 20_000})
- await page.goto(`${ORIGIN}/?try=1`, {waitUntil: 'domcontentloaded'})
- await expectLocator(panel.getByText('Drive this page with your agent.')).toBeVisible({timeout: 20_000})
await page.close()
}, 90_000)
})
diff --git a/apps/site/test/mobile-gating.it.test.ts b/apps/site/test/mobile-gating.it.test.ts
index fcc45d2b2..90ddfa378 100644
--- a/apps/site/test/mobile-gating.it.test.ts
+++ b/apps/site/test/mobile-gating.it.test.ts
@@ -29,6 +29,8 @@ test.describe('landing gates the dev-only demo behind a non-mobile pointer', ()
await expectLocator(page.getByRole('button', {name: 'Copy install command'})).toHaveCount(0, {timeout: 20_000})
await expectLocator(page.getByRole('button', {name: /Try it live/i})).toHaveCount(0)
await expectLocator(page.locator('[data-conciv-root]')).toHaveCount(0)
+ await expectLocator(page.getByText('desktop-only')).toBeVisible()
+ await expectLocator(page.getByRole('link', {name: 'Read the quick start →'})).toBeVisible()
await context.close()
}, 60_000)
diff --git a/apps/site/test/try-state.test.ts b/apps/site/test/try-state.test.ts
new file mode 100644
index 000000000..8749b3097
--- /dev/null
+++ b/apps/site/test/try-state.test.ts
@@ -0,0 +1,44 @@
+import {describe, expect, it} from 'vitest'
+import {shouldAutoOpen, shouldDismissOnClose, tryButtonLabel} from '../src/lib/try-state'
+
+describe('shouldAutoOpen', () => {
+ it('opens for a fresh desktop visitor', () => {
+ expect(shouldAutoOpen({widgetOpen: true, tryParam: false, dismissed: false, widgetPresent: false})).toBe(true)
+ })
+
+ it('stays closed once the visitor dismissed the panel', () => {
+ expect(shouldAutoOpen({widgetOpen: true, tryParam: false, dismissed: true, widgetPresent: false})).toBe(false)
+ })
+
+ it('stays closed when the widget is already present', () => {
+ expect(shouldAutoOpen({widgetOpen: true, tryParam: false, dismissed: false, widgetPresent: true})).toBe(false)
+ })
+})
+
+describe('shouldDismissOnClose', () => {
+ it('dismisses when a visitor closes an opened panel without connecting', () => {
+ expect(shouldDismissOnClose({hasBeenOpen: true, connected: false})).toBe(true)
+ })
+
+ it('does not dismiss once connected', () => {
+ expect(shouldDismissOnClose({hasBeenOpen: true, connected: true})).toBe(false)
+ })
+
+ it('does not dismiss a panel that was never opened', () => {
+ expect(shouldDismissOnClose({hasBeenOpen: false, connected: false})).toBe(false)
+ })
+})
+
+describe('tryButtonLabel', () => {
+ it('shows the connected label regardless of a stale pending intent', () => {
+ expect(tryButtonLabel({connected: true, pending: true})).toBe('Open agent panel')
+ })
+
+ it('shows a working state for a pre-mount click', () => {
+ expect(tryButtonLabel({connected: false, pending: true})).toBe('Opening…')
+ })
+
+ it('shows the default call to action otherwise', () => {
+ expect(tryButtonLabel({connected: false, pending: false})).toBe('Try it live')
+ })
+})
diff --git a/packages/embed/src/mount-impl.tsx b/packages/embed/src/mount-impl.tsx
index e73769ea9..ad2fb8498 100644
--- a/packages/embed/src/mount-impl.tsx
+++ b/packages/embed/src/mount-impl.tsx
@@ -33,7 +33,15 @@ declare global {
}
}
-type BootResult = {dispose: () => void; rebind?: (apiBase: string) => void}
+type BootResult = {dispose: () => void; rebind?: (apiBase: string) => void; interactive: Promise}
+
+function createInteractiveSignal(): {promise: Promise; notify: () => void} {
+ let notify = () => {}
+ const promise = new Promise((resolve) => {
+ notify = resolve
+ })
+ return {promise, notify}
+}
function metaContent(name: string): string {
return document.querySelector(`meta[name="${name}"]`)?.content ?? ''
@@ -97,6 +105,7 @@ function bootNormal(config: BootNormalConfig): BootResult {
const restore: {apply: (href: string) => void} = {apply: () => {}}
const storage = makeNavigationStorage(rpc, (href) => restore.apply(href))
const hostRouter = window.__TSR_ROUTER__
+ const interactive = createInteractiveSignal()
const router = createConcivRouter({
rpc,
history: createWebStorageHistory({storage}),
@@ -109,6 +118,7 @@ function bootNormal(config: BootNormalConfig): BootResult {
grabProvider: config.grabProvider,
apiBase,
connectionGeneration,
+ notifyInteractive: interactive.notify,
})
window.__TSR_ROUTER__ = hostRouter
const driver = makeDomPageDriver({tools: mountedClientTools(router), effects: mountedClientEffects(router)})
@@ -154,8 +164,9 @@ function bootNormal(config: BootNormalConfig): BootResult {
driver.dispose,
reachabilityRoot.dispose,
closeConnection,
+ interactive.notify,
]
- return {dispose: () => runDisposers(disposers), rebind}
+ return {dispose: () => runDisposers(disposers), rebind, interactive: interactive.promise}
}
type BootConnectConfig = {
@@ -185,6 +196,7 @@ function bootConnect(config: BootConnectConfig): BootResult {
}).dispose
}
const hostRouter = window.__TSR_ROUTER__
+ const interactive = createInteractiveSignal()
const router = createConcivRouter({
rpc: deferred.rpc,
history: createMemoryHistory({initialEntries: [connectPath(config.settings)]}),
@@ -197,6 +209,7 @@ function bootConnect(config: BootConnectConfig): BootResult {
disconnect: makeDisconnect(() => boundApiBase),
grabProvider: config.grabProvider,
apiBase,
+ notifyInteractive: interactive.notify,
})
window.__TSR_ROUTER__ = hostRouter
const driver = makeDomPageDriver({tools: mountedClientTools(router), effects: mountedClientEffects(router)})
@@ -213,8 +226,9 @@ function bootConnect(config: BootConnectConfig): BootResult {
driver.dispose,
reachabilityRoot.dispose,
deferred.close,
+ interactive.notify,
]
- return {dispose: () => runDisposers(disposers)}
+ return {dispose: () => runDisposers(disposers), interactive: interactive.promise}
}
async function boot(root: ShadowRoot, init: ConcivInit): Promise {
@@ -252,6 +266,7 @@ export function mountImpl(
}
disposeBoot = result.dispose
rebindBoot = result.rebind
+ return result.interactive
})
const rebind = async (apiBase: string): Promise => {
await ready
diff --git a/packages/embed/src/mount.ts b/packages/embed/src/mount.ts
index 9cabd9974..8e1b03e9e 100644
--- a/packages/embed/src/mount.ts
+++ b/packages/embed/src/mount.ts
@@ -1,6 +1,7 @@
import type {AnyExtension} from '@conciv/extension'
import type {GrabProvider} from '@conciv/grab'
import type {ConcivSettingsInit} from '@conciv/protocol/config-types'
+import type {EventBusClient, PanelCommandEventMap} from '@conciv/protocol/event-bus'
export type {ConcivSettingsInit} from '@conciv/protocol/config-types'
@@ -24,16 +25,26 @@ export type ConcivHandle = {
type MountState = 'unmounted' | 'mounting' | 'mounted'
-function dispatch(name: string, detail?: Record): void {
- if (typeof window === 'undefined') return
- window.dispatchEvent(new CustomEvent(name, {detail}))
-}
+type PanelCommandName = keyof PanelCommandEventMap & string
export function createConciv(init: ConcivInit = {}): ConcivHandle {
let state: MountState = 'unmounted'
let abort: AbortController | undefined
let teardown: (() => void) | undefined
let rebindImpl: ((apiBase: string) => Promise) | undefined
+ let panelCommandsPromise: Promise> | null = null
+
+ function panelCommands(): Promise> {
+ if (panelCommandsPromise) return panelCommandsPromise
+ panelCommandsPromise = import('@conciv/protocol/event-bus').then((eventBus) =>
+ eventBus.createEventBusClient({
+ pluginId: eventBus.PANEL_PLUGIN_ID,
+ reconnectEveryMs: 500,
+ maxRetries: 60,
+ }),
+ )
+ return panelCommandsPromise
+ }
async function mount(el: HTMLElement): Promise {
if (typeof document === 'undefined') return
@@ -67,18 +78,32 @@ export function createConciv(init: ConcivInit = {}): ConcivHandle {
teardown = undefined
rebindImpl = undefined
state = 'unmounted'
+ if (panelCommandsPromise) {
+ void panelCommandsPromise.then((client) => client.dispose()).catch(() => {})
+ panelCommandsPromise = null
+ }
+ }
+
+ function emitPanelCommand(name: PanelCommandName): void {
+ if (typeof window === 'undefined') return
+ void panelCommands()
+ .then((client) => client.emit(name, undefined))
+ .catch((error: unknown) => {
+ panelCommandsPromise = null
+ console.error('[conciv] panel command failed', error)
+ })
}
function open(): void {
- dispatch('conciv:open-panel')
+ emitPanelCommand('open')
}
function close(): void {
- dispatch('conciv:close-panel')
+ emitPanelCommand('close')
}
function toggle(): void {
- dispatch('conciv:toggle-panel')
+ emitPanelCommand('toggle')
}
async function rebind(apiBase: string): Promise {
@@ -89,13 +114,16 @@ export function createConciv(init: ConcivInit = {}): ConcivHandle {
return {mount, unmount, open, close, toggle, rebind}
}
-export function mountConciv(extensions: AnyExtension[]): void {
- if (typeof document === 'undefined') return
- if (document.querySelector('[data-conciv-script-root]')) return
+export function mountConciv(extensions: AnyExtension[]): Promise {
+ if (typeof document === 'undefined') return Promise.resolve()
+ if (document.querySelector('[data-conciv-script-root]')) return Promise.resolve()
const el = document.createElement('div')
el.setAttribute('data-conciv-script-root', '')
document.body.appendChild(el)
- void createConciv({extensions})
+ return createConciv({extensions})
.mount(el)
- .catch(() => undefined)
+ .catch((error: unknown) => {
+ el.remove()
+ throw error
+ })
}
diff --git a/packages/embed/tests/e2e/mount-ready.it.test.ts b/packages/embed/tests/e2e/mount-ready.it.test.ts
new file mode 100644
index 000000000..a8b608887
--- /dev/null
+++ b/packages/embed/tests/e2e/mount-ready.it.test.ts
@@ -0,0 +1,41 @@
+import {expect, test, type Page} from '@playwright/test'
+import {bootEmbedKit, type EmbedKit} from '../helpers/boot.js'
+import {handleHostPage} from '../helpers/host.js'
+import {serveHost} from '@conciv/extension-testkit/serve-host'
+import {chatBox} from './helpers/chat.js'
+
+let kit: EmbedKit
+let host: {base: string; close: () => Promise}
+
+test.beforeAll(async () => {
+ kit = await bootEmbedKit({text: 'ready contract'})
+ host = await serveHost(() => handleHostPage())
+})
+
+test.afterAll(async () => {
+ await host.close()
+ await kit.cleanup()
+})
+
+async function openPage(page: Page): Promise {
+ await page.goto(host.base, {waitUntil: 'domcontentloaded'})
+ return page
+}
+
+test.describe('mount() readiness contract', () => {
+ test('an open dispatched right after mount() resolves opens the panel, never dropped', async ({page}) => {
+ await openPage(page)
+ await page.evaluate(async (base) => {
+ const el = document.createElement('div')
+ document.body.appendChild(el)
+ const handle = window.ConcivHandle.makeHandle(base)
+ await handle.mount(el)
+ window.dispatchEvent(
+ new CustomEvent('conciv-dispatch-event', {
+ detail: {type: 'panel:open', payload: undefined, pluginId: 'panel'},
+ }),
+ )
+ }, kit.base)
+ await expect(chatBox(page)).toBeVisible({timeout: 10_000})
+ })
+})
diff --git a/packages/embed/tests/e2e/panel-focus.it.test.ts b/packages/embed/tests/e2e/panel-focus.it.test.ts
index 4a1af9727..6ec74145c 100644
--- a/packages/embed/tests/e2e/panel-focus.it.test.ts
+++ b/packages/embed/tests/e2e/panel-focus.it.test.ts
@@ -51,7 +51,13 @@ async function openPanelOverFocusedHostButton(page: Page): Promise
const hostButton = page.getByRole('button', {name: 'Host action'})
await hostButton.click()
await expect(hostButton).toBeFocused()
- await page.evaluate(() => window.dispatchEvent(new Event('conciv:open-panel')))
+ await page.evaluate(() =>
+ window.dispatchEvent(
+ new CustomEvent('conciv-dispatch-event', {
+ detail: {type: 'panel:open', payload: undefined, pluginId: 'panel'},
+ }),
+ ),
+ )
await expect(composer(page)).toBeVisible({timeout: 30_000})
return {host, page, hostButton}
}
diff --git a/packages/embed/tests/fixtures/global-entry.ts b/packages/embed/tests/fixtures/global-entry.ts
index f8cbb6d2b..444020d94 100644
--- a/packages/embed/tests/fixtures/global-entry.ts
+++ b/packages/embed/tests/fixtures/global-entry.ts
@@ -2,4 +2,4 @@ import terminal from '@conciv/extension-terminal/client'
import recorder from '@conciv/extension-recorder/client'
import {mountConciv} from '../../src/mount.js'
-mountConciv([terminal, recorder])
+void mountConciv([terminal, recorder])
diff --git a/packages/embed/tests/unit/native-bundle.test.ts b/packages/embed/tests/unit/native-bundle.test.ts
index d1764d9ff..78c731555 100644
--- a/packages/embed/tests/unit/native-bundle.test.ts
+++ b/packages/embed/tests/unit/native-bundle.test.ts
@@ -18,7 +18,7 @@ describe('embed native bundle', () => {
})
it('bundles the conciv app graph into the native entry', () => {
- expect(nativeBundle.includes('conciv:open-panel')).toBe(true)
+ expect(nativeBundle.includes('conciv chat agent')).toBe(true)
})
it('leaves no runtime import external in the self-contained native bundle', () => {
diff --git a/packages/extension-compiler/src/extensions.ts b/packages/extension-compiler/src/extensions.ts
index 027334cd1..eb61bdd47 100644
--- a/packages/extension-compiler/src/extensions.ts
+++ b/packages/extension-compiler/src/extensions.ts
@@ -42,7 +42,7 @@ export function extensionsModuleSource(
`const builtinEntries = [${builtinNames.map((n, i) => `{extension: ${n}, source: 'builtin:${i}'}`).join(', ')}]`,
`const picked = dedupeExtensions([...builtinEntries, ...folderEntries])`,
`for (const d of picked.dropped) console.warn('conciv extension dropped:', d.source, d.reason)`,
- `mountConciv(picked.extensions)`,
+ `mountConciv(picked.extensions).catch((error) => console.error('[conciv] widget failed to start', error))`,
'',
].join('\n')
}
diff --git a/packages/extensions/ios/package.json b/packages/extensions/ios/package.json
index 99b6c1e04..6a7681fd1 100644
--- a/packages/extensions/ios/package.json
+++ b/packages/extensions/ios/package.json
@@ -56,6 +56,7 @@
"dependencies": {
"@conciv/extension": "workspace:^",
"@conciv/grab": "workspace:^",
+ "@conciv/protocol": "workspace:^",
"zod": "^4.4.3"
},
"devDependencies": {
diff --git a/packages/extensions/ios/src/client.tsx b/packages/extensions/ios/src/client.tsx
index 2891da111..f20776b6c 100644
--- a/packages/extensions/ios/src/client.tsx
+++ b/packages/extensions/ios/src/client.tsx
@@ -1,5 +1,6 @@
import {createSignal, Show, type JSX} from 'solid-js'
import {defineExtension} from '@conciv/extension'
+import {createEventBusClient, PANEL_PLUGIN_ID, type PanelCommandEventMap} from '@conciv/protocol/event-bus'
import type {ElementRect, GrabActions, GrabProvider} from '@conciv/grab'
import {createBridgeClient, type BridgeClient, type BridgeTransport} from './shared/bridge-client.js'
import type {GrabMode} from './shared/bridge.js'
@@ -42,6 +43,12 @@ function dispatch(name: string, detail?: Record): void {
window.dispatchEvent(new CustomEvent(name, {detail}))
}
+const panelCommands = createEventBusClient({
+ pluginId: PANEL_PLUGIN_ID,
+ reconnectEveryMs: 500,
+ maxRetries: 60,
+})
+
function makeClientId(): string {
const random = globalThis.crypto?.randomUUID?.()
return `native-${random ?? Math.random().toString(36).slice(2)}`
@@ -94,8 +101,8 @@ function ensureBridge(): BridgeClient | null {
},
clientId: makeClientId(),
boundApiBase: nativePageBase(window.location),
- ensureOpen: () => dispatch('conciv:open-panel'),
- ensureClose: () => dispatch('conciv:close-panel'),
+ ensureOpen: () => panelCommands.emit('open', undefined),
+ ensureClose: () => panelCommands.emit('close', undefined),
onRebind: (apiBase) => dispatch('conciv:rebind', {apiBase}),
onIncompatible: (info) => setIncompatible(info),
onGrabbableChanged: (value) => setGrabbable(value),
@@ -115,13 +122,13 @@ export function makeNativeGrabProvider(): GrabProvider {
if (!engine) return null
const token = ++requestSeq
activeRequest = token
- dispatch('conciv:close-panel')
+ panelCommands.emit('close', undefined)
try {
return await engine.pick(mode)
} finally {
if (activeRequest === token) {
activeRequest = 0
- dispatch('conciv:open-panel')
+ panelCommands.emit('open', undefined)
}
}
}
diff --git a/packages/extensions/tanstack/test/host/main.tsx b/packages/extensions/tanstack/test/host/main.tsx
index 89b1a93b0..3ada8131b 100644
--- a/packages/extensions/tanstack/test/host/main.tsx
+++ b/packages/extensions/tanstack/test/host/main.tsx
@@ -152,5 +152,5 @@ function App() {
const rootElement = document.getElementById('root')
if (rootElement) {
createRoot(rootElement).render()
- mountConciv([tanstackExtension, connectProbe])
+ void mountConciv([tanstackExtension, connectProbe])
}
diff --git a/packages/plugin/src/nextjs-widget.ts b/packages/plugin/src/nextjs-widget.ts
index 2bea6a608..f8bc25333 100644
--- a/packages/plugin/src/nextjs-widget.ts
+++ b/packages/plugin/src/nextjs-widget.ts
@@ -11,14 +11,18 @@ async function startWidget(): Promise {
])
const picked = dedupeExtensions(entries)
for (const drop of picked.dropped) console.warn('conciv extension dropped:', drop.source, drop.reason)
- mountConciv(picked.extensions)
+ await mountConciv(picked.extensions)
}
if (typeof window !== 'undefined' && port && process.env.NODE_ENV !== 'production') {
if (document.readyState === 'loading') {
- document.addEventListener('DOMContentLoaded', () => void startWidget(), {once: true})
+ document.addEventListener(
+ 'DOMContentLoaded',
+ () => startWidget().catch((error) => console.error('[conciv] widget failed to start', error)),
+ {once: true},
+ )
} else {
- void startWidget()
+ startWidget().catch((error) => console.error('[conciv] widget failed to start', error))
}
}
diff --git a/packages/protocol/package.json b/packages/protocol/package.json
index c6bb52d79..41b3954e9 100644
--- a/packages/protocol/package.json
+++ b/packages/protocol/package.json
@@ -92,6 +92,10 @@
"./state-types": {
"types": "./dist/state-types.d.ts",
"import": "./dist/state-types.js"
+ },
+ "./event-bus": {
+ "types": "./dist/event-bus.d.ts",
+ "import": "./dist/event-bus.js"
}
},
"publishConfig": {
diff --git a/packages/protocol/src/event-bus.ts b/packages/protocol/src/event-bus.ts
new file mode 100644
index 000000000..6f2fa02ea
--- /dev/null
+++ b/packages/protocol/src/event-bus.ts
@@ -0,0 +1,279 @@
+declare const window: EventTarget | undefined
+
+declare global {
+ var __CONCIV_EVENT_BUS_TARGET__: EventTarget | undefined
+}
+
+export const DISPATCH_EVENT = 'conciv-dispatch-event'
+export const CONNECT_EVENT = 'conciv-connect'
+export const CONNECT_SUCCESS_EVENT = 'conciv-connect-success'
+export const GLOBAL_EVENT = 'conciv-global'
+
+function sharedEventTarget(): EventTarget {
+ if (typeof window !== 'undefined') return window
+ if (globalThis.__CONCIV_EVENT_BUS_TARGET__ === undefined) globalThis.__CONCIV_EVENT_BUS_TARGET__ = new EventTarget()
+ return globalThis.__CONCIV_EVENT_BUS_TARGET__
+}
+
+export type EventBusTarget = () => EventTarget
+
+export type EventBusScheduler = {
+ setInterval: (callback: () => void, ms: number) => unknown
+ clearInterval: (id: unknown) => void
+}
+
+function isTimerHandle(id: unknown): id is Parameters[0] {
+ return id !== null
+}
+
+function defaultScheduler(): EventBusScheduler {
+ return {
+ setInterval: (callback, ms) => setInterval(callback, ms),
+ clearInterval: (id) => {
+ if (!isTimerHandle(id)) return
+ clearInterval(id)
+ },
+ }
+}
+
+export type EventBusEnvelope = {
+ type: string
+ payload: TPayload
+ pluginId: string
+}
+
+function customEventDetail(event: Event): TDetail | null {
+ if (!(event instanceof CustomEvent)) return null
+ const detail: TDetail | null | undefined = event.detail
+ return detail ?? null
+}
+
+function readEnvelope(event: Event): EventBusEnvelope | null {
+ const detail = customEventDetail(event)
+ if (typeof detail !== 'object' || detail === null) return null
+ if (!('type' in detail) || !('payload' in detail) || !('pluginId' in detail)) return null
+ const {type, payload, pluginId} = detail
+ if (typeof type !== 'string' || typeof pluginId !== 'string') return null
+ return {type, payload, pluginId}
+}
+
+export type EventBusOptions = {
+ target?: EventBusTarget
+}
+
+export type EventBus = {
+ start: () => void
+ stop: () => void
+}
+
+export function createEventBus(options: EventBusOptions = {}): EventBus {
+ const target = options.target ?? sharedEventTarget
+ let started = false
+
+ function redispatch(event: Event): void {
+ const envelope = readEnvelope(event)
+ if (!envelope) return
+ target().dispatchEvent(new CustomEvent(envelope.type, {detail: envelope}))
+ target().dispatchEvent(new CustomEvent(GLOBAL_EVENT, {detail: envelope}))
+ }
+
+ function acknowledgeConnect(): void {
+ target().dispatchEvent(new CustomEvent(CONNECT_SUCCESS_EVENT))
+ }
+
+ function start(): void {
+ if (started) return
+ started = true
+ target().addEventListener(DISPATCH_EVENT, redispatch)
+ target().addEventListener(CONNECT_EVENT, acknowledgeConnect)
+ }
+
+ function stop(): void {
+ if (!started) return
+ started = false
+ target().removeEventListener(DISPATCH_EVENT, redispatch)
+ target().removeEventListener(CONNECT_EVENT, acknowledgeConnect)
+ }
+
+ return {start, stop}
+}
+
+export type EventBusClientState = 'idle' | 'connecting' | 'ready' | 'failed'
+
+export type EventBusClientOptions = {
+ pluginId: string
+ target?: EventBusTarget
+ scheduler?: EventBusScheduler
+ reconnectEveryMs?: number
+ maxRetries?: number
+}
+
+export type EventBusClient> = {
+ emit: (name: TName, payload: TEventMap[TName]) => void
+ on: (
+ name: TName,
+ handler: (event: EventBusEnvelope) => void,
+ ) => () => void
+ onAll: (handler: (event: EventBusEnvelope) => void) => () => void
+ getState: () => EventBusClientState
+ subscribe: (listener: () => void) => () => void
+ dispose: () => void
+}
+
+export function createEventBusClient>(
+ options: EventBusClientOptions,
+): EventBusClient {
+ const target = options.target ?? sharedEventTarget
+ const scheduler = options.scheduler ?? defaultScheduler()
+ const reconnectEveryMs = options.reconnectEveryMs ?? 300
+ const maxRetries = options.maxRetries ?? 5
+ const pluginId = options.pluginId
+
+ let state: EventBusClientState = 'idle'
+ let queue: EventBusEnvelope[] = []
+ let retryCount = 0
+ let intervalId: unknown = null
+ const listeners = new Set<() => void>()
+
+ function eventName(name: string): string {
+ return `${pluginId}:${name}`
+ }
+
+ function envelopeFor(
+ name: TName,
+ payload: TEventMap[TName],
+ ): EventBusEnvelope {
+ return {type: eventName(name), payload, pluginId}
+ }
+
+ function notify(): void {
+ for (const listener of listeners) listener()
+ }
+
+ function sendToBus(envelope: EventBusEnvelope): void {
+ target().dispatchEvent(new CustomEvent(DISPATCH_EVENT, {detail: envelope}))
+ }
+
+ function flushQueue(): void {
+ const pending = queue
+ queue = []
+ for (const envelope of pending) sendToBus(envelope)
+ }
+
+ function stopConnectLoop(): void {
+ if (intervalId === null) return
+ scheduler.clearInterval(intervalId)
+ intervalId = null
+ }
+
+ function onConnectSuccess(): void {
+ target().removeEventListener(CONNECT_SUCCESS_EVENT, onConnectSuccess)
+ stopConnectLoop()
+ state = 'ready'
+ flushQueue()
+ notify()
+ }
+
+ function attemptConnect(): void {
+ if (retryCount >= maxRetries) {
+ target().removeEventListener(CONNECT_SUCCESS_EVENT, onConnectSuccess)
+ stopConnectLoop()
+ state = 'failed'
+ queue = []
+ notify()
+ return
+ }
+ retryCount += 1
+ target().dispatchEvent(new CustomEvent(CONNECT_EVENT))
+ }
+
+ function startHandshake(): void {
+ state = 'connecting'
+ target().addEventListener(CONNECT_SUCCESS_EVENT, onConnectSuccess)
+ attemptConnect()
+ if (state === 'connecting') intervalId = scheduler.setInterval(attemptConnect, reconnectEveryMs)
+ notify()
+ }
+
+ function emit(name: TName, payload: TEventMap[TName]): void {
+ const envelope = envelopeFor(name, payload)
+ if (state === 'ready') {
+ sendToBus(envelope)
+ return
+ }
+ if (state === 'failed') {
+ retryCount = 0
+ queue.push(envelope)
+ startHandshake()
+ return
+ }
+ queue.push(envelope)
+ if (state === 'idle') startHandshake()
+ }
+
+ function on(
+ name: TName,
+ handler: (event: EventBusEnvelope) => void,
+ ): () => void {
+ const fullName = eventName(name)
+ const listener = (event: Event) => {
+ const envelope = customEventDetail>(event)
+ if (!envelope) return
+ handler(envelope)
+ }
+ target().addEventListener(fullName, listener)
+ return () => target().removeEventListener(fullName, listener)
+ }
+
+ function onAll(handler: (event: EventBusEnvelope) => void): () => void {
+ const listener = (event: Event) => {
+ const envelope = readEnvelope(event)
+ if (!envelope || envelope.pluginId !== pluginId) return
+ handler(envelope)
+ }
+ target().addEventListener(GLOBAL_EVENT, listener)
+ return () => target().removeEventListener(GLOBAL_EVENT, listener)
+ }
+
+ function getState(): EventBusClientState {
+ return state
+ }
+
+ function subscribe(listener: () => void): () => void {
+ listeners.add(listener)
+ return () => listeners.delete(listener)
+ }
+
+ function dispose(): void {
+ stopConnectLoop()
+ target().removeEventListener(CONNECT_SUCCESS_EVENT, onConnectSuccess)
+ listeners.clear()
+ queue = []
+ }
+
+ return {emit, on, onAll, getState, subscribe, dispose}
+}
+
+export const PANEL_PLUGIN_ID = 'panel'
+
+export type PanelCommandEventMap = {
+ open: undefined
+ close: undefined
+ toggle: undefined
+}
+
+export const CONNECTION_CHANGED_EVENT = 'conciv:connection-changed'
+export const PANEL_TOGGLED_EVENT = 'conciv:panel-toggled'
+
+export type WidgetConnectionChangedDetail = {connected: boolean}
+
+export type WidgetPanelToggledDetail = {
+ open: boolean
+ connected: boolean
+ mascotRect: {x: number; y: number; width: number; height: number} | null
+}
+
+export type WidgetStatusEventMap = {
+ [CONNECTION_CHANGED_EVENT]: WidgetConnectionChangedDetail
+ [PANEL_TOGGLED_EVENT]: WidgetPanelToggledDetail
+}
diff --git a/packages/protocol/test/event-bus.test.ts b/packages/protocol/test/event-bus.test.ts
new file mode 100644
index 000000000..6618dbafb
--- /dev/null
+++ b/packages/protocol/test/event-bus.test.ts
@@ -0,0 +1,216 @@
+import {describe, expect, it} from 'vitest'
+import {
+ createEventBus,
+ createEventBusClient,
+ GLOBAL_EVENT,
+ type EventBusEnvelope,
+ type EventBusScheduler,
+} from '../src/event-bus.js'
+
+type CommandMap = {
+ open: undefined
+ close: undefined
+}
+
+function createManualScheduler(): {scheduler: EventBusScheduler; tick: () => void; intervalCount: () => number} {
+ const callbacks = new Map void>()
+ let nextId = 0
+ return {
+ scheduler: {
+ setInterval: (callback) => {
+ const id = nextId
+ nextId += 1
+ callbacks.set(id, callback)
+ return id
+ },
+ clearInterval: (id) => {
+ if (typeof id !== 'number') return
+ callbacks.delete(id)
+ },
+ },
+ tick: () => {
+ for (const callback of callbacks.values()) callback()
+ },
+ intervalCount: () => callbacks.size,
+ }
+}
+
+describe('createEventBusClient / createEventBus', () => {
+ it('queues emits made before the handshake completes and flushes them in order once acked', () => {
+ const target = new EventTarget()
+ const {scheduler, tick} = createManualScheduler()
+ const received: unknown[] = []
+ const bus = createEventBus({target: () => target})
+ const listener = createEventBusClient({pluginId: 'panel', target: () => target, scheduler})
+ listener.on('open', (event) => received.push([event.type, event.pluginId]))
+ listener.on('close', (event) => received.push([event.type, event.pluginId]))
+
+ const client = createEventBusClient({pluginId: 'panel', target: () => target, scheduler})
+ client.emit('open', undefined)
+ client.emit('close', undefined)
+ expect(received).toEqual([])
+ expect(client.getState()).toBe('connecting')
+
+ bus.start()
+ expect(received).toEqual([])
+
+ tick()
+ expect(received).toEqual([
+ ['panel:open', 'panel'],
+ ['panel:close', 'panel'],
+ ])
+ expect(client.getState()).toBe('ready')
+ })
+
+ it('stops retrying once the bus acks the connection', () => {
+ const target = new EventTarget()
+ const {scheduler, tick, intervalCount} = createManualScheduler()
+ const bus = createEventBus({target: () => target})
+ bus.start()
+
+ const client = createEventBusClient({pluginId: 'panel', target: () => target, scheduler})
+ client.emit('open', undefined)
+ expect(client.getState()).toBe('ready')
+ expect(intervalCount()).toBe(0)
+
+ tick()
+ expect(client.getState()).toBe('ready')
+ })
+
+ it('gives up after the bounded retry count and drops the queue', () => {
+ const target = new EventTarget()
+ const {scheduler, tick} = createManualScheduler()
+ const client = createEventBusClient({
+ pluginId: 'panel',
+ target: () => target,
+ scheduler,
+ maxRetries: 2,
+ })
+
+ client.emit('open', undefined)
+ expect(client.getState()).toBe('connecting')
+
+ tick()
+ expect(client.getState()).toBe('connecting')
+
+ tick()
+ expect(client.getState()).toBe('failed')
+
+ const received: unknown[] = []
+ const bus = createEventBus({target: () => target})
+ const listener = createEventBusClient({pluginId: 'panel', target: () => target, scheduler})
+ listener.on('open', (event) => received.push(event))
+ bus.start()
+ expect(received).toEqual([])
+ })
+
+ it('restarts the handshake with a fresh retry budget when emit is called again after failure', () => {
+ const target = new EventTarget()
+ const {scheduler, tick} = createManualScheduler()
+ const client = createEventBusClient({
+ pluginId: 'panel',
+ target: () => target,
+ scheduler,
+ maxRetries: 2,
+ })
+
+ client.emit('open', undefined)
+ tick()
+ tick()
+ expect(client.getState()).toBe('failed')
+
+ client.emit('close', undefined)
+ expect(client.getState()).toBe('connecting')
+
+ const received: unknown[] = []
+ const bus = createEventBus({target: () => target})
+ const listener = createEventBusClient({pluginId: 'panel', target: () => target, scheduler})
+ listener.on('close', (event) => received.push(event.type))
+ bus.start()
+ expect(received).toEqual([])
+
+ tick()
+ expect(received).toEqual(['panel:close'])
+ expect(client.getState()).toBe('ready')
+ })
+
+ it('passes an emit straight through once the client is already connected', () => {
+ const target = new EventTarget()
+ const {scheduler} = createManualScheduler()
+ const received: unknown[] = []
+ const bus = createEventBus({target: () => target})
+ const listener = createEventBusClient({pluginId: 'panel', target: () => target, scheduler})
+ listener.on('open', (event) => received.push(event.payload))
+ bus.start()
+
+ const client = createEventBusClient({pluginId: 'panel', target: () => target, scheduler})
+ client.emit('open', undefined)
+ expect(client.getState()).toBe('ready')
+
+ client.emit('open', undefined)
+ expect(received).toEqual([undefined, undefined])
+ })
+
+ it('re-dispatches every emit as both the specific envelope event and the global event', () => {
+ const target = new EventTarget()
+ const {scheduler} = createManualScheduler()
+ const specific: EventBusEnvelope[] = []
+ const global: EventBusEnvelope[] = []
+ const bus = createEventBus({target: () => target})
+ bus.start()
+ target.addEventListener('panel:open', (event) => {
+ if (event instanceof CustomEvent) specific.push(event.detail)
+ })
+ target.addEventListener(GLOBAL_EVENT, (event) => {
+ if (event instanceof CustomEvent) global.push(event.detail)
+ })
+
+ const client = createEventBusClient({pluginId: 'panel', target: () => target, scheduler})
+ client.emit('open', undefined)
+
+ expect(specific).toEqual([{type: 'panel:open', payload: undefined, pluginId: 'panel'}])
+ expect(global).toEqual([{type: 'panel:open', payload: undefined, pluginId: 'panel'}])
+ })
+
+ it('filters onAll down to the listening client own pluginId', () => {
+ const target = new EventTarget()
+ const {scheduler} = createManualScheduler()
+ const seen: string[] = []
+ const bus = createEventBus({target: () => target})
+ bus.start()
+
+ const listener = createEventBusClient({pluginId: 'panel', target: () => target, scheduler})
+ listener.onAll((event) => seen.push(event.type))
+
+ const panelClient = createEventBusClient({pluginId: 'panel', target: () => target, scheduler})
+ const otherClient = createEventBusClient({pluginId: 'other', target: () => target, scheduler})
+ otherClient.emit('open', undefined)
+ panelClient.emit('close', undefined)
+
+ expect(seen).toEqual(['panel:close'])
+ })
+
+ it('stops delivering to a listener that unsubscribed and to every listener once the bus stops', () => {
+ const target = new EventTarget()
+ const {scheduler} = createManualScheduler()
+ const seen: string[] = []
+ const bus = createEventBus({target: () => target})
+ bus.start()
+
+ const listener = createEventBusClient({pluginId: 'panel', target: () => target, scheduler})
+ const unsubscribe = listener.on('open', (event) => seen.push(event.type))
+
+ const client = createEventBusClient({pluginId: 'panel', target: () => target, scheduler})
+ client.emit('open', undefined)
+ expect(seen).toEqual(['panel:open'])
+
+ unsubscribe()
+ client.emit('open', undefined)
+ expect(seen).toEqual(['panel:open'])
+
+ listener.on('close', (event) => seen.push(event.type))
+ bus.stop()
+ client.emit('close', undefined)
+ expect(seen).toEqual(['panel:open'])
+ })
+})
diff --git a/packages/protocol/tsdown.config.ts b/packages/protocol/tsdown.config.ts
index 64ab878f3..4758852ad 100644
--- a/packages/protocol/tsdown.config.ts
+++ b/packages/protocol/tsdown.config.ts
@@ -20,6 +20,7 @@ export default defineConfig({
'src/tool-view-types.ts',
'src/connect-ports.ts',
'src/state-types.ts',
+ 'src/event-bus.ts',
],
format: 'esm',
noExternal: [/^@tanstack\/ai(\/|$)/],
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 73b055986..dee00eea9 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -2069,6 +2069,9 @@ importers:
'@conciv/grab':
specifier: workspace:^
version: link:../../grab
+ '@conciv/protocol':
+ specifier: workspace:^
+ version: link:../../protocol
zod:
specifier: ^4.4.3
version: 4.4.3