Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/try-live-panel-ready-contract.md
Original file line number Diff line number Diff line change
@@ -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: '<pluginId>:<suffix>', payload, pluginId}`) and dispatched on one fixed bus event (`conciv-dispatch-event`); the running bus re-dispatches it as both a specific `<pluginId>:<suffix>` 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.
5 changes: 5 additions & 0 deletions apps/conciv/src/app/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export type AppContextValue = {
grabProvider?: GrabProvider
connectionGeneration: () => number
apiBase: () => string
notifyInteractive: () => void
}

export const AppContext = createContext<AppContextValue>()
Expand Down Expand Up @@ -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)
}
3 changes: 3 additions & 0 deletions apps/conciv/src/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export type ConcivRouterContext = {
apiBase: () => string
connectionGeneration: () => number
disposeInstances: () => void
notifyInteractive: () => void
}

export type ConcivRouterConfig = {
Expand All @@ -48,6 +49,7 @@ export type ConcivRouterConfig = {
grabProvider?: GrabProvider
apiBase?: () => string
connectionGeneration?: () => number
notifyInteractive?: () => void
}

function disposeExtensionInstances(instances: ExtensionInstance[]): void {
Expand Down Expand Up @@ -123,6 +125,7 @@ export function createConcivRouter(config: ConcivRouterConfig) {
apiBase,
connectionGeneration: config.connectionGeneration ?? (() => 0),
disposeInstances,
notifyInteractive: config.notifyInteractive ?? (() => {}),
},
})
}
Expand Down
40 changes: 30 additions & 10 deletions apps/conciv/src/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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})
Expand Down Expand Up @@ -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()},
}),
)
Expand Down Expand Up @@ -259,17 +270,26 @@ function RootChrome(props: {
onCleanup(() => cancelAnimationFrame(frame))
})

const eventBus = createEventBus()
const panelCommands = createEventBusClient<PanelCommandEventMap>({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) => {
Expand Down
1 change: 1 addition & 0 deletions apps/conciv/test/helpers/pane-harness.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
11 changes: 9 additions & 2 deletions apps/site/src/components/landing/hero.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -28,7 +28,14 @@ export function Hero() {
<b className="font-semibold text-foreground">run your tests</b>, without ever leaving the thing you're
building.
</p>
{!isMobile && (
{isMobile ? (
<p className="text-[13.5px] text-muted-foreground">
The live try-it flow needs a terminal, so it's desktop-only.{' '}
<Link to="/docs/$" params={{_splat: 'quick-start'}} className="font-semibold text-primary hover:underline">
Read the quick start →
</Link>
</p>
) : (
<>
<InstallChip />
<TryLiveButton />
Expand Down
40 changes: 30 additions & 10 deletions apps/site/src/components/landing/try-live-button.tsx
Original file line number Diff line number Diff line change
@@ -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<WidgetConnectionChangedDetail>
}
}

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<PanelCommandEventMap>({
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 (
<div className="mt-6">
<Button variant="outline" onClick={open}>
<span className="size-1.5 rounded-full bg-primary" aria-hidden />
{isConnected ? 'Open agent panel' : 'Try it live: connect your agent'}
<Button variant="default" onClick={open} aria-busy={busState === 'connecting'}>
<span className="size-1.5 rounded-full bg-primary-foreground" aria-hidden />
{tryButtonLabel({connected: isConnected, pending: busState === 'connecting'})}
</Button>
</div>
)
Expand Down
14 changes: 10 additions & 4 deletions apps/site/src/lib/mount-live-widget.ts
Original file line number Diff line number Diff line change
@@ -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<WidgetPanelToggledDetail>
}
}

function ensureWidgetMeta(defaultOpen: boolean): void {
if (document.querySelector('meta[name="pw-widget"]')) return
const meta = document.createElement('meta')
Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions apps/site/src/lib/try-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
50 changes: 38 additions & 12 deletions apps/site/test/live-connect.it.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -17,12 +18,34 @@ afterAll(async () => {
await engine?.stop()
})

async function openLandingOnConnectSteps(page: Page): Promise<Locator> {
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<void> {
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+$/)
Expand Down Expand Up @@ -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)
})
2 changes: 2 additions & 0 deletions apps/site/test/mobile-gating.it.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading