@@ -157,8 +157,143 @@ export function nextActiveTabId(
157157 return ( remaining [ closedIndex ] ?? remaining [ remaining . length - 1 ] ) ! . id ;
158158}
159159
160+ /**
161+ * The live `<webview>` elements, by thread and tab.
162+ *
163+ * Deliberately not in the store's state: these are DOM nodes, they change on
164+ * every tab mount, and nothing renders from them -- the automation host is the
165+ * only caller, and it wants the current element rather than a re-render. Kept
166+ * here rather than in the panel because the host now outlives the panel.
167+ */
168+ export interface PreviewWebviewHandle {
169+ getWebContentsId : ( ) => number ;
170+ loadURL : ( url : string ) => Promise < void > ;
171+ getBoundingClientRect : ( ) => DOMRect ;
172+ }
173+
174+ const webviewRegistry = new Map < string , PreviewWebviewHandle > ( ) ;
175+ const webviewListeners = new Set < ( ) => void > ( ) ;
176+
177+ function webviewKey ( threadRef : ScopedThreadRef , tabId : string ) : string {
178+ return `${ scopedThreadKey ( threadRef ) } ::${ tabId } ` ;
179+ }
180+
181+ export function registerPreviewWebview (
182+ threadRef : ScopedThreadRef ,
183+ tabId : string ,
184+ element : PreviewWebviewHandle | null ,
185+ ) : void {
186+ const key = webviewKey ( threadRef , tabId ) ;
187+ if ( element === null ) {
188+ webviewRegistry . delete ( key ) ;
189+ } else {
190+ webviewRegistry . set ( key , element ) ;
191+ }
192+ for ( const listener of webviewListeners ) {
193+ listener ( ) ;
194+ }
195+ }
196+
197+ export function getPreviewWebview (
198+ threadRef : ScopedThreadRef ,
199+ tabId : string ,
200+ ) : PreviewWebviewHandle | null {
201+ return webviewRegistry . get ( webviewKey ( threadRef , tabId ) ) ?? null ;
202+ }
203+
204+ export function subscribePreviewWebviews ( listener : ( ) => void ) : ( ) => void {
205+ webviewListeners . add ( listener ) ;
206+ return ( ) => {
207+ webviewListeners . delete ( listener ) ;
208+ } ;
209+ }
210+
211+ /** Test seam: the registry outlives any one component, so tests clear it. */
212+ export function resetPreviewWebviewsForTests ( ) : void {
213+ webviewRegistry . clear ( ) ;
214+ webviewListeners . clear ( ) ;
215+ }
216+
217+ /**
218+ * Waits for a page to exist, for as long as it is worth waiting.
219+ *
220+ * Opening the panel is not instant: the element mounts, attaches, and only then
221+ * has a webContents to drive. The agent's call arrives before all of that, so
222+ * it waits here rather than being told there is no browser -- but it waits
223+ * against a cap well under the broker's own timeout, so a panel that never
224+ * comes up answers the agent instead of hanging it.
225+ */
226+ export async function waitForPreviewWebview < T > ( input : {
227+ readonly resolve : ( ) => T | null ;
228+ readonly subscribe : ( listener : ( ) => void ) => ( ) => void ;
229+ readonly timeoutMs : number ;
230+ readonly setTimeoutFn ?: typeof globalThis . setTimeout ;
231+ readonly clearTimeoutFn ?: typeof globalThis . clearTimeout ;
232+ } ) : Promise < T | null > {
233+ const immediate = input . resolve ( ) ;
234+ if ( immediate !== null ) {
235+ return immediate ;
236+ }
237+ const setTimeoutFn = input . setTimeoutFn ?? globalThis . setTimeout ;
238+ const clearTimeoutFn = input . clearTimeoutFn ?? globalThis . clearTimeout ;
239+
240+ return await new Promise < T | null > ( ( resolvePromise ) => {
241+ let settled = false ;
242+ const finish = ( value : T | null ) => {
243+ if ( settled ) return ;
244+ settled = true ;
245+ clearTimeoutFn ( timer ) ;
246+ unsubscribe ( ) ;
247+ resolvePromise ( value ) ;
248+ } ;
249+ const unsubscribe = input . subscribe ( ( ) => {
250+ const candidate = input . resolve ( ) ;
251+ if ( candidate !== null ) {
252+ finish ( candidate ) ;
253+ }
254+ } ) ;
255+ const timer = setTimeoutFn ( ( ) => finish ( null ) , input . timeoutMs ) ;
256+ // Racing the subscription: registration may have landed between the first
257+ // check and the listener going on.
258+ const late = input . resolve ( ) ;
259+ if ( late !== null ) {
260+ finish ( late ) ;
261+ }
262+ } ) ;
263+ }
264+
265+ /** How long an agent's call waits for the panel it just opened to have a page. */
266+ export const PREVIEW_WEBVIEW_WAIT_MS = 5_000 ;
267+
268+ /**
269+ * What the agent is doing in a thread's browser.
270+ *
271+ * In the store rather than in the panel because the host that produces it now
272+ * outlives the panel that draws it: an agent can act on a page while the panel
273+ * is closed, and when it opens the panel has to already know where the pointer
274+ * went and which tab is the agent's.
275+ */
276+ export interface ThreadBrowserAgentState {
277+ /** The tab the agent pinned itself to; null until it acts. */
278+ tabId : string | null ;
279+ point : { x : number ; y : number ; from ?: { x : number ; y : number } ; sequence : number } | null ;
280+ activity : {
281+ phase : "running" | "done" ;
282+ verb : string ;
283+ detail : string | null ;
284+ sequence : number ;
285+ } | null ;
286+ }
287+
288+ export const EMPTY_AGENT_STATE : ThreadBrowserAgentState = Object . freeze ( {
289+ tabId : null ,
290+ point : null ,
291+ activity : null ,
292+ } ) ;
293+
160294interface BrowserPanelStoreState {
161295 browserStateByThreadKey : Record < string , ThreadBrowserState > ;
296+ agentStateByThreadKey : Record < string , ThreadBrowserAgentState > ;
162297 splitChatFraction : number ;
163298 /** Hides the chat so the page gets the whole centre; the split is remembered. */
164299 expanded : boolean ;
@@ -188,6 +323,15 @@ interface BrowserPanelStoreState {
188323 setTabZoom : ( threadRef : ScopedThreadRef , tabId : string , zoomFactor : number ) => void ;
189324 toggleDeviceToolbar : ( ) => void ;
190325 setAppearance : ( appearance : BrowserAppearance ) => void ;
326+ setAgentTab : ( threadRef : ScopedThreadRef , tabId : string | null ) => void ;
327+ setAgentPoint : (
328+ threadRef : ScopedThreadRef ,
329+ point : { x : number ; y : number ; from ?: { x : number ; y : number } } | null ,
330+ ) => void ;
331+ setAgentActivity : (
332+ threadRef : ScopedThreadRef ,
333+ activity : ThreadBrowserAgentState [ "activity" ] ,
334+ ) => void ;
191335 setSplitChatFraction : ( fraction : number ) => void ;
192336 toggleExpanded : ( ) => void ;
193337}
@@ -204,6 +348,16 @@ function updateThread(
204348 } ;
205349}
206350
351+ function updateAgentState (
352+ state : BrowserPanelStoreState ,
353+ threadRef : ScopedThreadRef ,
354+ update : ( current : ThreadBrowserAgentState ) => ThreadBrowserAgentState ,
355+ ) : Pick < BrowserPanelStoreState , "agentStateByThreadKey" > {
356+ const key = scopedThreadKey ( threadRef ) ;
357+ const current = state . agentStateByThreadKey [ key ] ?? EMPTY_AGENT_STATE ;
358+ return { agentStateByThreadKey : { ...state . agentStateByThreadKey , [ key ] : update ( current ) } } ;
359+ }
360+
207361function updateTab (
208362 current : ThreadBrowserState ,
209363 tabId : string ,
@@ -219,6 +373,7 @@ export const useBrowserPanelStore = create<BrowserPanelStoreState>()(
219373 persist (
220374 ( set ) => ( {
221375 browserStateByThreadKey : { } ,
376+ agentStateByThreadKey : { } ,
222377 splitChatFraction : DEFAULT_BROWSER_SPLIT_CHAT_FRACTION ,
223378 expanded : false ,
224379 deviceToolbarOpen : false ,
@@ -281,6 +436,18 @@ export const useBrowserPanelStore = create<BrowserPanelStoreState>()(
281436 ) ,
282437 toggleDeviceToolbar : ( ) => set ( ( state ) => ( { deviceToolbarOpen : ! state . deviceToolbarOpen } ) ) ,
283438 setAppearance : ( appearance ) => set ( ( ) => ( { appearance } ) ) ,
439+ setAgentTab : ( threadRef , tabId ) =>
440+ set ( ( state ) => updateAgentState ( state , threadRef , ( current ) => ( { ...current , tabId } ) ) ) ,
441+ setAgentPoint : ( threadRef , point ) =>
442+ set ( ( state ) =>
443+ updateAgentState ( state , threadRef , ( current ) => ( {
444+ ...current ,
445+ point :
446+ point === null ? null : { ...point , sequence : ( current . point ?. sequence ?? 0 ) + 1 } ,
447+ } ) ) ,
448+ ) ,
449+ setAgentActivity : ( threadRef , activity ) =>
450+ set ( ( state ) => updateAgentState ( state , threadRef , ( current ) => ( { ...current , activity } ) ) ) ,
284451 setSplitChatFraction : ( fraction ) =>
285452 set ( ( ) => ( { splitChatFraction : clampBrowserSplitFraction ( fraction ) } ) ) ,
286453 toggleExpanded : ( ) => set ( ( state ) => ( { expanded : ! state . expanded } ) ) ,
@@ -316,6 +483,16 @@ export function selectThreadBrowserState(
316483 return stored === undefined || stored . tabs . length === 0 ? DEFAULT_THREAD_STATE : stored ;
317484}
318485
486+ export function selectThreadAgentState (
487+ agentStateByThreadKey : Record < string , ThreadBrowserAgentState > ,
488+ threadRef : ScopedThreadRef | null ,
489+ ) : ThreadBrowserAgentState {
490+ if ( threadRef === null ) {
491+ return EMPTY_AGENT_STATE ;
492+ }
493+ return agentStateByThreadKey [ scopedThreadKey ( threadRef ) ] ?? EMPTY_AGENT_STATE ;
494+ }
495+
319496export function selectActiveTab ( state : ThreadBrowserState ) : BrowserTab | null {
320497 return state . tabs . find ( ( tab ) => tab . id === state . activeTabId ) ?? state . tabs [ 0 ] ?? null ;
321498}
0 commit comments