From 91e6fbeec4b9d509d7d24b50a19d6c6166127f96 Mon Sep 17 00:00:00 2001
From: Omri Katz <9701896+omridevk@users.noreply.github.com>
Date: Sat, 15 Aug 2026 23:02:23 +0300
Subject: [PATCH 1/7] fix(site): #502 try-it-live primary CTA + pre-mount click
race
Promotes TryLiveButton to the hero's sole primary CTA and closes the
pre-mount click race root-caused during this PR: embed's mount()
promise was resolving before the widget's root route onMount had
registered its conciv:open-panel listener, so an early click (or a
dismissed visitor's only path back in) was silently dropped even
after switching mount-live-widget.ts off the fire-and-forget
mountConciv() call. packages/embed/src/mount-impl.tsx now threads an
interactive signal through apps/conciv's router context, resolved
from __root.tsx's onMount once the listener is live, and mount()
does not resolve until that fires. Adds a mobile explanatory line in
place of the previous empty gap.
Co-Authored-By: Claude Fable 5
---
.changeset/try-live-panel-ready-contract.md | 5 ++
apps/conciv/src/app/context.ts | 5 ++
apps/conciv/src/router.tsx | 3 +
apps/conciv/src/routes/__root.tsx | 4 ++
apps/conciv/test/helpers/pane-harness.tsx | 1 +
apps/site/src/components/landing/hero.tsx | 11 +++-
.../components/landing/try-live-button.tsx | 64 +++++++++++++++++--
apps/site/src/lib/mount-live-widget.ts | 5 +-
apps/site/src/lib/try-state.ts | 6 ++
apps/site/test/live-connect.it.test.ts | 50 +++++++++++----
apps/site/test/mobile-gating.it.test.ts | 2 +
apps/site/test/try-state.test.ts | 44 +++++++++++++
packages/embed/src/mount-impl.tsx | 19 +++++-
.../embed/tests/e2e/mount-ready.it.test.ts | 36 +++++++++++
14 files changed, 231 insertions(+), 24 deletions(-)
create mode 100644 .changeset/try-live-panel-ready-contract.md
create mode 100644 apps/site/test/try-state.test.ts
create mode 100644 packages/embed/tests/e2e/mount-ready.it.test.ts
diff --git a/.changeset/try-live-panel-ready-contract.md b/.changeset/try-live-panel-ready-contract.md
new file mode 100644
index 000000000..73bb42f43
--- /dev/null
+++ b/.changeset/try-live-panel-ready-contract.md
@@ -0,0 +1,5 @@
+---
+'@conciv/embed': patch
+---
+
+`mount(el)` now resolves only once the widget can actually honor `open()`/`conciv:open-panel` — 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.
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..0d15ffa90 100644
--- a/apps/conciv/src/routes/__root.tsx
+++ b/apps/conciv/src/routes/__root.tsx
@@ -20,6 +20,7 @@ import {
useAppData,
useConnected,
useLayers,
+ useNotifyInteractive,
useSettings,
useSuppressed,
type AppContextValue,
@@ -113,6 +114,7 @@ function RootComponent() {
grabProvider: app.grabProvider,
connectionGeneration: app.connectionGeneration,
apiBase: app.apiBase,
+ notifyInteractive: app.notifyInteractive,
}
createEffect(() => {
@@ -157,6 +159,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})
@@ -270,6 +273,7 @@ function RootChrome(props: {
makeEventListener(window, 'conciv:open-panel', openFromHost)
makeEventListener(window, 'conciv:close-panel', closeFromHost)
makeEventListener(window, 'conciv:toggle-panel', toggleFromHost)
+ notifyInteractive()
})
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..51c35a0a3 100644
--- a/apps/site/src/components/landing/try-live-button.tsx
+++ b/apps/site/src/components/landing/try-live-button.tsx
@@ -1,5 +1,6 @@
import {useSyncExternalStore} from 'react'
import {Button} from '@/components/ui/button'
+import {tryButtonLabel} from '@/lib/try-state'
declare global {
interface WindowEventMap {
@@ -8,8 +9,15 @@ declare global {
}
let connected = false
+let mounted = false
+let pendingOpen = false
+const pendingListeners = new Set<() => void>()
-function subscribe(onChange: () => void): () => void {
+function notifyPending(): void {
+ for (const listener of pendingListeners) listener()
+}
+
+function subscribeConnection(onChange: () => void): () => void {
const handler = (event: WindowEventMap['conciv:connection-changed']) => {
connected = event.detail.connected
onChange()
@@ -18,22 +26,66 @@ function subscribe(onChange: () => void): () => void {
return () => window.removeEventListener('conciv:connection-changed', handler)
}
+function subscribeMounted(onChange: () => void): () => void {
+ const handler = () => {
+ mounted = true
+ if (pendingOpen) {
+ pendingOpen = false
+ window.dispatchEvent(new Event('conciv:open-panel'))
+ notifyPending()
+ }
+ onChange()
+ }
+ window.addEventListener('conciv:widget-mounted', handler)
+ return () => window.removeEventListener('conciv:widget-mounted', handler)
+}
+
+function subscribePending(onChange: () => void): () => void {
+ pendingListeners.add(onChange)
+ return () => pendingListeners.delete(onChange)
+}
+
function useConcivConnected(): boolean {
return useSyncExternalStore(
- subscribe,
+ subscribeConnection,
() => connected,
() => false,
)
}
+function useWidgetMounted(): boolean {
+ return useSyncExternalStore(
+ subscribeMounted,
+ () => mounted,
+ () => false,
+ )
+}
+
+function usePendingOpen(): boolean {
+ return useSyncExternalStore(
+ subscribePending,
+ () => pendingOpen,
+ () => false,
+ )
+}
+
export function TryLiveButton() {
const isConnected = useConcivConnected()
- const open = () => window.dispatchEvent(new Event('conciv:open-panel'))
+ const isMounted = useWidgetMounted()
+ const isPending = usePendingOpen()
+ const open = () => {
+ if (isMounted) {
+ window.dispatchEvent(new Event('conciv:open-panel'))
+ return
+ }
+ pendingOpen = true
+ notifyPending()
+ }
return (
-
)
diff --git a/apps/site/src/lib/mount-live-widget.ts b/apps/site/src/lib/mount-live-widget.ts
index c28c88871..0227f307d 100644
--- a/apps/site/src/lib/mount-live-widget.ts
+++ b/apps/site/src/lib/mount-live-widget.ts
@@ -23,7 +23,10 @@ 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})])
+ const root = document.createElement('div')
+ root.setAttribute('data-conciv-script-root', '')
+ document.body.appendChild(root)
+ await embed.createConciv({extensions: [terminal.default, tryItModule.tryIt({token})]}).mount(root)
window.dispatchEvent(new Event('conciv:widget-mounted'))
let hasBeenOpen = false
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..5867ec045 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)})
@@ -155,7 +165,7 @@ function bootNormal(config: BootNormalConfig): BootResult {
reachabilityRoot.dispose,
closeConnection,
]
- return {dispose: () => runDisposers(disposers), rebind}
+ return {dispose: () => runDisposers(disposers), rebind, interactive: interactive.promise}
}
type BootConnectConfig = {
@@ -185,6 +195,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 +208,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)})
@@ -214,7 +226,7 @@ function bootConnect(config: BootConnectConfig): BootResult {
reachabilityRoot.dispose,
deferred.close,
]
- return {dispose: () => runDisposers(disposers)}
+ return {dispose: () => runDisposers(disposers), interactive: interactive.promise}
}
async function boot(root: ShadowRoot, init: ConcivInit): Promise {
@@ -252,6 +264,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/tests/e2e/mount-ready.it.test.ts b/packages/embed/tests/e2e/mount-ready.it.test.ts
new file mode 100644
index 000000000..0304a1644
--- /dev/null
+++ b/packages/embed/tests/e2e/mount-ready.it.test.ts
@@ -0,0 +1,36 @@
+import {expect, test, type Page} from '@playwright/test'
+import {bootEmbedKit, type EmbedKit} from '../helpers/boot.js'
+import {handleHostPage, serveHost} from '../helpers/host.js'
+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 Event('conciv:open-panel'))
+ }, kit.base)
+ await expect(chatBox(page)).toBeVisible({timeout: 10_000})
+ })
+})
From 7df479456470661dd1a0e18821ec0b888ef08d81 Mon Sep 17 00:00:00 2001
From: Omri Katz <9701896+omridevk@users.noreply.github.com>
Date: Sun, 16 Aug 2026 00:21:56 +0300
Subject: [PATCH 2/7] fix(site,embed,protocol): port TanStack Devtools
event-bus protocol to close the click-race class
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Fixes the three review findings on the try-it-live race fix (stale
React snapshot in open(), embed-internals leak in mount-live-widget.ts,
unhandled mount-failure leaving the button stuck), then ports the
queue-until-connected handshake protocol from TanStack Devtools'
event-bus-client (packages/protocol/src/event-bus.ts) so the whole
class of "sender fires before receiver is listening" races is closed
structurally rather than patched per-event.
- createEventBusClient/createEventBusHost: sender queues emits until
the receiver acks a connect handshake (bounded retries, then an
explicit failed state that drops the queue).
- apps/conciv's root route is now the panel-commands host, acking
once its open/close/toggle listeners are live (the same point that
already resolved mount()'s promise).
- embed's createConciv().open()/close()/toggle() and the site's
TryLiveButton both emit through bus clients on that channel instead
of bare window.dispatchEvent; TryLiveButton's hand-rolled
pendingOpen/widget-mounted machinery is gone, replaced by the bus
client's own state.
- Wire event names are unchanged, so existing raw dispatchers (the
iOS bridge, embed's own IT/unit tests) keep working exactly as
before — the bus is additive reliability for callers that opt in.
- mountConciv() now returns the mount() promise instead of void.
Co-Authored-By: Claude Fable 5
---
.changeset/try-live-panel-ready-contract.md | 5 +
apps/conciv/src/routes/__root.tsx | 31 ++-
.../components/landing/try-live-button.tsx | 79 ++-----
apps/site/src/lib/mount-live-widget.ts | 17 +-
packages/embed/src/mount.ts | 42 +++-
packages/embed/tests/fixtures/global-entry.ts | 2 +-
packages/extension-compiler/src/extensions.ts | 2 +-
.../extensions/tanstack/test/host/main.tsx | 2 +-
packages/plugin/src/nextjs-widget.ts | 2 +-
packages/protocol/package.json | 4 +
packages/protocol/src/event-bus.ts | 223 ++++++++++++++++++
packages/protocol/test/event-bus.test.ts | 117 +++++++++
packages/protocol/tsdown.config.ts | 1 +
13 files changed, 439 insertions(+), 88 deletions(-)
create mode 100644 packages/protocol/src/event-bus.ts
create mode 100644 packages/protocol/test/event-bus.test.ts
diff --git a/.changeset/try-live-panel-ready-contract.md b/.changeset/try-live-panel-ready-contract.md
index 73bb42f43..326d6d0ae 100644
--- a/.changeset/try-live-panel-ready-contract.md
+++ b/.changeset/try-live-panel-ready-contract.md
@@ -1,5 +1,10 @@
---
'@conciv/embed': patch
+'@conciv/protocol': patch
---
`mount(el)` now resolves only once the widget can actually honor `open()`/`conciv:open-panel` — 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: `createEventBusClient`/`createEventBusHost`, a queue-until-connected event handshake (ported from TanStack Devtools' event-bus-client protocol) that eliminates the whole "sender fires before receiver is listening" class of races, not just the one above. `createConciv().open()`/`close()`/`toggle()` now emit through a bus client on the `conciv:panel-commands` channel instead of a bare `window.dispatchEvent`; the widget's root route answers as the bus host once its command listeners are registered. The wire event names (`conciv:open-panel`, `conciv:close-panel`, `conciv:toggle-panel`, `conciv:connection-changed`, `conciv:panel-toggled`) are unchanged, so existing raw dispatchers (the iOS bridge, tests) keep working exactly as before — the bus is additive reliability for callers that opt into it, not a breaking wire-protocol change.
diff --git a/apps/conciv/src/routes/__root.tsx b/apps/conciv/src/routes/__root.tsx
index 0d15ffa90..56205d34b 100644
--- a/apps/conciv/src/routes/__root.tsx
+++ b/apps/conciv/src/routes/__root.tsx
@@ -14,6 +14,16 @@ 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 {
+ CLOSE_PANEL_EVENT,
+ CONNECTION_CHANGED_EVENT,
+ createEventBusHost,
+ OPEN_PANEL_EVENT,
+ PANEL_COMMAND_CHANNEL,
+ PANEL_TOGGLED_EVENT,
+ TOGGLE_PANEL_EVENT,
+ type PanelCommandEventMap,
+} from '@conciv/protocol/event-bus'
import type {ConcivRouterContext} from '../router.js'
import {
AppContext,
@@ -119,7 +129,7 @@ function RootComponent() {
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()
@@ -197,7 +207,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()},
}),
)
@@ -262,19 +272,20 @@ function RootChrome(props: {
onCleanup(() => cancelAnimationFrame(frame))
})
+ const panelCommandHost = createEventBusHost({channel: PANEL_COMMAND_CHANNEL})
+
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)
+ panelCommandHost.on(OPEN_PANEL_EVENT, () => void openPanel())
+ panelCommandHost.on(CLOSE_PANEL_EVENT, () => {
+ if (panelOpen()) closePanel()
+ })
+ panelCommandHost.on(TOGGLE_PANEL_EVENT, () => togglePanel())
+ panelCommandHost.ready()
notifyInteractive()
})
+ onCleanup(() => panelCommandHost.dispose())
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return
diff --git a/apps/site/src/components/landing/try-live-button.tsx b/apps/site/src/components/landing/try-live-button.tsx
index 51c35a0a3..284581c0a 100644
--- a/apps/site/src/components/landing/try-live-button.tsx
+++ b/apps/site/src/components/landing/try-live-button.tsx
@@ -1,48 +1,31 @@
import {useSyncExternalStore} from 'react'
import {Button} from '@/components/ui/button'
+import {
+ CONNECTION_CHANGED_EVENT,
+ createEventBusClient,
+ OPEN_PANEL_EVENT,
+ PANEL_COMMAND_CHANNEL,
+ 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
-let mounted = false
-let pendingOpen = false
-const pendingListeners = new Set<() => void>()
-
-function notifyPending(): void {
- for (const listener of pendingListeners) listener()
-}
function subscribeConnection(onChange: () => void): () => void {
- const handler = (event: WindowEventMap['conciv:connection-changed']) => {
+ 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)
-}
-
-function subscribeMounted(onChange: () => void): () => void {
- const handler = () => {
- mounted = true
- if (pendingOpen) {
- pendingOpen = false
- window.dispatchEvent(new Event('conciv:open-panel'))
- notifyPending()
- }
- onChange()
- }
- window.addEventListener('conciv:widget-mounted', handler)
- return () => window.removeEventListener('conciv:widget-mounted', handler)
-}
-
-function subscribePending(onChange: () => void): () => void {
- pendingListeners.add(onChange)
- return () => pendingListeners.delete(onChange)
+ window.addEventListener(CONNECTION_CHANGED_EVENT, handler)
+ return () => window.removeEventListener(CONNECTION_CHANGED_EVENT, handler)
}
function useConcivConnected(): boolean {
@@ -53,39 +36,25 @@ function useConcivConnected(): boolean {
)
}
-function useWidgetMounted(): boolean {
- return useSyncExternalStore(
- subscribeMounted,
- () => mounted,
- () => false,
- )
-}
+const panelCommands = createEventBusClient({
+ channel: PANEL_COMMAND_CHANNEL,
+ reconnectEveryMs: 500,
+ maxRetries: 60,
+})
-function usePendingOpen(): boolean {
- return useSyncExternalStore(
- subscribePending,
- () => pendingOpen,
- () => false,
- )
+function useBusState(): EventBusClientState {
+ return useSyncExternalStore(panelCommands.subscribe, panelCommands.getState, () => 'idle')
}
export function TryLiveButton() {
const isConnected = useConcivConnected()
- const isMounted = useWidgetMounted()
- const isPending = usePendingOpen()
- const open = () => {
- if (isMounted) {
- window.dispatchEvent(new Event('conciv:open-panel'))
- return
- }
- pendingOpen = true
- notifyPending()
- }
+ const busState = useBusState()
+ const open = () => panelCommands.emit(OPEN_PANEL_EVENT, undefined)
return (
-
+
- {tryButtonLabel({connected: isConnected, pending: isPending})}
+ {tryButtonLabel({connected: isConnected, pending: busState === 'connecting'})}
)
diff --git a/apps/site/src/lib/mount-live-widget.ts b/apps/site/src/lib/mount-live-widget.ts
index 0227f307d..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,15 +30,11 @@ export async function mountLiveWidget(opts: {widgetOpen: boolean; tryParam: bool
import('@conciv/extension-try-it/client'),
])
if (document.querySelector('[data-conciv-root]')) return
- const root = document.createElement('div')
- root.setAttribute('data-conciv-script-root', '')
- document.body.appendChild(root)
- await embed.createConciv({extensions: [terminal.default, tryItModule.tryIt({token})]}).mount(root)
- 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/packages/embed/src/mount.ts b/packages/embed/src/mount.ts
index 9cabd9974..df4744007 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,9 +25,11 @@ 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
+
+type PanelCommandsBus = {
+ client: EventBusClient
+ events: {open: PanelCommandName; close: PanelCommandName; toggle: PanelCommandName}
}
export function createConciv(init: ConcivInit = {}): ConcivHandle {
@@ -34,6 +37,20 @@ export function createConciv(init: ConcivInit = {}): ConcivHandle {
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) => ({
+ client: eventBus.createEventBusClient({
+ channel: eventBus.PANEL_COMMAND_CHANNEL,
+ reconnectEveryMs: 500,
+ maxRetries: 60,
+ }),
+ events: {open: eventBus.OPEN_PANEL_EVENT, close: eventBus.CLOSE_PANEL_EVENT, toggle: eventBus.TOGGLE_PANEL_EVENT},
+ }))
+ return panelCommandsPromise
+ }
async function mount(el: HTMLElement): Promise {
if (typeof document === 'undefined') return
@@ -70,15 +87,18 @@ export function createConciv(init: ConcivInit = {}): ConcivHandle {
}
function open(): void {
- dispatch('conciv:open-panel')
+ if (typeof window === 'undefined') return
+ void panelCommands().then((bus) => bus.client.emit(bus.events.open, undefined))
}
function close(): void {
- dispatch('conciv:close-panel')
+ if (typeof window === 'undefined') return
+ void panelCommands().then((bus) => bus.client.emit(bus.events.close, undefined))
}
function toggle(): void {
- dispatch('conciv:toggle-panel')
+ if (typeof window === 'undefined') return
+ void panelCommands().then((bus) => bus.client.emit(bus.events.toggle, undefined))
}
async function rebind(apiBase: string): Promise {
@@ -89,13 +109,11 @@ 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})
- .mount(el)
- .catch(() => undefined)
+ return createConciv({extensions}).mount(el)
}
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/extension-compiler/src/extensions.ts b/packages/extension-compiler/src/extensions.ts
index 027334cd1..d17b7b4cf 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)`,
+ `void mountConciv(picked.extensions)`,
'',
].join('\n')
}
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..92b4b188b 100644
--- a/packages/plugin/src/nextjs-widget.ts
+++ b/packages/plugin/src/nextjs-widget.ts
@@ -11,7 +11,7 @@ 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') {
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..35a851195
--- /dev/null
+++ b/packages/protocol/src/event-bus.ts
@@ -0,0 +1,223 @@
+declare const window: EventTarget | undefined
+
+declare global {
+ var __CONCIV_EVENT_BUS_TARGET__: EventTarget | undefined
+}
+
+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 defaultScheduler(): EventBusScheduler {
+ const timers = new Map>()
+ let nextId = 0
+ return {
+ setInterval: (callback, ms) => {
+ const id = nextId
+ nextId += 1
+ timers.set(id, setInterval(callback, ms))
+ return id
+ },
+ clearInterval: (id) => {
+ if (typeof id !== 'number') return
+ const timer = timers.get(id)
+ if (timer !== undefined) clearInterval(timer)
+ timers.delete(id)
+ },
+ }
+}
+
+export type EventBusClientState = 'idle' | 'connecting' | 'ready' | 'failed'
+
+export type EventBusClientOptions = {
+ channel: string
+ target?: EventBusTarget
+ scheduler?: EventBusScheduler
+ reconnectEveryMs?: number
+ maxRetries?: number
+}
+
+export type EventBusClient> = {
+ emit: (name: TName, payload: TEventMap[TName]) => void
+ getState: () => EventBusClientState
+ subscribe: (listener: () => void) => () => void
+ dispose: () => void
+}
+
+type QueuedEvent = {name: string; payload: unknown}
+
+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 connectEventName = `${options.channel}:connect`
+ const connectSuccessEventName = `${options.channel}:connect-success`
+
+ let state: EventBusClientState = 'idle'
+ let queue: QueuedEvent[] = []
+ let retryCount = 0
+ let intervalId: unknown = null
+ const listeners = new Set<() => void>()
+
+ function notify(): void {
+ for (const listener of listeners) listener()
+ }
+
+ function dispatch(name: string, payload: unknown): void {
+ target().dispatchEvent(new CustomEvent(name, {detail: payload}))
+ }
+
+ function flushQueue(): void {
+ const pending = queue
+ queue = []
+ for (const event of pending) dispatch(event.name, event.payload)
+ }
+
+ function stopConnectLoop(): void {
+ if (intervalId === null) return
+ scheduler.clearInterval(intervalId)
+ intervalId = null
+ }
+
+ function onConnectSuccess(): void {
+ target().removeEventListener(connectSuccessEventName, onConnectSuccess)
+ stopConnectLoop()
+ state = 'ready'
+ flushQueue()
+ notify()
+ }
+
+ function attemptConnect(): void {
+ if (retryCount >= maxRetries) {
+ target().removeEventListener(connectSuccessEventName, onConnectSuccess)
+ stopConnectLoop()
+ state = 'failed'
+ queue = []
+ notify()
+ return
+ }
+ retryCount += 1
+ target().dispatchEvent(new CustomEvent(connectEventName))
+ }
+
+ function startHandshake(): void {
+ state = 'connecting'
+ target().addEventListener(connectSuccessEventName, onConnectSuccess)
+ attemptConnect()
+ if (state === 'connecting') intervalId = scheduler.setInterval(attemptConnect, reconnectEveryMs)
+ notify()
+ }
+
+ function emit(name: TName, payload: TEventMap[TName]): void {
+ if (state === 'ready') {
+ dispatch(name, payload)
+ return
+ }
+ if (state === 'failed') return
+ queue.push({name, payload})
+ if (state === 'idle') startHandshake()
+ }
+
+ function getState(): EventBusClientState {
+ return state
+ }
+
+ function subscribe(listener: () => void): () => void {
+ listeners.add(listener)
+ return () => listeners.delete(listener)
+ }
+
+ function dispose(): void {
+ stopConnectLoop()
+ target().removeEventListener(connectSuccessEventName, onConnectSuccess)
+ listeners.clear()
+ queue = []
+ }
+
+ return {emit, getState, subscribe, dispose}
+}
+
+export type EventBusHostOptions = {
+ channel: string
+ target?: EventBusTarget
+}
+
+export type EventBusHost> = {
+ on: (name: TName, handler: (payload: TEventMap[TName]) => void) => void
+ ready: () => void
+ dispose: () => void
+}
+
+type RegisteredListener = {name: string; listener: (event: Event) => void}
+
+export function createEventBusHost>(
+ options: EventBusHostOptions,
+): EventBusHost {
+ const target = options.target ?? sharedEventTarget
+ const connectEventName = `${options.channel}:connect`
+ const connectSuccessEventName = `${options.channel}:connect-success`
+ const registered: RegisteredListener[] = []
+ let connectListener: (() => void) | null = null
+
+ function on(name: TName, handler: (payload: TEventMap[TName]) => void): void {
+ const listener = (event: Event) => handler((event as CustomEvent).detail)
+ registered.push({name, listener})
+ target().addEventListener(name, listener)
+ }
+
+ function ready(): void {
+ if (connectListener) return
+ connectListener = () => target().dispatchEvent(new CustomEvent(connectSuccessEventName))
+ target().addEventListener(connectEventName, connectListener)
+ }
+
+ function dispose(): void {
+ for (const entry of registered) target().removeEventListener(entry.name, entry.listener)
+ registered.length = 0
+ if (connectListener) {
+ target().removeEventListener(connectEventName, connectListener)
+ connectListener = null
+ }
+ }
+
+ return {on, ready, dispose}
+}
+
+export const PANEL_COMMAND_CHANNEL = 'conciv:panel-commands'
+export const OPEN_PANEL_EVENT = 'conciv:open-panel'
+export const CLOSE_PANEL_EVENT = 'conciv:close-panel'
+export const TOGGLE_PANEL_EVENT = 'conciv:toggle-panel'
+
+export type PanelCommandEventMap = {
+ [OPEN_PANEL_EVENT]: undefined
+ [CLOSE_PANEL_EVENT]: undefined
+ [TOGGLE_PANEL_EVENT]: 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..028a64b91
--- /dev/null
+++ b/packages/protocol/test/event-bus.test.ts
@@ -0,0 +1,117 @@
+import {describe, expect, it} from 'vitest'
+import {createEventBusClient, createEventBusHost, 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
+ let cleared = 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)
+ cleared += 1
+ },
+ },
+ tick: () => {
+ for (const callback of callbacks.values()) callback()
+ },
+ intervalCount: () => callbacks.size - cleared,
+ }
+}
+
+describe('createEventBusClient / createEventBusHost', () => {
+ 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 host = createEventBusHost({channel: 'test-channel', target: () => target})
+ host.on('open', (payload) => received.push(['open', payload]))
+ host.on('close', (payload) => received.push(['close', payload]))
+
+ const client = createEventBusClient({channel: 'test-channel', target: () => target, scheduler})
+ client.emit('open', undefined)
+ client.emit('close', undefined)
+ expect(received).toEqual([])
+ expect(client.getState()).toBe('connecting')
+
+ host.ready()
+ expect(received).toEqual([])
+
+ tick()
+ expect(received).toEqual([
+ ['open', null],
+ ['close', null],
+ ])
+ expect(client.getState()).toBe('ready')
+ })
+
+ it('stops retrying once the host acks the connection', () => {
+ const target = new EventTarget()
+ const {scheduler, tick, intervalCount} = createManualScheduler()
+ const host = createEventBusHost({channel: 'test-channel', target: () => target})
+ host.on('open', () => {})
+ host.ready()
+
+ const client = createEventBusClient({channel: 'test-channel', 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({
+ channel: 'test-channel',
+ 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 host = createEventBusHost({channel: 'test-channel', target: () => target})
+ host.on('open', (payload) => received.push(payload))
+ host.ready()
+ expect(received).toEqual([])
+ })
+
+ it('passes an emit straight through once the client is already connected', () => {
+ const target = new EventTarget()
+ const {scheduler} = createManualScheduler()
+ const received: unknown[] = []
+ const host = createEventBusHost({channel: 'test-channel', target: () => target})
+ host.on('open', (payload) => received.push(payload))
+ host.ready()
+
+ const client = createEventBusClient({channel: 'test-channel', target: () => target, scheduler})
+ client.emit('open', undefined)
+ expect(client.getState()).toBe('ready')
+
+ client.emit('open', undefined)
+ expect(received).toEqual([null, null])
+ })
+})
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(\/|$)/],
From b59e70afbbe37feeac30d4e7a1050151e90ee861 Mon Sep 17 00:00:00 2001
From: Omri Katz <9701896+omridevk@users.noreply.github.com>
Date: Sun, 16 Aug 2026 00:34:27 +0300
Subject: [PATCH 3/7] fix(protocol): event bus emit-after-failure restarts
handshake; simplify scheduler
emit() called while the client is in 'failed' state now resets retryCount
and restarts the handshake instead of silently dropping the event, so a
client that exhausted its retry budget can recover on new user intent
without a page reload. Also drops the Map+counter wrapper in
defaultScheduler: setInterval's own return value is already an opaque
handle, so it's passed straight through to clearInterval.
Co-Authored-By: Claude Fable 5
---
packages/protocol/src/event-bus.ts | 26 ++++++++++-----------
packages/protocol/test/event-bus.test.ts | 29 ++++++++++++++++++++++++
2 files changed, 42 insertions(+), 13 deletions(-)
diff --git a/packages/protocol/src/event-bus.ts b/packages/protocol/src/event-bus.ts
index 35a851195..dca08aacb 100644
--- a/packages/protocol/src/event-bus.ts
+++ b/packages/protocol/src/event-bus.ts
@@ -17,21 +17,16 @@ export type EventBusScheduler = {
clearInterval: (id: unknown) => void
}
+function isTimerHandle(id: unknown): id is Parameters[0] {
+ return id !== null
+}
+
function defaultScheduler(): EventBusScheduler {
- const timers = new Map>()
- let nextId = 0
return {
- setInterval: (callback, ms) => {
- const id = nextId
- nextId += 1
- timers.set(id, setInterval(callback, ms))
- return id
- },
+ setInterval: (callback, ms) => setInterval(callback, ms),
clearInterval: (id) => {
- if (typeof id !== 'number') return
- const timer = timers.get(id)
- if (timer !== undefined) clearInterval(timer)
- timers.delete(id)
+ if (!isTimerHandle(id)) return
+ clearInterval(id)
},
}
}
@@ -125,7 +120,12 @@ export function createEventBusClient>(
dispatch(name, payload)
return
}
- if (state === 'failed') return
+ if (state === 'failed') {
+ retryCount = 0
+ queue.push({name, payload})
+ startHandshake()
+ return
+ }
queue.push({name, payload})
if (state === 'idle') startHandshake()
}
diff --git a/packages/protocol/test/event-bus.test.ts b/packages/protocol/test/event-bus.test.ts
index 028a64b91..3e9ca3ef8 100644
--- a/packages/protocol/test/event-bus.test.ts
+++ b/packages/protocol/test/event-bus.test.ts
@@ -99,6 +99,35 @@ describe('createEventBusClient / createEventBusHost', () => {
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({
+ channel: 'test-channel',
+ 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 host = createEventBusHost({channel: 'test-channel', target: () => target})
+ host.on('close', (payload) => received.push(payload))
+ host.ready()
+ expect(received).toEqual([])
+
+ tick()
+ expect(received).toEqual([null])
+ expect(client.getState()).toBe('ready')
+ })
+
it('passes an emit straight through once the client is already connected', () => {
const target = new EventTarget()
const {scheduler} = createManualScheduler()
From a62cd0e6cbbea8b28d6e8a1a71c97dbd018a1bae Mon Sep 17 00:00:00 2001
From: Omri Katz <9701896+omridevk@users.noreply.github.com>
Date: Sun, 16 Aug 2026 01:03:10 +0300
Subject: [PATCH 4/7] fix(embed): dispose bus client on unmount, guard panel
command import failure, clean up script root on mount failure
Prevents a pre-unmount panel command from leaving the bus client's retry
interval running forever, stops a rejected dynamic import from poisoning
every later open/close/toggle call, and removes the mounted script-root
marker if mount() rejects so mountConciv() can retry instead of
early-returning forever.
Co-Authored-By: Claude Fable 5
---
packages/embed/src/mount.ts | 30 +++++++++++++++++++++++-------
1 file changed, 23 insertions(+), 7 deletions(-)
diff --git a/packages/embed/src/mount.ts b/packages/embed/src/mount.ts
index df4744007..565f5ad5b 100644
--- a/packages/embed/src/mount.ts
+++ b/packages/embed/src/mount.ts
@@ -84,21 +84,32 @@ export function createConciv(init: ConcivInit = {}): ConcivHandle {
teardown = undefined
rebindImpl = undefined
state = 'unmounted'
+ if (panelCommandsPromise) {
+ void panelCommandsPromise.then((bus) => bus.client.dispose()).catch(() => {})
+ panelCommandsPromise = null
+ }
}
- function open(): void {
+ function emitPanelCommand(pick: (events: PanelCommandsBus['events']) => PanelCommandName): void {
if (typeof window === 'undefined') return
- void panelCommands().then((bus) => bus.client.emit(bus.events.open, undefined))
+ void panelCommands()
+ .then((bus) => bus.client.emit(pick(bus.events), undefined))
+ .catch((error: unknown) => {
+ panelCommandsPromise = null
+ console.error('[conciv] panel command failed', error)
+ })
+ }
+
+ function open(): void {
+ emitPanelCommand((events) => events.open)
}
function close(): void {
- if (typeof window === 'undefined') return
- void panelCommands().then((bus) => bus.client.emit(bus.events.close, undefined))
+ emitPanelCommand((events) => events.close)
}
function toggle(): void {
- if (typeof window === 'undefined') return
- void panelCommands().then((bus) => bus.client.emit(bus.events.toggle, undefined))
+ emitPanelCommand((events) => events.toggle)
}
async function rebind(apiBase: string): Promise {
@@ -115,5 +126,10 @@ export function mountConciv(extensions: AnyExtension[]): Promise {
const el = document.createElement('div')
el.setAttribute('data-conciv-script-root', '')
document.body.appendChild(el)
- return createConciv({extensions}).mount(el)
+ return createConciv({extensions})
+ .mount(el)
+ .catch((error: unknown) => {
+ el.remove()
+ throw error
+ })
}
From 8a893cd673f638893d077436fa42fe44169d5707 Mon Sep 17 00:00:00 2001
From: Omri Katz <9701896+omridevk@users.noreply.github.com>
Date: Sun, 16 Aug 2026 01:28:04 +0300
Subject: [PATCH 5/7] fix(embed,plugin,extension-compiler): resolve interactive
on dispose; guard fire-and-forget mountConciv call sites; fix test scheduler
accounting
Teardown between boot() resolving and the widget's onMount left the
interactive promise pending forever, hanging mount(). Disposal now
resolves it. The extension-compiler bootstrap template and the Next.js
widget shim both fire-and-forgot mountConciv, which now rejects on
startup failure; both call sites catch and log. The manual test
scheduler's interval accounting subtracted cumulative clears from live
size, going negative after a genuine clear.
Co-Authored-By: Claude Fable 5
---
packages/embed/src/mount-impl.tsx | 2 ++
packages/extension-compiler/src/extensions.ts | 2 +-
packages/plugin/src/nextjs-widget.ts | 8 ++++++--
packages/protocol/test/event-bus.test.ts | 4 +---
4 files changed, 10 insertions(+), 6 deletions(-)
diff --git a/packages/embed/src/mount-impl.tsx b/packages/embed/src/mount-impl.tsx
index 5867ec045..ad2fb8498 100644
--- a/packages/embed/src/mount-impl.tsx
+++ b/packages/embed/src/mount-impl.tsx
@@ -164,6 +164,7 @@ function bootNormal(config: BootNormalConfig): BootResult {
driver.dispose,
reachabilityRoot.dispose,
closeConnection,
+ interactive.notify,
]
return {dispose: () => runDisposers(disposers), rebind, interactive: interactive.promise}
}
@@ -225,6 +226,7 @@ function bootConnect(config: BootConnectConfig): BootResult {
driver.dispose,
reachabilityRoot.dispose,
deferred.close,
+ interactive.notify,
]
return {dispose: () => runDisposers(disposers), interactive: interactive.promise}
}
diff --git a/packages/extension-compiler/src/extensions.ts b/packages/extension-compiler/src/extensions.ts
index d17b7b4cf..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)`,
- `void mountConciv(picked.extensions)`,
+ `mountConciv(picked.extensions).catch((error) => console.error('[conciv] widget failed to start', error))`,
'',
].join('\n')
}
diff --git a/packages/plugin/src/nextjs-widget.ts b/packages/plugin/src/nextjs-widget.ts
index 92b4b188b..f8bc25333 100644
--- a/packages/plugin/src/nextjs-widget.ts
+++ b/packages/plugin/src/nextjs-widget.ts
@@ -16,9 +16,13 @@ async function startWidget(): Promise {
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/test/event-bus.test.ts b/packages/protocol/test/event-bus.test.ts
index 3e9ca3ef8..3d7ab508c 100644
--- a/packages/protocol/test/event-bus.test.ts
+++ b/packages/protocol/test/event-bus.test.ts
@@ -9,7 +9,6 @@ type CommandMap = {
function createManualScheduler(): {scheduler: EventBusScheduler; tick: () => void; intervalCount: () => number} {
const callbacks = new Map void>()
let nextId = 0
- let cleared = 0
return {
scheduler: {
setInterval: (callback) => {
@@ -21,13 +20,12 @@ function createManualScheduler(): {scheduler: EventBusScheduler; tick: () => voi
clearInterval: (id) => {
if (typeof id !== 'number') return
callbacks.delete(id)
- cleared += 1
},
},
tick: () => {
for (const callback of callbacks.values()) callback()
},
- intervalCount: () => callbacks.size - cleared,
+ intervalCount: () => callbacks.size,
}
}
From 266a0c2f970a48642ab90153d3c0e3cac6e29829 Mon Sep 17 00:00:00 2001
From: Omri Katz <9701896+omridevk@users.noreply.github.com>
Date: Sun, 16 Aug 2026 02:01:23 +0300
Subject: [PATCH 6/7] refactor(protocol): faithful port of the TanStack
Devtools in-page event bus
Rewrites the panel-command event bus as an envelope protocol mirroring
TanStack Devtools' EventClient/ClientEventBus:
- every emit wraps as {type: ':', payload, pluginId}
and rides one fixed bus event, conciv-dispatch-event
- fixed global handshake: conciv-connect / conciv-connect-success
- createEventBus(start/stop) re-dispatches each envelope as both the
specific : event and a global conciv-global event,
and answers the connect handshake
- client on()/onAll() replace the old host-side on(); onAll filters by
pluginId; both return unsubscribers
- queue-until-connected, bounded retry, ack flushes in order
Panel commands move to pluginId 'panel' with open/close/toggle suffixes,
so the wire events are panel:open/panel:close/panel:toggle. The widget
root, embed handle, landing button and iOS bridge all speak the bus
through a client. Status events stay raw window events.
Co-Authored-By: Claude Fable 5
---
.changeset/try-live-panel-ready-contract.md | 7 +-
apps/conciv/src/routes/__root.tsx | 31 +--
.../components/landing/try-live-button.tsx | 7 +-
packages/embed/src/mount.ts | 30 ++-
.../embed/tests/e2e/mount-ready.it.test.ts | 6 +-
.../embed/tests/e2e/panel-focus.it.test.ts | 8 +-
.../embed/tests/unit/native-bundle.test.ts | 2 +-
packages/extensions/ios/package.json | 1 +
packages/extensions/ios/src/client.tsx | 15 +-
packages/protocol/src/event-bus.ts | 198 +++++++++++-------
packages/protocol/test/event-bus.test.ts | 128 ++++++++---
pnpm-lock.yaml | 27 +--
12 files changed, 305 insertions(+), 155 deletions(-)
diff --git a/.changeset/try-live-panel-ready-contract.md b/.changeset/try-live-panel-ready-contract.md
index 326d6d0ae..b9b6d2a64 100644
--- a/.changeset/try-live-panel-ready-contract.md
+++ b/.changeset/try-live-panel-ready-contract.md
@@ -1,10 +1,13 @@
---
'@conciv/embed': patch
+'@conciv/extension-ios': patch
'@conciv/protocol': patch
---
-`mount(el)` now resolves only once the widget can actually honor `open()`/`conciv:open-panel` — 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.
+`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: `createEventBusClient`/`createEventBusHost`, a queue-until-connected event handshake (ported from TanStack Devtools' event-bus-client protocol) that eliminates the whole "sender fires before receiver is listening" class of races, not just the one above. `createConciv().open()`/`close()`/`toggle()` now emit through a bus client on the `conciv:panel-commands` channel instead of a bare `window.dispatchEvent`; the widget's root route answers as the bus host once its command listeners are registered. The wire event names (`conciv:open-panel`, `conciv:close-panel`, `conciv:toggle-panel`, `conciv:connection-changed`, `conciv:panel-toggled`) are unchanged, so existing raw dispatchers (the iOS bridge, tests) keep working exactly as before — the bus is additive reliability for callers that opt into it, not a breaking wire-protocol change.
+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/routes/__root.tsx b/apps/conciv/src/routes/__root.tsx
index 56205d34b..78e103eac 100644
--- a/apps/conciv/src/routes/__root.tsx
+++ b/apps/conciv/src/routes/__root.tsx
@@ -15,13 +15,11 @@ import {createHotkey} from '@tanstack/solid-hotkeys'
import {Show, createEffect, createSignal, onCleanup, onMount} from 'solid-js'
import {makeEventListener} from '@solid-primitives/event-listener'
import {
- CLOSE_PANEL_EVENT,
CONNECTION_CHANGED_EVENT,
- createEventBusHost,
- OPEN_PANEL_EVENT,
- PANEL_COMMAND_CHANNEL,
+ createEventBus,
+ createEventBusClient,
+ PANEL_PLUGIN_ID,
PANEL_TOGGLED_EVENT,
- TOGGLE_PANEL_EVENT,
type PanelCommandEventMap,
} from '@conciv/protocol/event-bus'
import type {ConcivRouterContext} from '../router.js'
@@ -272,20 +270,27 @@ function RootChrome(props: {
onCleanup(() => cancelAnimationFrame(frame))
})
- const panelCommandHost = createEventBusHost({channel: PANEL_COMMAND_CHANNEL})
+ const eventBus = createEventBus()
+ const panelCommands = createEventBusClient({pluginId: PANEL_PLUGIN_ID})
onMount(() => {
if (settings.defaultOpen && closedMatch()) void openPanel()
makeEventListener(window, 'resize', reportPanelState)
- panelCommandHost.on(OPEN_PANEL_EVENT, () => void openPanel())
- panelCommandHost.on(CLOSE_PANEL_EVENT, () => {
- if (panelOpen()) closePanel()
- })
- panelCommandHost.on(TOGGLE_PANEL_EVENT, () => togglePanel())
- panelCommandHost.ready()
+ 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()
+ })
})
- onCleanup(() => panelCommandHost.dispose())
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return
diff --git a/apps/site/src/components/landing/try-live-button.tsx b/apps/site/src/components/landing/try-live-button.tsx
index 284581c0a..45ef8eefe 100644
--- a/apps/site/src/components/landing/try-live-button.tsx
+++ b/apps/site/src/components/landing/try-live-button.tsx
@@ -3,8 +3,7 @@ import {Button} from '@/components/ui/button'
import {
CONNECTION_CHANGED_EVENT,
createEventBusClient,
- OPEN_PANEL_EVENT,
- PANEL_COMMAND_CHANNEL,
+ PANEL_PLUGIN_ID,
type EventBusClientState,
type PanelCommandEventMap,
type WidgetConnectionChangedDetail,
@@ -37,7 +36,7 @@ function useConcivConnected(): boolean {
}
const panelCommands = createEventBusClient({
- channel: PANEL_COMMAND_CHANNEL,
+ pluginId: PANEL_PLUGIN_ID,
reconnectEveryMs: 500,
maxRetries: 60,
})
@@ -49,7 +48,7 @@ function useBusState(): EventBusClientState {
export function TryLiveButton() {
const isConnected = useConcivConnected()
const busState = useBusState()
- const open = () => panelCommands.emit(OPEN_PANEL_EVENT, undefined)
+ const open = () => panelCommands.emit('open', undefined)
return (
diff --git a/packages/embed/src/mount.ts b/packages/embed/src/mount.ts
index 565f5ad5b..8e1b03e9e 100644
--- a/packages/embed/src/mount.ts
+++ b/packages/embed/src/mount.ts
@@ -27,28 +27,22 @@ type MountState = 'unmounted' | 'mounting' | 'mounted'
type PanelCommandName = keyof PanelCommandEventMap & string
-type PanelCommandsBus = {
- client: EventBusClient
- events: {open: PanelCommandName; close: PanelCommandName; toggle: PanelCommandName}
-}
-
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
+ let panelCommandsPromise: Promise> | null = null
- function panelCommands(): Promise {
+ function panelCommands(): Promise> {
if (panelCommandsPromise) return panelCommandsPromise
- panelCommandsPromise = import('@conciv/protocol/event-bus').then((eventBus) => ({
- client: eventBus.createEventBusClient({
- channel: eventBus.PANEL_COMMAND_CHANNEL,
+ panelCommandsPromise = import('@conciv/protocol/event-bus').then((eventBus) =>
+ eventBus.createEventBusClient({
+ pluginId: eventBus.PANEL_PLUGIN_ID,
reconnectEveryMs: 500,
maxRetries: 60,
}),
- events: {open: eventBus.OPEN_PANEL_EVENT, close: eventBus.CLOSE_PANEL_EVENT, toggle: eventBus.TOGGLE_PANEL_EVENT},
- }))
+ )
return panelCommandsPromise
}
@@ -85,15 +79,15 @@ export function createConciv(init: ConcivInit = {}): ConcivHandle {
rebindImpl = undefined
state = 'unmounted'
if (panelCommandsPromise) {
- void panelCommandsPromise.then((bus) => bus.client.dispose()).catch(() => {})
+ void panelCommandsPromise.then((client) => client.dispose()).catch(() => {})
panelCommandsPromise = null
}
}
- function emitPanelCommand(pick: (events: PanelCommandsBus['events']) => PanelCommandName): void {
+ function emitPanelCommand(name: PanelCommandName): void {
if (typeof window === 'undefined') return
void panelCommands()
- .then((bus) => bus.client.emit(pick(bus.events), undefined))
+ .then((client) => client.emit(name, undefined))
.catch((error: unknown) => {
panelCommandsPromise = null
console.error('[conciv] panel command failed', error)
@@ -101,15 +95,15 @@ export function createConciv(init: ConcivInit = {}): ConcivHandle {
}
function open(): void {
- emitPanelCommand((events) => events.open)
+ emitPanelCommand('open')
}
function close(): void {
- emitPanelCommand((events) => events.close)
+ emitPanelCommand('close')
}
function toggle(): void {
- emitPanelCommand((events) => events.toggle)
+ emitPanelCommand('toggle')
}
async function rebind(apiBase: string): Promise {
diff --git a/packages/embed/tests/e2e/mount-ready.it.test.ts b/packages/embed/tests/e2e/mount-ready.it.test.ts
index 0304a1644..e5405d9a1 100644
--- a/packages/embed/tests/e2e/mount-ready.it.test.ts
+++ b/packages/embed/tests/e2e/mount-ready.it.test.ts
@@ -29,7 +29,11 @@ test.describe('mount() readiness contract', () => {
document.body.appendChild(el)
const handle = window.ConcivHandle.makeHandle(base)
await handle.mount(el)
- window.dispatchEvent(new Event('conciv:open-panel'))
+ 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 aa6e34c20..02f764587 100644
--- a/packages/embed/tests/e2e/panel-focus.it.test.ts
+++ b/packages/embed/tests/e2e/panel-focus.it.test.ts
@@ -69,7 +69,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/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/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/protocol/src/event-bus.ts b/packages/protocol/src/event-bus.ts
index dca08aacb..6f2fa02ea 100644
--- a/packages/protocol/src/event-bus.ts
+++ b/packages/protocol/src/event-bus.ts
@@ -4,6 +4,11 @@ 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()
@@ -31,10 +36,72 @@ function defaultScheduler(): EventBusScheduler {
}
}
+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 = {
- channel: string
+ pluginId: string
target?: EventBusTarget
scheduler?: EventBusScheduler
reconnectEveryMs?: number
@@ -43,13 +110,16 @@ export type EventBusClientOptions = {
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
}
-type QueuedEvent = {name: string; payload: unknown}
-
export function createEventBusClient>(
options: EventBusClientOptions,
): EventBusClient {
@@ -57,27 +127,37 @@ export function createEventBusClient>(
const scheduler = options.scheduler ?? defaultScheduler()
const reconnectEveryMs = options.reconnectEveryMs ?? 300
const maxRetries = options.maxRetries ?? 5
- const connectEventName = `${options.channel}:connect`
- const connectSuccessEventName = `${options.channel}:connect-success`
+ const pluginId = options.pluginId
let state: EventBusClientState = 'idle'
- let queue: QueuedEvent[] = []
+ 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 dispatch(name: string, payload: unknown): void {
- target().dispatchEvent(new CustomEvent(name, {detail: payload}))
+ function sendToBus(envelope: EventBusEnvelope): void {
+ target().dispatchEvent(new CustomEvent(DISPATCH_EVENT, {detail: envelope}))
}
function flushQueue(): void {
const pending = queue
queue = []
- for (const event of pending) dispatch(event.name, event.payload)
+ for (const envelope of pending) sendToBus(envelope)
}
function stopConnectLoop(): void {
@@ -87,7 +167,7 @@ export function createEventBusClient>(
}
function onConnectSuccess(): void {
- target().removeEventListener(connectSuccessEventName, onConnectSuccess)
+ target().removeEventListener(CONNECT_SUCCESS_EVENT, onConnectSuccess)
stopConnectLoop()
state = 'ready'
flushQueue()
@@ -96,7 +176,7 @@ export function createEventBusClient>(
function attemptConnect(): void {
if (retryCount >= maxRetries) {
- target().removeEventListener(connectSuccessEventName, onConnectSuccess)
+ target().removeEventListener(CONNECT_SUCCESS_EVENT, onConnectSuccess)
stopConnectLoop()
state = 'failed'
queue = []
@@ -104,32 +184,57 @@ export function createEventBusClient>(
return
}
retryCount += 1
- target().dispatchEvent(new CustomEvent(connectEventName))
+ target().dispatchEvent(new CustomEvent(CONNECT_EVENT))
}
function startHandshake(): void {
state = 'connecting'
- target().addEventListener(connectSuccessEventName, onConnectSuccess)
+ 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') {
- dispatch(name, payload)
+ sendToBus(envelope)
return
}
if (state === 'failed') {
retryCount = 0
- queue.push({name, payload})
+ queue.push(envelope)
startHandshake()
return
}
- queue.push({name, payload})
+ 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
}
@@ -141,69 +246,20 @@ export function createEventBusClient>(
function dispose(): void {
stopConnectLoop()
- target().removeEventListener(connectSuccessEventName, onConnectSuccess)
+ target().removeEventListener(CONNECT_SUCCESS_EVENT, onConnectSuccess)
listeners.clear()
queue = []
}
- return {emit, getState, subscribe, dispose}
-}
-
-export type EventBusHostOptions = {
- channel: string
- target?: EventBusTarget
-}
-
-export type EventBusHost> = {
- on: (name: TName, handler: (payload: TEventMap[TName]) => void) => void
- ready: () => void
- dispose: () => void
-}
-
-type RegisteredListener = {name: string; listener: (event: Event) => void}
-
-export function createEventBusHost>(
- options: EventBusHostOptions,
-): EventBusHost {
- const target = options.target ?? sharedEventTarget
- const connectEventName = `${options.channel}:connect`
- const connectSuccessEventName = `${options.channel}:connect-success`
- const registered: RegisteredListener[] = []
- let connectListener: (() => void) | null = null
-
- function on(name: TName, handler: (payload: TEventMap[TName]) => void): void {
- const listener = (event: Event) => handler((event as CustomEvent).detail)
- registered.push({name, listener})
- target().addEventListener(name, listener)
- }
-
- function ready(): void {
- if (connectListener) return
- connectListener = () => target().dispatchEvent(new CustomEvent(connectSuccessEventName))
- target().addEventListener(connectEventName, connectListener)
- }
-
- function dispose(): void {
- for (const entry of registered) target().removeEventListener(entry.name, entry.listener)
- registered.length = 0
- if (connectListener) {
- target().removeEventListener(connectEventName, connectListener)
- connectListener = null
- }
- }
-
- return {on, ready, dispose}
+ return {emit, on, onAll, getState, subscribe, dispose}
}
-export const PANEL_COMMAND_CHANNEL = 'conciv:panel-commands'
-export const OPEN_PANEL_EVENT = 'conciv:open-panel'
-export const CLOSE_PANEL_EVENT = 'conciv:close-panel'
-export const TOGGLE_PANEL_EVENT = 'conciv:toggle-panel'
+export const PANEL_PLUGIN_ID = 'panel'
export type PanelCommandEventMap = {
- [OPEN_PANEL_EVENT]: undefined
- [CLOSE_PANEL_EVENT]: undefined
- [TOGGLE_PANEL_EVENT]: undefined
+ open: undefined
+ close: undefined
+ toggle: undefined
}
export const CONNECTION_CHANGED_EVENT = 'conciv:connection-changed'
diff --git a/packages/protocol/test/event-bus.test.ts b/packages/protocol/test/event-bus.test.ts
index 3d7ab508c..6618dbafb 100644
--- a/packages/protocol/test/event-bus.test.ts
+++ b/packages/protocol/test/event-bus.test.ts
@@ -1,5 +1,11 @@
import {describe, expect, it} from 'vitest'
-import {createEventBusClient, createEventBusHost, type EventBusScheduler} from '../src/event-bus.js'
+import {
+ createEventBus,
+ createEventBusClient,
+ GLOBAL_EVENT,
+ type EventBusEnvelope,
+ type EventBusScheduler,
+} from '../src/event-bus.js'
type CommandMap = {
open: undefined
@@ -29,40 +35,40 @@ function createManualScheduler(): {scheduler: EventBusScheduler; tick: () => voi
}
}
-describe('createEventBusClient / createEventBusHost', () => {
+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 host = createEventBusHost({channel: 'test-channel', target: () => target})
- host.on('open', (payload) => received.push(['open', payload]))
- host.on('close', (payload) => received.push(['close', payload]))
+ 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({channel: 'test-channel', target: () => target, scheduler})
+ const client = createEventBusClient({pluginId: 'panel', target: () => target, scheduler})
client.emit('open', undefined)
client.emit('close', undefined)
expect(received).toEqual([])
expect(client.getState()).toBe('connecting')
- host.ready()
+ bus.start()
expect(received).toEqual([])
tick()
expect(received).toEqual([
- ['open', null],
- ['close', null],
+ ['panel:open', 'panel'],
+ ['panel:close', 'panel'],
])
expect(client.getState()).toBe('ready')
})
- it('stops retrying once the host acks the connection', () => {
+ it('stops retrying once the bus acks the connection', () => {
const target = new EventTarget()
const {scheduler, tick, intervalCount} = createManualScheduler()
- const host = createEventBusHost({channel: 'test-channel', target: () => target})
- host.on('open', () => {})
- host.ready()
+ const bus = createEventBus({target: () => target})
+ bus.start()
- const client = createEventBusClient({channel: 'test-channel', target: () => target, scheduler})
+ const client = createEventBusClient({pluginId: 'panel', target: () => target, scheduler})
client.emit('open', undefined)
expect(client.getState()).toBe('ready')
expect(intervalCount()).toBe(0)
@@ -75,7 +81,7 @@ describe('createEventBusClient / createEventBusHost', () => {
const target = new EventTarget()
const {scheduler, tick} = createManualScheduler()
const client = createEventBusClient({
- channel: 'test-channel',
+ pluginId: 'panel',
target: () => target,
scheduler,
maxRetries: 2,
@@ -91,9 +97,10 @@ describe('createEventBusClient / createEventBusHost', () => {
expect(client.getState()).toBe('failed')
const received: unknown[] = []
- const host = createEventBusHost({channel: 'test-channel', target: () => target})
- host.on('open', (payload) => received.push(payload))
- host.ready()
+ 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([])
})
@@ -101,7 +108,7 @@ describe('createEventBusClient / createEventBusHost', () => {
const target = new EventTarget()
const {scheduler, tick} = createManualScheduler()
const client = createEventBusClient({
- channel: 'test-channel',
+ pluginId: 'panel',
target: () => target,
scheduler,
maxRetries: 2,
@@ -116,13 +123,14 @@ describe('createEventBusClient / createEventBusHost', () => {
expect(client.getState()).toBe('connecting')
const received: unknown[] = []
- const host = createEventBusHost({channel: 'test-channel', target: () => target})
- host.on('close', (payload) => received.push(payload))
- host.ready()
+ 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([null])
+ expect(received).toEqual(['panel:close'])
expect(client.getState()).toBe('ready')
})
@@ -130,15 +138,79 @@ describe('createEventBusClient / createEventBusHost', () => {
const target = new EventTarget()
const {scheduler} = createManualScheduler()
const received: unknown[] = []
- const host = createEventBusHost({channel: 'test-channel', target: () => target})
- host.on('open', (payload) => received.push(payload))
- host.ready()
+ 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({channel: 'test-channel', target: () => target, scheduler})
+ const client = createEventBusClient({pluginId: 'panel', target: () => target, scheduler})
client.emit('open', undefined)
expect(client.getState()).toBe('ready')
client.emit('open', undefined)
- expect(received).toEqual([null, null])
+ 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/pnpm-lock.yaml b/pnpm-lock.yaml
index 5483cb629..598110ba7 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1043,7 +1043,7 @@ importers:
version: 0.5.20(tailwindcss@4.3.2)
'@tanstack/devtools-vite':
specifier: latest
- version: 0.8.3(vite@8.1.3(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.22.4)(yaml@2.9.0))
+ version: 0.8.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(vite@8.1.3(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.22.4)(yaml@2.9.0))
'@tanstack/router-cli':
specifier: ^1.132.0
version: 1.167.18
@@ -1116,7 +1116,7 @@ importers:
version: 0.5.20(tailwindcss@4.3.2)
'@tanstack/devtools-vite':
specifier: latest
- version: 0.8.3(vite@8.1.3(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.22.4)(yaml@2.9.0))
+ version: 0.8.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(vite@8.1.3(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.22.4)(yaml@2.9.0))
'@tanstack/router-cli':
specifier: ^1.132.0
version: 1.167.18
@@ -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
@@ -20123,9 +20126,9 @@ snapshots:
'@oxc-parser/binding-openharmony-arm64@0.131.0':
optional: true
- '@oxc-parser/binding-wasm32-wasi@0.120.0':
+ '@oxc-parser/binding-wasm32-wasi@0.120.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)':
dependencies:
- '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)
+ '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)
transitivePeerDependencies:
- '@emnapi/core'
- '@emnapi/runtime'
@@ -22508,14 +22511,14 @@ snapshots:
'@tanstack/pacer-lite': 0.2.2
typescript: 6.0.3
- '@tanstack/devtools-bundler-core@0.1.1':
+ '@tanstack/devtools-bundler-core@0.1.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)':
dependencies:
'@tanstack/devtools-client': 0.0.8
'@tanstack/devtools-event-bus': 0.4.2
chalk: 5.6.2
launch-editor: 2.14.1
magic-string: 0.30.21
- oxc-parser: 0.120.0
+ oxc-parser: 0.120.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)
picomatch: 4.0.5
transitivePeerDependencies:
- '@emnapi/core'
@@ -22576,9 +22579,9 @@ snapshots:
- bufferutil
- utf-8-validate
- '@tanstack/devtools-vite@0.8.3(vite@8.1.3(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.22.4)(yaml@2.9.0))':
+ '@tanstack/devtools-vite@0.8.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(vite@8.1.3(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.22.4)(yaml@2.9.0))':
dependencies:
- '@tanstack/devtools-bundler-core': 0.1.1
+ '@tanstack/devtools-bundler-core': 0.1.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)
'@tanstack/devtools-client': 0.0.8
'@tanstack/devtools-event-bus': 0.4.2
chalk: 5.6.2
@@ -24440,9 +24443,9 @@ snapshots:
obug: 2.1.3
std-env: 4.1.0
tinyrainbow: 3.1.1
- vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.0)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(jsdom@28.1.0(@noble/hashes@1.8.0))(vite@8.1.3(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.51.0)(terser@5.49.0)(tsx@4.22.4)(yaml@2.9.0))
+ vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.0)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(jsdom@28.1.0(@noble/hashes@1.8.0))(vite@8.1.3(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.22.4)(yaml@2.9.0))
optionalDependencies:
- '@vitest/browser': 4.1.10(vite@8.1.3(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.51.0)(terser@5.49.0)(tsx@4.22.4)(yaml@2.9.0))(vitest@4.1.10)
+ '@vitest/browser': 4.1.10(vite@8.1.3(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.22.4)(yaml@2.9.0))(vitest@4.1.10)
'@vitest/expect@3.2.4':
dependencies:
@@ -29897,7 +29900,7 @@ snapshots:
object-keys: 1.1.1
safe-push-apply: 1.0.0
- oxc-parser@0.120.0:
+ oxc-parser@0.120.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1):
dependencies:
'@oxc-project/types': 0.120.0
optionalDependencies:
@@ -29917,7 +29920,7 @@ snapshots:
'@oxc-parser/binding-linux-x64-gnu': 0.120.0
'@oxc-parser/binding-linux-x64-musl': 0.120.0
'@oxc-parser/binding-openharmony-arm64': 0.120.0
- '@oxc-parser/binding-wasm32-wasi': 0.120.0
+ '@oxc-parser/binding-wasm32-wasi': 0.120.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)
'@oxc-parser/binding-win32-arm64-msvc': 0.120.0
'@oxc-parser/binding-win32-ia32-msvc': 0.120.0
'@oxc-parser/binding-win32-x64-msvc': 0.120.0
From 734b83423957206e26c5df7d5738af3b70e97ef9 Mon Sep 17 00:00:00 2001
From: Omri Katz <9701896+omridevk@users.noreply.github.com>
Date: Sun, 16 Aug 2026 02:25:26 +0300
Subject: [PATCH 7/7] test(embed): mount-ready uses serveHost from
extension-testkit after #514 helper move
Co-Authored-By: Claude Fable 5
---
packages/embed/tests/e2e/mount-ready.it.test.ts | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/packages/embed/tests/e2e/mount-ready.it.test.ts b/packages/embed/tests/e2e/mount-ready.it.test.ts
index e5405d9a1..a8b608887 100644
--- a/packages/embed/tests/e2e/mount-ready.it.test.ts
+++ b/packages/embed/tests/e2e/mount-ready.it.test.ts
@@ -1,6 +1,7 @@
import {expect, test, type Page} from '@playwright/test'
import {bootEmbedKit, type EmbedKit} from '../helpers/boot.js'
-import {handleHostPage, serveHost} from '../helpers/host.js'
+import {handleHostPage} from '../helpers/host.js'
+import {serveHost} from '@conciv/extension-testkit/serve-host'
import {chatBox} from './helpers/chat.js'
let kit: EmbedKit