diff --git a/.changeset/try-live-panel-ready-contract.md b/.changeset/try-live-panel-ready-contract.md new file mode 100644 index 000000000..b9b6d2a64 --- /dev/null +++ b/.changeset/try-live-panel-ready-contract.md @@ -0,0 +1,13 @@ +--- +'@conciv/embed': patch +'@conciv/extension-ios': patch +'@conciv/protocol': patch +--- + +`mount(el)` now resolves only once the widget can actually honor `open()` — previously the returned promise settled as soon as the app's boot sequence finished computing, which raced ahead of the root route's `onMount` (where the open-panel listener registers). Any embedder that opens the panel immediately after `mount()` resolves — including a landing page's "Try it live" button that dispatches an early click before the widget bundle finishes loading — no longer has that open silently dropped. + +`mountConciv(extensions)` now returns the underlying `mount()` promise instead of `void`, so a caller can `await` it (or observe a rejection) instead of the widget's readiness being unobservable outside `createConciv`. Existing fire-and-forget call sites keep working unchanged; they just ignore the returned promise. + +New `@conciv/protocol/event-bus` export: `createEventBus`/`createEventBusClient`, a faithful port of the TanStack Devtools in-page event-bus protocol. It eliminates the whole "sender fires before receiver is listening" class of races, not just the one above. Every emit is wrapped as an envelope (`{type: ':', payload, pluginId}`) and dispatched on one fixed bus event (`conciv-dispatch-event`); the running bus re-dispatches it as both a specific `:` event and a global `conciv-global` event, and answers the fixed `conciv-connect` handshake with `conciv-connect-success`. Clients queue emits until connected, retry on a bounded loop, and flush in order on ack. + +Panel commands moved onto that protocol under the `panel` plugin id, so the wire events are now `panel:open`, `panel:close` and `panel:toggle` instead of `conciv:open-panel`, `conciv:close-panel` and `conciv:toggle-panel`, and they are spoken through a bus client rather than a bare `window.dispatchEvent`. `createConciv().open()`/`close()`/`toggle()`, the landing page's "Try it live" button, and the iOS bridge's panel open/close all emit through a client; the widget's root route subscribes with `client.on()` and starts the bus once its listeners are registered. Status events (`conciv:connection-changed`, `conciv:panel-toggled`) are unchanged raw window events. diff --git a/apps/conciv/src/app/context.ts b/apps/conciv/src/app/context.ts index 708b836b1..4fdc60a91 100644 --- a/apps/conciv/src/app/context.ts +++ b/apps/conciv/src/app/context.ts @@ -28,6 +28,7 @@ export type AppContextValue = { grabProvider?: GrabProvider connectionGeneration: () => number apiBase: () => string + notifyInteractive: () => void } export const AppContext = createContext() @@ -101,3 +102,7 @@ export function useConnectionGeneration(): () => number { export function useApiBase(): () => string { return useAppScope('useApiBase', (app) => app.apiBase) } + +export function useNotifyInteractive(): () => void { + return useAppScope('useNotifyInteractive', (app) => app.notifyInteractive) +} diff --git a/apps/conciv/src/router.tsx b/apps/conciv/src/router.tsx index c027ed57c..2bcfa9ff3 100644 --- a/apps/conciv/src/router.tsx +++ b/apps/conciv/src/router.tsx @@ -33,6 +33,7 @@ export type ConcivRouterContext = { apiBase: () => string connectionGeneration: () => number disposeInstances: () => void + notifyInteractive: () => void } export type ConcivRouterConfig = { @@ -48,6 +49,7 @@ export type ConcivRouterConfig = { grabProvider?: GrabProvider apiBase?: () => string connectionGeneration?: () => number + notifyInteractive?: () => void } function disposeExtensionInstances(instances: ExtensionInstance[]): void { @@ -123,6 +125,7 @@ export function createConcivRouter(config: ConcivRouterConfig) { apiBase, connectionGeneration: config.connectionGeneration ?? (() => 0), disposeInstances, + notifyInteractive: config.notifyInteractive ?? (() => {}), }, }) } diff --git a/apps/conciv/src/routes/__root.tsx b/apps/conciv/src/routes/__root.tsx index 055dbbd53..78e103eac 100644 --- a/apps/conciv/src/routes/__root.tsx +++ b/apps/conciv/src/routes/__root.tsx @@ -14,12 +14,21 @@ import {showToast} from '@conciv/page' import {createHotkey} from '@tanstack/solid-hotkeys' import {Show, createEffect, createSignal, onCleanup, onMount} from 'solid-js' import {makeEventListener} from '@solid-primitives/event-listener' +import { + CONNECTION_CHANGED_EVENT, + createEventBus, + createEventBusClient, + PANEL_PLUGIN_ID, + PANEL_TOGGLED_EVENT, + type PanelCommandEventMap, +} from '@conciv/protocol/event-bus' import type {ConcivRouterContext} from '../router.js' import { AppContext, useAppData, useConnected, useLayers, + useNotifyInteractive, useSettings, useSuppressed, type AppContextValue, @@ -113,11 +122,12 @@ function RootComponent() { grabProvider: app.grabProvider, connectionGeneration: app.connectionGeneration, apiBase: app.apiBase, + notifyInteractive: app.notifyInteractive, } createEffect(() => { const isConnected = app.connected() - window.dispatchEvent(new CustomEvent('conciv:connection-changed', {detail: {connected: isConnected}})) + window.dispatchEvent(new CustomEvent(CONNECTION_CHANGED_EVENT, {detail: {connected: isConnected}})) }) const reachability = makeEngineReachability() @@ -157,6 +167,7 @@ function RootChrome(props: { const layers = useLayers() const suppressed = useSuppressed() const connected = useConnected() + const notifyInteractive = useNotifyInteractive() const router = useRouter() const matchRoute = useMatchRoute() const panelMatch = matchRoute({to: '/panel/$sessionId', fuzzy: true}) @@ -194,7 +205,7 @@ function RootChrome(props: { const reportPanelState = () => { const open = panelOpen() window.dispatchEvent( - new CustomEvent('conciv:panel-toggled', { + new CustomEvent(PANEL_TOGGLED_EVENT, { detail: {open, connected: connected(), mascotRect: open ? null : mascotRect()}, }), ) @@ -259,17 +270,26 @@ function RootChrome(props: { onCleanup(() => cancelAnimationFrame(frame)) }) + const eventBus = createEventBus() + const panelCommands = createEventBusClient({pluginId: PANEL_PLUGIN_ID}) + onMount(() => { if (settings.defaultOpen && closedMatch()) void openPanel() - const openFromHost = () => void openPanel() - const closeFromHost = () => { - if (panelOpen()) closePanel() - } - const toggleFromHost = () => togglePanel() makeEventListener(window, 'resize', reportPanelState) - makeEventListener(window, 'conciv:open-panel', openFromHost) - makeEventListener(window, 'conciv:close-panel', closeFromHost) - makeEventListener(window, 'conciv:toggle-panel', toggleFromHost) + const unsubscribes = [ + panelCommands.on('open', () => void openPanel()), + panelCommands.on('close', () => { + if (panelOpen()) closePanel() + }), + panelCommands.on('toggle', () => togglePanel()), + ] + eventBus.start() + notifyInteractive() + onCleanup(() => { + for (const unsubscribe of unsubscribes) unsubscribe() + eventBus.stop() + panelCommands.dispose() + }) }) const onKeyDown = (event: KeyboardEvent) => { diff --git a/apps/conciv/test/helpers/pane-harness.tsx b/apps/conciv/test/helpers/pane-harness.tsx index f74384488..384a4979b 100644 --- a/apps/conciv/test/helpers/pane-harness.tsx +++ b/apps/conciv/test/helpers/pane-harness.tsx @@ -66,6 +66,7 @@ export function mountPane(options: PaneMountOptions, view: (pane: PaneContextVal connectMode: false, connectionGeneration: () => 0, apiBase: () => options.base, + notifyInteractive: () => {}, } const pane: PaneContextValue = { sessionId: () => options.sessionId, diff --git a/apps/site/src/components/landing/hero.tsx b/apps/site/src/components/landing/hero.tsx index c4169642b..d8f981fb6 100644 --- a/apps/site/src/components/landing/hero.tsx +++ b/apps/site/src/components/landing/hero.tsx @@ -1,5 +1,5 @@ import {Suspense, lazy} from 'react' -import {ClientOnly} from '@tanstack/react-router' +import {ClientOnly, Link} from '@tanstack/react-router' import {Badge} from '@/components/ui/badge' import {useIsMobile} from '@/lib/use-is-mobile' import {TryLiveButton} from './try-live-button' @@ -28,7 +28,14 @@ export function Hero() { run your tests, without ever leaving the thing you're building.

