From 7992ff416559e97b7e9e71fde2b35ff9fc8d45c3 Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Mon, 17 Aug 2026 21:42:47 +0100 Subject: [PATCH 1/2] (MOT-3732) feat(console): expose injectable UI chat controls --- console/web/src/App.tsx | 9 ++- .../web/src/hooks/use-conversations.test.ts | 35 ++++++++++ console/web/src/hooks/use-conversations.ts | 41 +++++++++-- console/web/src/lib/conversations-context.tsx | 53 ++++++++++++++ console/web/src/lib/ui-loader.test.tsx | 70 ++++++++++++++++++- console/web/src/lib/ui-loader.tsx | 15 +++- console/web/src/main.test.ts | 2 +- console/web/src/main.tsx | 23 +++--- console/web/src/types/injectable-ui.ts | 4 ++ packages/console-ui/index.d.ts | 4 ++ 10 files changed, 233 insertions(+), 23 deletions(-) diff --git a/console/web/src/App.tsx b/console/web/src/App.tsx index 2ac842d47..bbf07450f 100644 --- a/console/web/src/App.tsx +++ b/console/web/src/App.tsx @@ -26,6 +26,7 @@ import { } from '@/hooks/use-workspace-tabs' import { ConversationsProvider, + type InjectableUiRuntime, useConversationsCtx, } from '@/lib/conversations-context' import { loadEdgeAddDiscovered, saveEdgeAddDiscovered } from '@/lib/storage' @@ -46,7 +47,11 @@ import { TracesV2 } from '@/pages/TracesV2' import { Workers } from '@/pages/Workers' import type { PanelSide } from '@/types/injectable-ui' -export function App() { +export function App({ + injectableUiRuntime, +}: { + injectableUiRuntime?: Promise +}) { const [theme, setTheme] = useTheme() const [view, setView] = useHashRoute() const extPageId = useExtPageRoute() @@ -162,7 +167,7 @@ export function App() { }, []) return ( - +
): Conversation { @@ -420,3 +421,37 @@ describe('mergeConversationMeta / system_prompt', () => { expect(next.systemPrompt?.strategy).toBe('enrich') }) }) + +describe('resolveActiveConversationId', () => { + it('keeps a pending select until that session appears in the list', () => { + const waiting = resolveActiveConversationId({ + conversationIds: ['draft'], + activeId: 'draft', + pendingSelectId: 'worker-session', + }) + expect(waiting).toEqual({ + activeId: 'worker-session', + pendingSelectId: 'worker-session', + }) + + const arrived = resolveActiveConversationId({ + conversationIds: ['worker-session', 'draft'], + activeId: 'draft', + pendingSelectId: 'worker-session', + }) + expect(arrived).toEqual({ + activeId: 'worker-session', + pendingSelectId: null, + }) + }) + + it('falls back to the first conversation when nothing is pending or active', () => { + expect( + resolveActiveConversationId({ + conversationIds: ['a', 'b'], + activeId: 'gone', + pendingSelectId: null, + }), + ).toEqual({ activeId: 'a', pendingSelectId: null }) + }) +}) diff --git a/console/web/src/hooks/use-conversations.ts b/console/web/src/hooks/use-conversations.ts index 20b969059..101b11c29 100644 --- a/console/web/src/hooks/use-conversations.ts +++ b/console/web/src/hooks/use-conversations.ts @@ -238,6 +238,30 @@ export function applyCatalogModelFallback( return changed ? next : conversations } +/** Keep a just-selected session even if `session::created` has not yet + * inserted it into the sidebar list. Without this, the boot-time "always + * have an active chat" effect snaps back to conversations[0]. */ +export function resolveActiveConversationId(input: { + conversationIds: readonly string[] + activeId: string | null + pendingSelectId: string | null +}): { activeId: string | null; pendingSelectId: string | null } { + const { conversationIds, activeId, pendingSelectId } = input + if (conversationIds.length === 0) { + return { activeId, pendingSelectId } + } + if (pendingSelectId) { + if (conversationIds.includes(pendingSelectId)) { + return { activeId: pendingSelectId, pendingSelectId: null } + } + return { activeId: pendingSelectId, pendingSelectId } + } + if (!activeId || !conversationIds.includes(activeId)) { + return { activeId: conversationIds[0], pendingSelectId: null } + } + return { activeId, pendingSelectId: null } +} + /** * Mark every backgrounded server-backed conversation stale so the next * activation re-hydrates it. A transcript subscription exists only for the @@ -439,6 +463,7 @@ export function useConversations( emptyConversation(loadLastModel()), ]) const [activeId, setActiveId] = useState(() => loadActiveId()) + const pendingSelectIdRef = useRef(null) /** Highest seen `message-updated` revision per (session, entry). */ const revisionsRef = useRef(new Map>()) @@ -775,10 +800,13 @@ export function useConversations( /* Ensure there's always a sensible "active" pointer at the start. */ useEffect(() => { - if (conversations.length === 0) return - if (!activeId || !conversations.some((c) => c.id === activeId)) { - setActiveId(conversations[0].id) - } + const next = resolveActiveConversationId({ + conversationIds: conversations.map((c) => c.id), + activeId, + pendingSelectId: pendingSelectIdRef.current, + }) + pendingSelectIdRef.current = next.pendingSelectId + if (next.activeId !== activeId) setActiveId(next.activeId) }, [conversations, activeId]) const active = useMemo( @@ -793,7 +821,10 @@ export function useConversations( return next.id }, []) - const select = useCallback((id: string) => setActiveId(id), []) + const select = useCallback((id: string) => { + pendingSelectIdRef.current = id + setActiveId(id) + }, []) const rename = useCallback( (id: string, title: string) => { diff --git a/console/web/src/lib/conversations-context.tsx b/console/web/src/lib/conversations-context.tsx index da47ec59b..caa23bcca 100644 --- a/console/web/src/lib/conversations-context.tsx +++ b/console/web/src/lib/conversations-context.tsx @@ -3,6 +3,8 @@ import { type ReactNode, useCallback, useContext, + useEffect, + useRef, useState, } from 'react' import { @@ -27,11 +29,14 @@ import { } from '@/hooks/use-worktree-status' import type { ChatBackend } from '@/lib/backend' import { getDefaultBackend } from '@/lib/backend' +import type { IiiClient } from '@/lib/iii-client' import { type ProviderListEntry, refreshProviderModels, } from '@/lib/models-catalog' +import { type ConversationAdapter, startUiLoader } from '@/lib/ui-loader' import type { ModelOption } from '@/types/chat' +import type { ConsoleApi } from '@/types/injectable-ui' const backend = getDefaultBackend() @@ -88,8 +93,14 @@ const ConversationsContext = createContext( null, ) +export interface InjectableUiRuntime { + client: IiiClient + api: ConsoleApi +} + interface ConversationsProviderProps { children: ReactNode + injectableUiRuntime?: Promise } /** @@ -100,6 +111,7 @@ interface ConversationsProviderProps { */ export function ConversationsProvider({ children, + injectableUiRuntime, }: ConversationsProviderProps) { const harnessStatus = useHarnessStatus(backend.id === 'real') const harnessAvailable = isHarnessAvailable(harnessStatus) @@ -148,6 +160,47 @@ export function ConversationsProvider({ } }, [harnessAvailable, refresh, presentProviders]) + const selectConversationRef = useRef(api.select) + selectConversationRef.current = api.select + const conversationsRef = useRef(api.conversations) + conversationsRef.current = api.conversations + const activeIdRef = useRef(api.activeId) + activeIdRef.current = api.activeId + const conversationAdapterRef = useRef(null) + if (!conversationAdapterRef.current) { + conversationAdapterRef.current = { + selectConversation(sessionId) { + const id = sessionId.trim() + if (id) selectConversationRef.current(id) + }, + composerModel(conversationId) { + const requested = conversationId?.trim() + const id = requested || activeIdRef.current + if (!id) return null + const model = conversationsRef.current.find( + (conversation) => conversation.id === id, + )?.model + return typeof model === 'string' && model.trim() ? model.trim() : null + }, + } + } + + useEffect(() => { + if (!injectableUiRuntime) return + let active = true + let stop: (() => void) | undefined + void injectableUiRuntime + .then(({ client, api: consoleApi }) => { + if (!active || !conversationAdapterRef.current) return + stop = startUiLoader(client, consoleApi, conversationAdapterRef.current) + }) + .catch(() => undefined) + return () => { + active = false + stop?.() + } + }, [injectableUiRuntime]) + const value: ConversationsContextValue = { ...api, backend, diff --git a/console/web/src/lib/ui-loader.test.tsx b/console/web/src/lib/ui-loader.test.tsx index dbb66cf68..38ed7708b 100644 --- a/console/web/src/lib/ui-loader.test.tsx +++ b/console/web/src/lib/ui-loader.test.tsx @@ -7,7 +7,11 @@ import type { UiAssetsPush, } from '../types/injectable-ui' import type { IiiClient } from './iii-client' -import { startUiLoader, UI_ASSETS_FN } from './ui-loader' +import { + type ConversationAdapter, + startUiLoader, + UI_ASSETS_FN, +} from './ui-loader' import { getExtConfigForm, getUiAssetsStatus, @@ -35,9 +39,14 @@ function setupForm(label: string): UiModule { } function createHarness({ + conversationAdapter = { + selectConversation: vi.fn(), + composerModel: vi.fn(() => null), + }, importModule = vi.fn(async () => setupForm('default')), manifest = Promise.resolve({ disabled: false }), }: { + conversationAdapter?: ConversationAdapter importModule?: (url: string) => Promise manifest?: Promise<{ disabled: boolean }> } = {}) { @@ -60,7 +69,7 @@ function createHarness({ tokens: [], useTheme: () => 'light', } as ConsoleApi - const stop = startUiLoader(client, api, { + const stop = startUiLoader(client, api, conversationAdapter, { baseUrl: new URL('http://console.test/base/'), importModule, }) @@ -185,3 +194,60 @@ describe('injectable UI script updates', () => { harness.stop() }) }) + +describe('injectable UI conversation adapters', () => { + it('keeps concurrent loader hosts isolated through teardown and reload', async () => { + const selectA = vi.fn() + const selectB = vi.fn() + const modelA = vi.fn(() => 'provider::model-a') + const modelB = vi.fn(() => 'provider::model-b') + const observed: string[] = [] + const moduleFor = (sessionId: string): UiModule => ({ + default(host) { + host.chat.selectConversation?.(sessionId) + observed.push(host.chat.composerModel?.('draft') ?? 'missing') + }, + }) + const first = createHarness({ + conversationAdapter: { + selectConversation: selectA, + composerModel: modelA, + }, + importModule: async () => moduleFor('session-a'), + }) + const second = createHarness({ + conversationAdapter: { + selectConversation: selectB, + composerModel: modelB, + }, + importModule: async () => moduleFor('session-b'), + }) + + first.emit({ + event: 'sync', + assets: [{ path: 'first/page.js', kind: 'script', hash: 'one' }], + }) + second.emit({ + event: 'sync', + assets: [{ path: 'second/page.js', kind: 'script', hash: 'one' }], + }) + await vi.waitFor(() => expect(observed).toHaveLength(2)) + expect(selectA).toHaveBeenCalledWith('session-a') + expect(selectA).not.toHaveBeenCalledWith('session-b') + expect(selectB).toHaveBeenCalledWith('session-b') + expect(selectB).not.toHaveBeenCalledWith('session-a') + expect(observed).toEqual(['provider::model-a', 'provider::model-b']) + + first.stop() + second.emit({ + event: 'set', + path: 'second/page.js', + kind: 'script', + hash: 'two', + }) + await vi.waitFor(() => expect(selectB).toHaveBeenCalledTimes(2)) + expect(selectA).toHaveBeenCalledTimes(1) + expect(modelB).toHaveBeenCalledTimes(2) + second.stop() + }) +}) diff --git a/console/web/src/lib/ui-loader.tsx b/console/web/src/lib/ui-loader.tsx index 4752a1ee3..cb30677fd 100644 --- a/console/web/src/lib/ui-loader.tsx +++ b/console/web/src/lib/ui-loader.tsx @@ -60,6 +60,11 @@ interface UiLoaderOptions { importModule?: (url: string) => Promise<{ default?: SetupFn }> } +export interface ConversationAdapter { + selectConversation(sessionId: string): void + composerModel(conversationId?: string | null): string | null +} + /** * The scope wrapper every injected render mounts inside: `data-iii-ui` * carries the first segment of the script's path (worker CSS compiles @@ -100,6 +105,7 @@ export function ExtErrorChip({ path, error }: { path: string; error: Error }) { function makeHost( api: ConsoleApi, + conversationAdapter: ConversationAdapter, path: string, cleanups: Array<() => void>, ): Host { @@ -183,6 +189,12 @@ function makeHost( }), ) }, + selectConversation(sessionId) { + conversationAdapter.selectConversation(sessionId) + }, + composerModel(conversationId) { + return conversationAdapter.composerModel(conversationId) + }, }, } } @@ -195,6 +207,7 @@ function makeHost( export function startUiLoader( client: IiiClient, api: ConsoleApi, + conversationAdapter: ConversationAdapter, options: UiLoaderOptions = {}, ): () => void { const loaded = new Map() @@ -245,7 +258,7 @@ export function startUiLoader( if (typeof mod.default !== 'function') { throw new Error('no default setup() export') } - const host = makeHost(api, path, cleanups) + const host = makeHost(api, conversationAdapter, path, cleanups) const teardown = await mod.default(host) if (typeof teardown === 'function') cleanups.push(teardown) loaded.set(path, { kind: 'script', path, hash, cleanups }) diff --git a/console/web/src/main.test.ts b/console/web/src/main.test.ts index ded05d69f..b349dc70c 100644 --- a/console/web/src/main.test.ts +++ b/console/web/src/main.test.ts @@ -31,7 +31,7 @@ describe('main.tsx injectable UI readiness wiring', () => { it('marks assets as loading before asynchronous client bootstrap', () => { const loadingAt = src.indexOf("setUiAssetsStatus('loading')") - const clientBootstrapAt = src.indexOf('\ngetIiiClient()') + const clientBootstrapAt = src.indexOf('getIiiClient()', loadingAt) expect(loadingAt).toBeGreaterThan(-1) expect(clientBootstrapAt).toBeGreaterThan(-1) diff --git a/console/web/src/main.tsx b/console/web/src/main.tsx index 5edee1672..a6fcb9f7f 100644 --- a/console/web/src/main.tsx +++ b/console/web/src/main.tsx @@ -9,7 +9,6 @@ import { TooltipProvider } from '@/components/ui/Tooltip' import { buildConsoleApi } from '@/lib/console-api' import { installRandomUUIDPolyfill } from '@/lib/crypto-polyfill' import { getIiiClient } from '@/lib/iii-client' -import { startUiLoader } from '@/lib/ui-loader' import { setUiAssetsStatus } from '@/lib/ui-slots' import { App } from './App' import faviconUrl from './icons/favicon.svg?url' @@ -44,16 +43,16 @@ window.__III_CONSOLE__ = bootGlobal // injected-UI slots as loading synchronously so configuration editors do not // mistake a not-yet-registered override for a genuinely absent one. setUiAssetsStatus('loading') -getIiiClient() - .then((client) => { - bootGlobal.api = buildConsoleApi(client) - Object.freeze(bootGlobal) - startUiLoader(client, bootGlobal.api) - }) - .catch((err) => { - setUiAssetsStatus('unavailable') - console.error('[iii-ui] loader not started — engine client failed', err) - }) +const injectableUiRuntime = getIiiClient().then((client) => { + const api = buildConsoleApi(client) + bootGlobal.api = api + Object.freeze(bootGlobal) + return { client, api } +}) +void injectableUiRuntime.catch((err) => { + setUiAssetsStatus('unavailable') + console.error('[iii-ui] loader not started — engine client failed', err) +}) const favicon = document.querySelector('link[rel="icon"]') ?? @@ -80,7 +79,7 @@ createRoot(root).render( - + , diff --git a/console/web/src/types/injectable-ui.ts b/console/web/src/types/injectable-ui.ts index 210e4ede2..b04c30b82 100644 --- a/console/web/src/types/injectable-ui.ts +++ b/console/web/src/types/injectable-ui.ts @@ -244,6 +244,10 @@ export interface Host { chat: { registerSessionChip(chip: SessionChipRegistration): () => void registerTurnSummary(summary: SessionTurnSummaryRegistration): () => void + /** Jump the sidebar to this session. Feature-detect on older consoles. */ + selectConversation?(sessionId: string): void + /** Live composer model for a conversation, including unsaved drafts. */ + composerModel?(conversationId?: string | null): string | null } } diff --git a/packages/console-ui/index.d.ts b/packages/console-ui/index.d.ts index b2676b56d..ccf1922eb 100644 --- a/packages/console-ui/index.d.ts +++ b/packages/console-ui/index.d.ts @@ -246,6 +246,10 @@ export interface Host { registerTurnSummary?( summary: SessionTurnSummaryRegistration, ): () => void + /** Optional on consoles that predate worker-driven conversation switching. */ + selectConversation?(sessionId: string): void + /** Live composer model for a conversation, including unsaved drafts. */ + composerModel?(conversationId?: string | null): string | null } } From 1ab963d9166f7a0094a282c89b44c9d22bccfa0a Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Wed, 19 Aug 2026 17:03:43 +0100 Subject: [PATCH 2/2] feat(console): show chat when a page asks for a conversation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selecting was only half the job. A page that starts a turn on a session the operator cannot see leaves them on whatever chat happened to be open, or on no chat at all when the workspace holds a single page — the turn runs somewhere off screen and reads as a failure. `selectConversation` now also reports the request, and the workspace places chat in the active tab. A tab already showing chat keeps its layout and simply switches conversation. --- console/web/src/App.tsx | 7 ++++++- console/web/src/lib/conversations-context.tsx | 12 +++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/console/web/src/App.tsx b/console/web/src/App.tsx index 5e3b412bc..61b862312 100644 --- a/console/web/src/App.tsx +++ b/console/web/src/App.tsx @@ -210,7 +210,12 @@ export function App({ }) return ( - + { + workspaceRef.current.openScreen(CHAT_SCREEN) + }} + >
+ /** Called when an injected page asks for a conversation, so the host can + place the chat pane when none is open. */ + onConversationRequested?: (sessionId: string) => void } /** @@ -112,6 +115,7 @@ interface ConversationsProviderProps { export function ConversationsProvider({ children, injectableUiRuntime, + onConversationRequested, }: ConversationsProviderProps) { const harnessStatus = useHarnessStatus(backend.id === 'real') const harnessAvailable = isHarnessAvailable(harnessStatus) @@ -166,12 +170,18 @@ export function ConversationsProvider({ conversationsRef.current = api.conversations const activeIdRef = useRef(api.activeId) activeIdRef.current = api.activeId + const conversationRequestedRef = useRef(onConversationRequested) + conversationRequestedRef.current = onConversationRequested const conversationAdapterRef = useRef(null) if (!conversationAdapterRef.current) { conversationAdapterRef.current = { selectConversation(sessionId) { const id = sessionId.trim() - if (id) selectConversationRef.current(id) + if (!id) return + selectConversationRef.current(id) + // Selecting is only half of it: a page that started a turn wants the + // operator to see it, and the chat pane may not be on screen at all. + conversationRequestedRef.current?.(id) }, composerModel(conversationId) { const requested = conversationId?.trim()