- {!isMobile && ( + {isMobile ? ( +

+ The live try-it flow needs a terminal, so it's desktop-only.{' '} + + Read the quick start → + +

+ ) : ( <> diff --git a/apps/site/src/components/landing/try-live-button.tsx b/apps/site/src/components/landing/try-live-button.tsx index face2d241..45ef8eefe 100644 --- a/apps/site/src/components/landing/try-live-button.tsx +++ b/apps/site/src/components/landing/try-live-button.tsx @@ -1,39 +1,59 @@ import {useSyncExternalStore} from 'react' import {Button} from '@/components/ui/button' +import { + CONNECTION_CHANGED_EVENT, + createEventBusClient, + PANEL_PLUGIN_ID, + type EventBusClientState, + type PanelCommandEventMap, + type WidgetConnectionChangedDetail, +} from '@conciv/protocol/event-bus' +import {tryButtonLabel} from '@/lib/try-state' declare global { interface WindowEventMap { - 'conciv:connection-changed': CustomEvent<{connected: boolean}> + [CONNECTION_CHANGED_EVENT]: CustomEvent } } let connected = false -function subscribe(onChange: () => void): () => void { - const handler = (event: WindowEventMap['conciv:connection-changed']) => { +function subscribeConnection(onChange: () => void): () => void { + const handler = (event: WindowEventMap[typeof CONNECTION_CHANGED_EVENT]) => { connected = event.detail.connected onChange() } - window.addEventListener('conciv:connection-changed', handler) - return () => window.removeEventListener('conciv:connection-changed', handler) + window.addEventListener(CONNECTION_CHANGED_EVENT, handler) + return () => window.removeEventListener(CONNECTION_CHANGED_EVENT, handler) } function useConcivConnected(): boolean { return useSyncExternalStore( - subscribe, + subscribeConnection, () => connected, () => false, ) } +const panelCommands = createEventBusClient({ + pluginId: PANEL_PLUGIN_ID, + reconnectEveryMs: 500, + maxRetries: 60, +}) + +function useBusState(): EventBusClientState { + return useSyncExternalStore(panelCommands.subscribe, panelCommands.getState, () => 'idle') +} + export function TryLiveButton() { const isConnected = useConcivConnected() - const open = () => window.dispatchEvent(new Event('conciv:open-panel')) + const busState = useBusState() + const open = () => panelCommands.emit('open', undefined) return (
-
) diff --git a/apps/site/src/lib/mount-live-widget.ts b/apps/site/src/lib/mount-live-widget.ts index c28c88871..169302c48 100644 --- a/apps/site/src/lib/mount-live-widget.ts +++ b/apps/site/src/lib/mount-live-widget.ts @@ -1,6 +1,13 @@ +import {PANEL_TOGGLED_EVENT, type WidgetPanelToggledDetail} from '@conciv/protocol/event-bus' import {dismissTry, getTrySession} from './try-session.functions' import {shouldAutoOpen, shouldDismissOnClose} from './try-state' +declare global { + interface WindowEventMap { + [PANEL_TOGGLED_EVENT]: CustomEvent + } +} + function ensureWidgetMeta(defaultOpen: boolean): void { if (document.querySelector('meta[name="pw-widget"]')) return const meta = document.createElement('meta') @@ -23,12 +30,11 @@ export async function mountLiveWidget(opts: {widgetOpen: boolean; tryParam: bool import('@conciv/extension-try-it/client'), ]) if (document.querySelector('[data-conciv-root]')) return - embed.mountConciv([terminal.default, tryItModule.tryIt({token})]) - window.dispatchEvent(new Event('conciv:widget-mounted')) + await embed.mountConciv([terminal.default, tryItModule.tryIt({token})]) let hasBeenOpen = false - window.addEventListener('conciv:panel-toggled', (event) => { - const detail = (event as CustomEvent<{open: boolean; connected: boolean}>).detail + window.addEventListener(PANEL_TOGGLED_EVENT, (event) => { + const detail = event.detail if (!detail) return if (detail.open) { hasBeenOpen = true diff --git a/apps/site/src/lib/try-state.ts b/apps/site/src/lib/try-state.ts index dbda08cab..3370c0a2e 100644 --- a/apps/site/src/lib/try-state.ts +++ b/apps/site/src/lib/try-state.ts @@ -10,3 +10,9 @@ export function shouldAutoOpen(opts: { export function shouldDismissOnClose(opts: {hasBeenOpen: boolean; connected: boolean}): boolean { return opts.hasBeenOpen && !opts.connected } + +export function tryButtonLabel(opts: {connected: boolean; pending: boolean}): string { + if (opts.connected) return 'Open agent panel' + if (opts.pending) return 'Opening…' + return 'Try it live' +} diff --git a/apps/site/test/live-connect.it.test.ts b/apps/site/test/live-connect.it.test.ts index c821f14cc..88f00be52 100644 --- a/apps/site/test/live-connect.it.test.ts +++ b/apps/site/test/live-connect.it.test.ts @@ -3,6 +3,7 @@ import {expect as expectLocator} from 'playwright/test' import {createFakeHarness} from '@conciv/harness-testkit' import {runConnect} from '@conciv/try' import type {Engine} from '@conciv/core/start' +import type {Page, Locator} from 'playwright/test' import {createSiteTest} from './site-fixture.js' const SITE_PORT = 8787 @@ -17,12 +18,34 @@ afterAll(async () => { await engine?.stop() }) +async function openLandingOnConnectSteps(page: Page): Promise { + await page.goto(ORIGIN, {waitUntil: 'domcontentloaded'}) + const panel = page.getByRole('dialog', {name: 'conciv chat agent'}) + await expectLocator(panel.getByText('Drive this page with your agent.')).toBeVisible({timeout: 20_000}) + return panel +} + +async function dismissAndReload(page: Page, panel: Locator): Promise { + await page.getByRole('button', {name: 'Minimize conciv chat'}).click() + await expectLocator(panel).toBeHidden({timeout: 10_000}) + await page.reload({waitUntil: 'domcontentloaded'}) + await expectLocator(page.getByRole('button', {name: 'Open conciv chat'})).toBeVisible({timeout: 20_000}) + expect(await panel.isVisible()).toBe(false) +} + test.describe('widget-native live connect on the built site', () => { + test('opens the panel for a click that lands before the widget bundle has mounted', async ({browser}) => { + const page = await browser.newPage() + await page.goto(`${ORIGIN}/?widget=false`, {waitUntil: 'domcontentloaded'}) + await page.getByRole('button', {name: /Try it live/i}).click() + await expectLocator(page.getByRole('dialog', {name: 'conciv chat agent'})).toBeVisible({timeout: 20_000}) + + await page.close() + }, 60_000) + test('boots the widget into connect steps and hands off in place to live chat', async ({browser}) => { const page = await browser.newPage() - await page.goto(ORIGIN, {waitUntil: 'domcontentloaded'}) - const panel = page.getByRole('dialog', {name: 'conciv chat agent'}) - await expectLocator(panel.getByText('Drive this page with your agent.')).toBeVisible({timeout: 20_000}) + const panel = await openLandingOnConnectSteps(page) const command = await panel .getByText(/^npx @conciv\/try --token \S+$/) @@ -65,19 +88,22 @@ test.describe('widget-native live connect on the built site', () => { test('remembers a pre-connect dismissal, and ?try=1 forces the panel open again', async ({browser}) => { const page = await browser.newPage() - await page.goto(ORIGIN, {waitUntil: 'domcontentloaded'}) - const panel = page.getByRole('dialog', {name: 'conciv chat agent'}) + const panel = await openLandingOnConnectSteps(page) + await dismissAndReload(page, panel) + + await page.goto(`${ORIGIN}/?try=1`, {waitUntil: 'domcontentloaded'}) await expectLocator(panel.getByText('Drive this page with your agent.')).toBeVisible({timeout: 20_000}) + await page.close() + }, 90_000) - await page.getByRole('button', {name: 'Minimize conciv chat'}).click() - await expectLocator(panel).toBeHidden({timeout: 10_000}) + test('the button reopens the panel after a dismiss-then-reload re-entry', async ({browser}) => { + const page = await browser.newPage() + const panel = await openLandingOnConnectSteps(page) + await dismissAndReload(page, panel) - await page.reload({waitUntil: 'domcontentloaded'}) - await expectLocator(page.getByRole('button', {name: 'Open conciv chat'})).toBeVisible({timeout: 20_000}) - expect(await panel.isVisible()).toBe(false) + await page.getByRole('button', {name: /Try it live/i}).click() + await expectLocator(panel).toBeVisible({timeout: 20_000}) - await page.goto(`${ORIGIN}/?try=1`, {waitUntil: 'domcontentloaded'}) - await expectLocator(panel.getByText('Drive this page with your agent.')).toBeVisible({timeout: 20_000}) await page.close() }, 90_000) }) diff --git a/apps/site/test/mobile-gating.it.test.ts b/apps/site/test/mobile-gating.it.test.ts index fcc45d2b2..90ddfa378 100644 --- a/apps/site/test/mobile-gating.it.test.ts +++ b/apps/site/test/mobile-gating.it.test.ts @@ -29,6 +29,8 @@ test.describe('landing gates the dev-only demo behind a non-mobile pointer', () await expectLocator(page.getByRole('button', {name: 'Copy install command'})).toHaveCount(0, {timeout: 20_000}) await expectLocator(page.getByRole('button', {name: /Try it live/i})).toHaveCount(0) await expectLocator(page.locator('[data-conciv-root]')).toHaveCount(0) + await expectLocator(page.getByText('desktop-only')).toBeVisible() + await expectLocator(page.getByRole('link', {name: 'Read the quick start →'})).toBeVisible() await context.close() }, 60_000) diff --git a/apps/site/test/try-state.test.ts b/apps/site/test/try-state.test.ts new file mode 100644 index 000000000..8749b3097 --- /dev/null +++ b/apps/site/test/try-state.test.ts @@ -0,0 +1,44 @@ +import {describe, expect, it} from 'vitest' +import {shouldAutoOpen, shouldDismissOnClose, tryButtonLabel} from '../src/lib/try-state' + +describe('shouldAutoOpen', () => { + it('opens for a fresh desktop visitor', () => { + expect(shouldAutoOpen({widgetOpen: true, tryParam: false, dismissed: false, widgetPresent: false})).toBe(true) + }) + + it('stays closed once the visitor dismissed the panel', () => { + expect(shouldAutoOpen({widgetOpen: true, tryParam: false, dismissed: true, widgetPresent: false})).toBe(false) + }) + + it('stays closed when the widget is already present', () => { + expect(shouldAutoOpen({widgetOpen: true, tryParam: false, dismissed: false, widgetPresent: true})).toBe(false) + }) +}) + +describe('shouldDismissOnClose', () => { + it('dismisses when a visitor closes an opened panel without connecting', () => { + expect(shouldDismissOnClose({hasBeenOpen: true, connected: false})).toBe(true) + }) + + it('does not dismiss once connected', () => { + expect(shouldDismissOnClose({hasBeenOpen: true, connected: true})).toBe(false) + }) + + it('does not dismiss a panel that was never opened', () => { + expect(shouldDismissOnClose({hasBeenOpen: false, connected: false})).toBe(false) + }) +}) + +describe('tryButtonLabel', () => { + it('shows the connected label regardless of a stale pending intent', () => { + expect(tryButtonLabel({connected: true, pending: true})).toBe('Open agent panel') + }) + + it('shows a working state for a pre-mount click', () => { + expect(tryButtonLabel({connected: false, pending: true})).toBe('Opening…') + }) + + it('shows the default call to action otherwise', () => { + expect(tryButtonLabel({connected: false, pending: false})).toBe('Try it live') + }) +}) diff --git a/packages/embed/src/mount-impl.tsx b/packages/embed/src/mount-impl.tsx index e73769ea9..ad2fb8498 100644 --- a/packages/embed/src/mount-impl.tsx +++ b/packages/embed/src/mount-impl.tsx @@ -33,7 +33,15 @@ declare global { } } -type BootResult = {dispose: () => void; rebind?: (apiBase: string) => void} +type BootResult = {dispose: () => void; rebind?: (apiBase: string) => void; interactive: Promise} + +function createInteractiveSignal(): {promise: Promise; notify: () => void} { + let notify = () => {} + const promise = new Promise((resolve) => { + notify = resolve + }) + return {promise, notify} +} function metaContent(name: string): string { return document.querySelector(`meta[name="${name}"]`)?.content ?? '' @@ -97,6 +105,7 @@ function bootNormal(config: BootNormalConfig): BootResult { const restore: {apply: (href: string) => void} = {apply: () => {}} const storage = makeNavigationStorage(rpc, (href) => restore.apply(href)) const hostRouter = window.__TSR_ROUTER__ + const interactive = createInteractiveSignal() const router = createConcivRouter({ rpc, history: createWebStorageHistory({storage}), @@ -109,6 +118,7 @@ function bootNormal(config: BootNormalConfig): BootResult { grabProvider: config.grabProvider, apiBase, connectionGeneration, + notifyInteractive: interactive.notify, }) window.__TSR_ROUTER__ = hostRouter const driver = makeDomPageDriver({tools: mountedClientTools(router), effects: mountedClientEffects(router)}) @@ -154,8 +164,9 @@ function bootNormal(config: BootNormalConfig): BootResult { driver.dispose, reachabilityRoot.dispose, closeConnection, + interactive.notify, ] - return {dispose: () => runDisposers(disposers), rebind} + return {dispose: () => runDisposers(disposers), rebind, interactive: interactive.promise} } type BootConnectConfig = { @@ -185,6 +196,7 @@ function bootConnect(config: BootConnectConfig): BootResult { }).dispose } const hostRouter = window.__TSR_ROUTER__ + const interactive = createInteractiveSignal() const router = createConcivRouter({ rpc: deferred.rpc, history: createMemoryHistory({initialEntries: [connectPath(config.settings)]}), @@ -197,6 +209,7 @@ function bootConnect(config: BootConnectConfig): BootResult { disconnect: makeDisconnect(() => boundApiBase), grabProvider: config.grabProvider, apiBase, + notifyInteractive: interactive.notify, }) window.__TSR_ROUTER__ = hostRouter const driver = makeDomPageDriver({tools: mountedClientTools(router), effects: mountedClientEffects(router)}) @@ -213,8 +226,9 @@ function bootConnect(config: BootConnectConfig): BootResult { driver.dispose, reachabilityRoot.dispose, deferred.close, + interactive.notify, ] - return {dispose: () => runDisposers(disposers)} + return {dispose: () => runDisposers(disposers), interactive: interactive.promise} } async function boot(root: ShadowRoot, init: ConcivInit): Promise { @@ -252,6 +266,7 @@ export function mountImpl( } disposeBoot = result.dispose rebindBoot = result.rebind + return result.interactive }) const rebind = async (apiBase: string): Promise => { await ready diff --git a/packages/embed/src/mount.ts b/packages/embed/src/mount.ts index 9cabd9974..8e1b03e9e 100644 --- a/packages/embed/src/mount.ts +++ b/packages/embed/src/mount.ts @@ -1,6 +1,7 @@ import type {AnyExtension} from '@conciv/extension' import type {GrabProvider} from '@conciv/grab' import type {ConcivSettingsInit} from '@conciv/protocol/config-types' +import type {EventBusClient, PanelCommandEventMap} from '@conciv/protocol/event-bus' export type {ConcivSettingsInit} from '@conciv/protocol/config-types' @@ -24,16 +25,26 @@ export type ConcivHandle = { type MountState = 'unmounted' | 'mounting' | 'mounted' -function dispatch(name: string, detail?: Record): void { - if (typeof window === 'undefined') return - window.dispatchEvent(new CustomEvent(name, {detail})) -} +type PanelCommandName = keyof PanelCommandEventMap & string export function createConciv(init: ConcivInit = {}): ConcivHandle { let state: MountState = 'unmounted' let abort: AbortController | undefined let teardown: (() => void) | undefined let rebindImpl: ((apiBase: string) => Promise) | undefined + let panelCommandsPromise: Promise> | null = null + + function panelCommands(): Promise> { + if (panelCommandsPromise) return panelCommandsPromise + panelCommandsPromise = import('@conciv/protocol/event-bus').then((eventBus) => + eventBus.createEventBusClient({ + pluginId: eventBus.PANEL_PLUGIN_ID, + reconnectEveryMs: 500, + maxRetries: 60, + }), + ) + return panelCommandsPromise + } async function mount(el: HTMLElement): Promise { if (typeof document === 'undefined') return @@ -67,18 +78,32 @@ export function createConciv(init: ConcivInit = {}): ConcivHandle { teardown = undefined rebindImpl = undefined state = 'unmounted' + if (panelCommandsPromise) { + void panelCommandsPromise.then((client) => client.dispose()).catch(() => {}) + panelCommandsPromise = null + } + } + + function emitPanelCommand(name: PanelCommandName): void { + if (typeof window === 'undefined') return + void panelCommands() + .then((client) => client.emit(name, undefined)) + .catch((error: unknown) => { + panelCommandsPromise = null + console.error('[conciv] panel command failed', error) + }) } function open(): void { - dispatch('conciv:open-panel') + emitPanelCommand('open') } function close(): void { - dispatch('conciv:close-panel') + emitPanelCommand('close') } function toggle(): void { - dispatch('conciv:toggle-panel') + emitPanelCommand('toggle') } async function rebind(apiBase: string): Promise { @@ -89,13 +114,16 @@ export function createConciv(init: ConcivInit = {}): ConcivHandle { return {mount, unmount, open, close, toggle, rebind} } -export function mountConciv(extensions: AnyExtension[]): void { - if (typeof document === 'undefined') return - if (document.querySelector('[data-conciv-script-root]')) return +export function mountConciv(extensions: AnyExtension[]): Promise { + if (typeof document === 'undefined') return Promise.resolve() + if (document.querySelector('[data-conciv-script-root]')) return Promise.resolve() const el = document.createElement('div') el.setAttribute('data-conciv-script-root', '') document.body.appendChild(el) - void createConciv({extensions}) + return createConciv({extensions}) .mount(el) - .catch(() => undefined) + .catch((error: unknown) => { + el.remove() + throw error + }) } diff --git a/packages/embed/tests/e2e/mount-ready.it.test.ts b/packages/embed/tests/e2e/mount-ready.it.test.ts new file mode 100644 index 000000000..a8b608887 --- /dev/null +++ b/packages/embed/tests/e2e/mount-ready.it.test.ts @@ -0,0 +1,41 @@ +import {expect, test, type Page} from '@playwright/test' +import {bootEmbedKit, type EmbedKit} from '../helpers/boot.js' +import {handleHostPage} from '../helpers/host.js' +import {serveHost} from '@conciv/extension-testkit/serve-host' +import {chatBox} from './helpers/chat.js' + +let kit: EmbedKit +let host: {base: string; close: () => Promise} + +test.beforeAll(async () => { + kit = await bootEmbedKit({text: 'ready contract'}) + host = await serveHost(() => handleHostPage()) +}) + +test.afterAll(async () => { + await host.close() + await kit.cleanup() +}) + +async function openPage(page: Page): Promise { + await page.goto(host.base, {waitUntil: 'domcontentloaded'}) + return page +} + +test.describe('mount() readiness contract', () => { + test('an open dispatched right after mount() resolves opens the panel, never dropped', async ({page}) => { + await openPage(page) + await page.evaluate(async (base) => { + const el = document.createElement('div') + document.body.appendChild(el) + const handle = window.ConcivHandle.makeHandle(base) + await handle.mount(el) + window.dispatchEvent( + new CustomEvent('conciv-dispatch-event', { + detail: {type: 'panel:open', payload: undefined, pluginId: 'panel'}, + }), + ) + }, kit.base) + await expect(chatBox(page)).toBeVisible({timeout: 10_000}) + }) +}) diff --git a/packages/embed/tests/e2e/panel-focus.it.test.ts b/packages/embed/tests/e2e/panel-focus.it.test.ts index 4a1af9727..6ec74145c 100644 --- a/packages/embed/tests/e2e/panel-focus.it.test.ts +++ b/packages/embed/tests/e2e/panel-focus.it.test.ts @@ -51,7 +51,13 @@ async function openPanelOverFocusedHostButton(page: Page): Promise const hostButton = page.getByRole('button', {name: 'Host action'}) await hostButton.click() await expect(hostButton).toBeFocused() - await page.evaluate(() => window.dispatchEvent(new Event('conciv:open-panel'))) + await page.evaluate(() => + window.dispatchEvent( + new CustomEvent('conciv-dispatch-event', { + detail: {type: 'panel:open', payload: undefined, pluginId: 'panel'}, + }), + ), + ) await expect(composer(page)).toBeVisible({timeout: 30_000}) return {host, page, hostButton} } diff --git a/packages/embed/tests/fixtures/global-entry.ts b/packages/embed/tests/fixtures/global-entry.ts index f8cbb6d2b..444020d94 100644 --- a/packages/embed/tests/fixtures/global-entry.ts +++ b/packages/embed/tests/fixtures/global-entry.ts @@ -2,4 +2,4 @@ import terminal from '@conciv/extension-terminal/client' import recorder from '@conciv/extension-recorder/client' import {mountConciv} from '../../src/mount.js' -mountConciv([terminal, recorder]) +void mountConciv([terminal, recorder]) diff --git a/packages/embed/tests/unit/native-bundle.test.ts b/packages/embed/tests/unit/native-bundle.test.ts index d1764d9ff..78c731555 100644 --- a/packages/embed/tests/unit/native-bundle.test.ts +++ b/packages/embed/tests/unit/native-bundle.test.ts @@ -18,7 +18,7 @@ describe('embed native bundle', () => { }) it('bundles the conciv app graph into the native entry', () => { - expect(nativeBundle.includes('conciv:open-panel')).toBe(true) + expect(nativeBundle.includes('conciv chat agent')).toBe(true) }) it('leaves no runtime import external in the self-contained native bundle', () => { diff --git a/packages/extension-compiler/src/extensions.ts b/packages/extension-compiler/src/extensions.ts index 027334cd1..eb61bdd47 100644 --- a/packages/extension-compiler/src/extensions.ts +++ b/packages/extension-compiler/src/extensions.ts @@ -42,7 +42,7 @@ export function extensionsModuleSource( `const builtinEntries = [${builtinNames.map((n, i) => `{extension: ${n}, source: 'builtin:${i}'}`).join(', ')}]`, `const picked = dedupeExtensions([...builtinEntries, ...folderEntries])`, `for (const d of picked.dropped) console.warn('conciv extension dropped:', d.source, d.reason)`, - `mountConciv(picked.extensions)`, + `mountConciv(picked.extensions).catch((error) => console.error('[conciv] widget failed to start', error))`, '', ].join('\n') } diff --git a/packages/extensions/ios/package.json b/packages/extensions/ios/package.json index 99b6c1e04..6a7681fd1 100644 --- a/packages/extensions/ios/package.json +++ b/packages/extensions/ios/package.json @@ -56,6 +56,7 @@ "dependencies": { "@conciv/extension": "workspace:^", "@conciv/grab": "workspace:^", + "@conciv/protocol": "workspace:^", "zod": "^4.4.3" }, "devDependencies": { diff --git a/packages/extensions/ios/src/client.tsx b/packages/extensions/ios/src/client.tsx index 2891da111..f20776b6c 100644 --- a/packages/extensions/ios/src/client.tsx +++ b/packages/extensions/ios/src/client.tsx @@ -1,5 +1,6 @@ import {createSignal, Show, type JSX} from 'solid-js' import {defineExtension} from '@conciv/extension' +import {createEventBusClient, PANEL_PLUGIN_ID, type PanelCommandEventMap} from '@conciv/protocol/event-bus' import type {ElementRect, GrabActions, GrabProvider} from '@conciv/grab' import {createBridgeClient, type BridgeClient, type BridgeTransport} from './shared/bridge-client.js' import type {GrabMode} from './shared/bridge.js' @@ -42,6 +43,12 @@ function dispatch(name: string, detail?: Record): void { window.dispatchEvent(new CustomEvent(name, {detail})) } +const panelCommands = createEventBusClient({ + pluginId: PANEL_PLUGIN_ID, + reconnectEveryMs: 500, + maxRetries: 60, +}) + function makeClientId(): string { const random = globalThis.crypto?.randomUUID?.() return `native-${random ?? Math.random().toString(36).slice(2)}` @@ -94,8 +101,8 @@ function ensureBridge(): BridgeClient | null { }, clientId: makeClientId(), boundApiBase: nativePageBase(window.location), - ensureOpen: () => dispatch('conciv:open-panel'), - ensureClose: () => dispatch('conciv:close-panel'), + ensureOpen: () => panelCommands.emit('open', undefined), + ensureClose: () => panelCommands.emit('close', undefined), onRebind: (apiBase) => dispatch('conciv:rebind', {apiBase}), onIncompatible: (info) => setIncompatible(info), onGrabbableChanged: (value) => setGrabbable(value), @@ -115,13 +122,13 @@ export function makeNativeGrabProvider(): GrabProvider { if (!engine) return null const token = ++requestSeq activeRequest = token - dispatch('conciv:close-panel') + panelCommands.emit('close', undefined) try { return await engine.pick(mode) } finally { if (activeRequest === token) { activeRequest = 0 - dispatch('conciv:open-panel') + panelCommands.emit('open', undefined) } } } diff --git a/packages/extensions/tanstack/test/host/main.tsx b/packages/extensions/tanstack/test/host/main.tsx index 89b1a93b0..3ada8131b 100644 --- a/packages/extensions/tanstack/test/host/main.tsx +++ b/packages/extensions/tanstack/test/host/main.tsx @@ -152,5 +152,5 @@ function App() { const rootElement = document.getElementById('root') if (rootElement) { createRoot(rootElement).render() - mountConciv([tanstackExtension, connectProbe]) + void mountConciv([tanstackExtension, connectProbe]) } diff --git a/packages/plugin/src/nextjs-widget.ts b/packages/plugin/src/nextjs-widget.ts index 2bea6a608..f8bc25333 100644 --- a/packages/plugin/src/nextjs-widget.ts +++ b/packages/plugin/src/nextjs-widget.ts @@ -11,14 +11,18 @@ async function startWidget(): Promise { ]) const picked = dedupeExtensions(entries) for (const drop of picked.dropped) console.warn('conciv extension dropped:', drop.source, drop.reason) - mountConciv(picked.extensions) + await mountConciv(picked.extensions) } if (typeof window !== 'undefined' && port && process.env.NODE_ENV !== 'production') { if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', () => void startWidget(), {once: true}) + document.addEventListener( + 'DOMContentLoaded', + () => startWidget().catch((error) => console.error('[conciv] widget failed to start', error)), + {once: true}, + ) } else { - void startWidget() + startWidget().catch((error) => console.error('[conciv] widget failed to start', error)) } } diff --git a/packages/protocol/package.json b/packages/protocol/package.json index c6bb52d79..41b3954e9 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -92,6 +92,10 @@ "./state-types": { "types": "./dist/state-types.d.ts", "import": "./dist/state-types.js" + }, + "./event-bus": { + "types": "./dist/event-bus.d.ts", + "import": "./dist/event-bus.js" } }, "publishConfig": { diff --git a/packages/protocol/src/event-bus.ts b/packages/protocol/src/event-bus.ts new file mode 100644 index 000000000..6f2fa02ea --- /dev/null +++ b/packages/protocol/src/event-bus.ts @@ -0,0 +1,279 @@ +declare const window: EventTarget | undefined + +declare global { + var __CONCIV_EVENT_BUS_TARGET__: EventTarget | undefined +} + +export const DISPATCH_EVENT = 'conciv-dispatch-event' +export const CONNECT_EVENT = 'conciv-connect' +export const CONNECT_SUCCESS_EVENT = 'conciv-connect-success' +export const GLOBAL_EVENT = 'conciv-global' + +function sharedEventTarget(): EventTarget { + if (typeof window !== 'undefined') return window + if (globalThis.__CONCIV_EVENT_BUS_TARGET__ === undefined) globalThis.__CONCIV_EVENT_BUS_TARGET__ = new EventTarget() + return globalThis.__CONCIV_EVENT_BUS_TARGET__ +} + +export type EventBusTarget = () => EventTarget + +export type EventBusScheduler = { + setInterval: (callback: () => void, ms: number) => unknown + clearInterval: (id: unknown) => void +} + +function isTimerHandle(id: unknown): id is Parameters[0] { + return id !== null +} + +function defaultScheduler(): EventBusScheduler { + return { + setInterval: (callback, ms) => setInterval(callback, ms), + clearInterval: (id) => { + if (!isTimerHandle(id)) return + clearInterval(id) + }, + } +} + +export type EventBusEnvelope = { + type: string + payload: TPayload + pluginId: string +} + +function customEventDetail(event: Event): TDetail | null { + if (!(event instanceof CustomEvent)) return null + const detail: TDetail | null | undefined = event.detail + return detail ?? null +} + +function readEnvelope(event: Event): EventBusEnvelope | null { + const detail = customEventDetail(event) + if (typeof detail !== 'object' || detail === null) return null + if (!('type' in detail) || !('payload' in detail) || !('pluginId' in detail)) return null + const {type, payload, pluginId} = detail + if (typeof type !== 'string' || typeof pluginId !== 'string') return null + return {type, payload, pluginId} +} + +export type EventBusOptions = { + target?: EventBusTarget +} + +export type EventBus = { + start: () => void + stop: () => void +} + +export function createEventBus(options: EventBusOptions = {}): EventBus { + const target = options.target ?? sharedEventTarget + let started = false + + function redispatch(event: Event): void { + const envelope = readEnvelope(event) + if (!envelope) return + target().dispatchEvent(new CustomEvent(envelope.type, {detail: envelope})) + target().dispatchEvent(new CustomEvent(GLOBAL_EVENT, {detail: envelope})) + } + + function acknowledgeConnect(): void { + target().dispatchEvent(new CustomEvent(CONNECT_SUCCESS_EVENT)) + } + + function start(): void { + if (started) return + started = true + target().addEventListener(DISPATCH_EVENT, redispatch) + target().addEventListener(CONNECT_EVENT, acknowledgeConnect) + } + + function stop(): void { + if (!started) return + started = false + target().removeEventListener(DISPATCH_EVENT, redispatch) + target().removeEventListener(CONNECT_EVENT, acknowledgeConnect) + } + + return {start, stop} +} + +export type EventBusClientState = 'idle' | 'connecting' | 'ready' | 'failed' + +export type EventBusClientOptions = { + pluginId: string + target?: EventBusTarget + scheduler?: EventBusScheduler + reconnectEveryMs?: number + maxRetries?: number +} + +export type EventBusClient> = { + emit: (name: TName, payload: TEventMap[TName]) => void + on: ( + name: TName, + handler: (event: EventBusEnvelope) => void, + ) => () => void + onAll: (handler: (event: EventBusEnvelope) => void) => () => void + getState: () => EventBusClientState + subscribe: (listener: () => void) => () => void + dispose: () => void +} + +export function createEventBusClient>( + options: EventBusClientOptions, +): EventBusClient { + const target = options.target ?? sharedEventTarget + const scheduler = options.scheduler ?? defaultScheduler() + const reconnectEveryMs = options.reconnectEveryMs ?? 300 + const maxRetries = options.maxRetries ?? 5 + const pluginId = options.pluginId + + let state: EventBusClientState = 'idle' + let queue: EventBusEnvelope[] = [] + let retryCount = 0 + let intervalId: unknown = null + const listeners = new Set<() => void>() + + function eventName(name: string): string { + return `${pluginId}:${name}` + } + + function envelopeFor( + name: TName, + payload: TEventMap[TName], + ): EventBusEnvelope { + return {type: eventName(name), payload, pluginId} + } + + function notify(): void { + for (const listener of listeners) listener() + } + + function sendToBus(envelope: EventBusEnvelope): void { + target().dispatchEvent(new CustomEvent(DISPATCH_EVENT, {detail: envelope})) + } + + function flushQueue(): void { + const pending = queue + queue = [] + for (const envelope of pending) sendToBus(envelope) + } + + function stopConnectLoop(): void { + if (intervalId === null) return + scheduler.clearInterval(intervalId) + intervalId = null + } + + function onConnectSuccess(): void { + target().removeEventListener(CONNECT_SUCCESS_EVENT, onConnectSuccess) + stopConnectLoop() + state = 'ready' + flushQueue() + notify() + } + + function attemptConnect(): void { + if (retryCount >= maxRetries) { + target().removeEventListener(CONNECT_SUCCESS_EVENT, onConnectSuccess) + stopConnectLoop() + state = 'failed' + queue = [] + notify() + return + } + retryCount += 1 + target().dispatchEvent(new CustomEvent(CONNECT_EVENT)) + } + + function startHandshake(): void { + state = 'connecting' + target().addEventListener(CONNECT_SUCCESS_EVENT, onConnectSuccess) + attemptConnect() + if (state === 'connecting') intervalId = scheduler.setInterval(attemptConnect, reconnectEveryMs) + notify() + } + + function emit(name: TName, payload: TEventMap[TName]): void { + const envelope = envelopeFor(name, payload) + if (state === 'ready') { + sendToBus(envelope) + return + } + if (state === 'failed') { + retryCount = 0 + queue.push(envelope) + startHandshake() + return + } + queue.push(envelope) + if (state === 'idle') startHandshake() + } + + function on( + name: TName, + handler: (event: EventBusEnvelope) => void, + ): () => void { + const fullName = eventName(name) + const listener = (event: Event) => { + const envelope = customEventDetail>(event) + if (!envelope) return + handler(envelope) + } + target().addEventListener(fullName, listener) + return () => target().removeEventListener(fullName, listener) + } + + function onAll(handler: (event: EventBusEnvelope) => void): () => void { + const listener = (event: Event) => { + const envelope = readEnvelope(event) + if (!envelope || envelope.pluginId !== pluginId) return + handler(envelope) + } + target().addEventListener(GLOBAL_EVENT, listener) + return () => target().removeEventListener(GLOBAL_EVENT, listener) + } + + function getState(): EventBusClientState { + return state + } + + function subscribe(listener: () => void): () => void { + listeners.add(listener) + return () => listeners.delete(listener) + } + + function dispose(): void { + stopConnectLoop() + target().removeEventListener(CONNECT_SUCCESS_EVENT, onConnectSuccess) + listeners.clear() + queue = [] + } + + return {emit, on, onAll, getState, subscribe, dispose} +} + +export const PANEL_PLUGIN_ID = 'panel' + +export type PanelCommandEventMap = { + open: undefined + close: undefined + toggle: undefined +} + +export const CONNECTION_CHANGED_EVENT = 'conciv:connection-changed' +export const PANEL_TOGGLED_EVENT = 'conciv:panel-toggled' + +export type WidgetConnectionChangedDetail = {connected: boolean} + +export type WidgetPanelToggledDetail = { + open: boolean + connected: boolean + mascotRect: {x: number; y: number; width: number; height: number} | null +} + +export type WidgetStatusEventMap = { + [CONNECTION_CHANGED_EVENT]: WidgetConnectionChangedDetail + [PANEL_TOGGLED_EVENT]: WidgetPanelToggledDetail +} diff --git a/packages/protocol/test/event-bus.test.ts b/packages/protocol/test/event-bus.test.ts new file mode 100644 index 000000000..6618dbafb --- /dev/null +++ b/packages/protocol/test/event-bus.test.ts @@ -0,0 +1,216 @@ +import {describe, expect, it} from 'vitest' +import { + createEventBus, + createEventBusClient, + GLOBAL_EVENT, + type EventBusEnvelope, + type EventBusScheduler, +} from '../src/event-bus.js' + +type CommandMap = { + open: undefined + close: undefined +} + +function createManualScheduler(): {scheduler: EventBusScheduler; tick: () => void; intervalCount: () => number} { + const callbacks = new Map void>() + let nextId = 0 + return { + scheduler: { + setInterval: (callback) => { + const id = nextId + nextId += 1 + callbacks.set(id, callback) + return id + }, + clearInterval: (id) => { + if (typeof id !== 'number') return + callbacks.delete(id) + }, + }, + tick: () => { + for (const callback of callbacks.values()) callback() + }, + intervalCount: () => callbacks.size, + } +} + +describe('createEventBusClient / createEventBus', () => { + it('queues emits made before the handshake completes and flushes them in order once acked', () => { + const target = new EventTarget() + const {scheduler, tick} = createManualScheduler() + const received: unknown[] = [] + const bus = createEventBus({target: () => target}) + const listener = createEventBusClient({pluginId: 'panel', target: () => target, scheduler}) + listener.on('open', (event) => received.push([event.type, event.pluginId])) + listener.on('close', (event) => received.push([event.type, event.pluginId])) + + const client = createEventBusClient({pluginId: 'panel', target: () => target, scheduler}) + client.emit('open', undefined) + client.emit('close', undefined) + expect(received).toEqual([]) + expect(client.getState()).toBe('connecting') + + bus.start() + expect(received).toEqual([]) + + tick() + expect(received).toEqual([ + ['panel:open', 'panel'], + ['panel:close', 'panel'], + ]) + expect(client.getState()).toBe('ready') + }) + + it('stops retrying once the bus acks the connection', () => { + const target = new EventTarget() + const {scheduler, tick, intervalCount} = createManualScheduler() + const bus = createEventBus({target: () => target}) + bus.start() + + const client = createEventBusClient({pluginId: 'panel', target: () => target, scheduler}) + client.emit('open', undefined) + expect(client.getState()).toBe('ready') + expect(intervalCount()).toBe(0) + + tick() + expect(client.getState()).toBe('ready') + }) + + it('gives up after the bounded retry count and drops the queue', () => { + const target = new EventTarget() + const {scheduler, tick} = createManualScheduler() + const client = createEventBusClient({ + pluginId: 'panel', + target: () => target, + scheduler, + maxRetries: 2, + }) + + client.emit('open', undefined) + expect(client.getState()).toBe('connecting') + + tick() + expect(client.getState()).toBe('connecting') + + tick() + expect(client.getState()).toBe('failed') + + const received: unknown[] = [] + const bus = createEventBus({target: () => target}) + const listener = createEventBusClient({pluginId: 'panel', target: () => target, scheduler}) + listener.on('open', (event) => received.push(event)) + bus.start() + expect(received).toEqual([]) + }) + + it('restarts the handshake with a fresh retry budget when emit is called again after failure', () => { + const target = new EventTarget() + const {scheduler, tick} = createManualScheduler() + const client = createEventBusClient({ + pluginId: 'panel', + target: () => target, + scheduler, + maxRetries: 2, + }) + + client.emit('open', undefined) + tick() + tick() + expect(client.getState()).toBe('failed') + + client.emit('close', undefined) + expect(client.getState()).toBe('connecting') + + const received: unknown[] = [] + const bus = createEventBus({target: () => target}) + const listener = createEventBusClient({pluginId: 'panel', target: () => target, scheduler}) + listener.on('close', (event) => received.push(event.type)) + bus.start() + expect(received).toEqual([]) + + tick() + expect(received).toEqual(['panel:close']) + expect(client.getState()).toBe('ready') + }) + + it('passes an emit straight through once the client is already connected', () => { + const target = new EventTarget() + const {scheduler} = createManualScheduler() + const received: unknown[] = [] + const bus = createEventBus({target: () => target}) + const listener = createEventBusClient({pluginId: 'panel', target: () => target, scheduler}) + listener.on('open', (event) => received.push(event.payload)) + bus.start() + + const client = createEventBusClient({pluginId: 'panel', target: () => target, scheduler}) + client.emit('open', undefined) + expect(client.getState()).toBe('ready') + + client.emit('open', undefined) + expect(received).toEqual([undefined, undefined]) + }) + + it('re-dispatches every emit as both the specific envelope event and the global event', () => { + const target = new EventTarget() + const {scheduler} = createManualScheduler() + const specific: EventBusEnvelope[] = [] + const global: EventBusEnvelope[] = [] + const bus = createEventBus({target: () => target}) + bus.start() + target.addEventListener('panel:open', (event) => { + if (event instanceof CustomEvent) specific.push(event.detail) + }) + target.addEventListener(GLOBAL_EVENT, (event) => { + if (event instanceof CustomEvent) global.push(event.detail) + }) + + const client = createEventBusClient({pluginId: 'panel', target: () => target, scheduler}) + client.emit('open', undefined) + + expect(specific).toEqual([{type: 'panel:open', payload: undefined, pluginId: 'panel'}]) + expect(global).toEqual([{type: 'panel:open', payload: undefined, pluginId: 'panel'}]) + }) + + it('filters onAll down to the listening client own pluginId', () => { + const target = new EventTarget() + const {scheduler} = createManualScheduler() + const seen: string[] = [] + const bus = createEventBus({target: () => target}) + bus.start() + + const listener = createEventBusClient({pluginId: 'panel', target: () => target, scheduler}) + listener.onAll((event) => seen.push(event.type)) + + const panelClient = createEventBusClient({pluginId: 'panel', target: () => target, scheduler}) + const otherClient = createEventBusClient({pluginId: 'other', target: () => target, scheduler}) + otherClient.emit('open', undefined) + panelClient.emit('close', undefined) + + expect(seen).toEqual(['panel:close']) + }) + + it('stops delivering to a listener that unsubscribed and to every listener once the bus stops', () => { + const target = new EventTarget() + const {scheduler} = createManualScheduler() + const seen: string[] = [] + const bus = createEventBus({target: () => target}) + bus.start() + + const listener = createEventBusClient({pluginId: 'panel', target: () => target, scheduler}) + const unsubscribe = listener.on('open', (event) => seen.push(event.type)) + + const client = createEventBusClient({pluginId: 'panel', target: () => target, scheduler}) + client.emit('open', undefined) + expect(seen).toEqual(['panel:open']) + + unsubscribe() + client.emit('open', undefined) + expect(seen).toEqual(['panel:open']) + + listener.on('close', (event) => seen.push(event.type)) + bus.stop() + client.emit('close', undefined) + expect(seen).toEqual(['panel:open']) + }) +}) diff --git a/packages/protocol/tsdown.config.ts b/packages/protocol/tsdown.config.ts index 64ab878f3..4758852ad 100644 --- a/packages/protocol/tsdown.config.ts +++ b/packages/protocol/tsdown.config.ts @@ -20,6 +20,7 @@ export default defineConfig({ 'src/tool-view-types.ts', 'src/connect-ports.ts', 'src/state-types.ts', + 'src/event-bus.ts', ], format: 'esm', noExternal: [/^@tanstack\/ai(\/|$)/], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 73b055986..dee00eea9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2069,6 +2069,9 @@ importers: '@conciv/grab': specifier: workspace:^ version: link:../../grab + '@conciv/protocol': + specifier: workspace:^ + version: link:../../protocol zod: specifier: ^4.4.3 version: 4.4.3