1- import { createSignal , onMount , onCleanup , Show } from "solid-js" ;
1+ import { createSignal , onMount , onCleanup } from "solid-js" ;
22import { appStore } from "../../stores/app-store" ;
3- import * as ipc from "../../ipc" ;
3+ import { Window } from "@tauri-apps/api/window" ;
4+ import { Webview } from "@tauri-apps/api/webview" ;
45
56interface Props {
67 threadId : string ;
78}
89
10+ // Track webview instances across component lifecycle
11+ const webviewInstances : Record < string , Webview > = { } ;
12+
913export function BrowserPanel ( props : Props ) {
1014 const { store, setStore } = appStore ;
15+ const [ urlInput , setUrlInput ] = createSignal (
16+ store . threadBrowserUrls [ props . threadId ] || "https://google.com"
17+ ) ;
18+ const [ ready , setReady ] = createSignal ( false ) ;
19+ let containerRef : HTMLDivElement | undefined ;
1120
12- const [ urlInput , setUrlInput ] = createSignal ( store . threadBrowserUrls [ props . threadId ] || "https://google.com" ) ;
13- const [ screenshot , setScreenshot ] = createSignal < string | null > ( null ) ;
14- const [ loading , setLoading ] = createSignal ( false ) ;
15- let imgRef : HTMLImageElement | undefined ;
16- let viewportRef : HTMLDivElement | undefined ;
17-
18- // Viewport dimensions for coordinate mapping
19- const VIEWPORT_W = 1200 ;
20- const VIEWPORT_H = 800 ;
21-
22- onMount ( async ( ) => {
23- const unlisten = await ipc . listenBrowserEvent ( ( payload ) => {
24- if ( payload . thread_id !== props . threadId ) return ;
25- const t = payload . type ;
26- if ( t === "screenshot" && payload . data ) {
27- setScreenshot ( `data:image/png;base64,${ payload . data } ` ) ;
28- setLoading ( false ) ;
29- } else if ( t === "navigated" && payload . url ) {
30- setUrlInput ( payload . url ) ;
31- setStore ( "threadBrowserUrls" , props . threadId , payload . url ) ;
32- } else if ( t === "ready" ) {
33- navigate ( ) ;
34- }
35- } ) ;
36- onCleanup ( ( ) => unlisten ( ) ) ;
37-
38- // Auto-resize the Playwright viewport to match the pane width
39- if ( viewportRef ) {
40- const ro = new ResizeObserver ( ( entries ) => {
41- for ( const e of entries ) {
42- const w = Math . round ( e . contentRect . width * 2 ) ; // 2x for retina
43- const h = Math . round ( e . contentRect . height * 2 ) ;
44- if ( w > 100 && h > 100 ) {
45- ipc . browserResize ( props . threadId , w , h ) . catch ( ( ) => { } ) ;
46- }
47- }
48- } ) ;
49- ro . observe ( viewportRef ) ;
50- onCleanup ( ( ) => ro . disconnect ( ) ) ;
21+ const label = ( ) => `browser-${ props . threadId . replace ( / [ ^ a - z A - Z 0 - 9 - ] / g, "" ) } ` ;
22+
23+ function getContainerBounds ( ) {
24+ if ( ! containerRef ) return null ;
25+ const rect = containerRef . getBoundingClientRect ( ) ;
26+ if ( rect . width < 10 || rect . height < 10 ) return null ;
27+ return { x : Math . round ( rect . left ) , y : Math . round ( rect . top ) , w : Math . round ( rect . width ) , h : Math . round ( rect . height ) } ;
28+ }
29+
30+ async function updateBounds ( ) {
31+ const wv = webviewInstances [ props . threadId ] ;
32+ if ( ! wv ) return ;
33+ const b = getContainerBounds ( ) ;
34+ if ( ! b ) return ;
35+ try {
36+ await wv . setPosition ( new ( await import ( "@tauri-apps/api/dpi" ) ) . LogicalPosition ( b . x , b . y ) ) ;
37+ await wv . setSize ( new ( await import ( "@tauri-apps/api/dpi" ) ) . LogicalSize ( b . w , b . h ) ) ;
38+ } catch { }
39+ }
40+
41+ async function createWebview ( ) {
42+ const existing = webviewInstances [ props . threadId ] ;
43+ if ( existing ) {
44+ // Already exists — just reposition and show
45+ setReady ( true ) ;
46+ await updateBounds ( ) ;
47+ return ;
5148 }
5249
53- navigate ( ) ;
54- } ) ;
50+ const b = getContainerBounds ( ) ;
51+ if ( ! b ) return ;
5552
56- function navigate ( ) {
53+ try {
54+ const appWindow = await Window . getByLabel ( "main" ) ;
55+ if ( ! appWindow ) return ;
56+
57+ const url = urlInput ( ) . trim ( ) || "https://google.com" ;
58+ const wv = new Webview ( appWindow , label ( ) , {
59+ url,
60+ x : b . x ,
61+ y : b . y ,
62+ width : b . w ,
63+ height : b . h ,
64+ } ) ;
65+
66+ // Wait for it to be created
67+ await wv . once ( "tauri://created" , ( ) => { } ) ;
68+ webviewInstances [ props . threadId ] = wv ;
69+ setReady ( true ) ;
70+ } catch ( e ) {
71+ console . error ( "Failed to create webview:" , e ) ;
72+ }
73+ }
74+
75+ async function navigate ( ) {
5776 let url = urlInput ( ) . trim ( ) ;
5877 if ( ! url ) return ;
5978 if ( ! url . match ( / ^ h t t p s ? : \/ \/ / ) ) url = "https://" + url ;
6079 setUrlInput ( url ) ;
6180 setStore ( "threadBrowserUrls" , props . threadId , url ) ;
62- setLoading ( true ) ;
63- ipc . browserNavigate ( props . threadId , url ) . catch ( ( ) => setLoading ( false ) ) ;
64- }
6581
66- function handleClick ( e : MouseEvent ) {
67- if ( ! imgRef ) return ;
68- const rect = imgRef . getBoundingClientRect ( ) ;
69- const x = ( ( e . clientX - rect . left ) / rect . width ) * VIEWPORT_W ;
70- const y = ( ( e . clientY - rect . top ) / rect . height ) * VIEWPORT_H ;
71- setLoading ( true ) ;
72- ipc . browserClick ( props . threadId , x , y ) . catch ( ( ) => setLoading ( false ) ) ;
73- }
74-
75- function handleScroll ( e : WheelEvent ) {
76- e . preventDefault ( ) ;
77- ipc . browserScroll ( props . threadId , e . deltaY * 2 ) . catch ( ( ) => { } ) ;
82+ const wv = webviewInstances [ props . threadId ] ;
83+ if ( wv ) {
84+ try {
85+ // Navigate by evaluating JS in the webview
86+ await wv . eval ( `window.location.href = ${ JSON . stringify ( url ) } ` ) ;
87+ } catch {
88+ // If eval fails, destroy and recreate
89+ await destroyWebview ( ) ;
90+ await createWebview ( ) ;
91+ }
92+ }
7893 }
7994
80- function handleKeyDown ( e : KeyboardEvent ) {
81- // Only forward when the viewport area is focused
82- if ( e . target !== viewportRef && e . target !== imgRef ) return ;
83- e . preventDefault ( ) ;
84- if ( e . key . length === 1 ) {
85- ipc . browserTypeText ( props . threadId , e . key ) . catch ( ( ) => { } ) ;
86- } else {
87- ipc . browserKeypress ( props . threadId , e . key ) . catch ( ( ) => { } ) ;
95+ async function destroyWebview ( ) {
96+ const wv = webviewInstances [ props . threadId ] ;
97+ if ( wv ) {
98+ try { await wv . close ( ) ; } catch { }
99+ delete webviewInstances [ props . threadId ] ;
88100 }
101+ setReady ( false ) ;
89102 }
90103
91104 function close ( ) {
92105 setStore ( "threadBrowserOpen" , props . threadId , false ) ;
93- ipc . browserClose ( props . threadId ) . catch ( ( ) => { } ) ;
106+ // Move offscreen instead of destroying (so it persists when switching tabs)
107+ const wv = webviewInstances [ props . threadId ] ;
108+ if ( wv ) {
109+ import ( "@tauri-apps/api/dpi" ) . then ( ( { LogicalPosition } ) => {
110+ wv . setPosition ( new LogicalPosition ( - 9999 , - 9999 ) ) . catch ( ( ) => { } ) ;
111+ } ) ;
112+ }
94113 }
95114
115+ onMount ( ( ) => {
116+ // Wait for layout to settle then create the webview
117+ setTimeout ( ( ) => createWebview ( ) , 150 ) ;
118+
119+ // Track bounds changes
120+ const ro = new ResizeObserver ( ( ) => updateBounds ( ) ) ;
121+ if ( containerRef ) ro . observe ( containerRef ) ;
122+ window . addEventListener ( "resize" , updateBounds ) ;
123+ const interval = setInterval ( updateBounds , 300 ) ;
124+
125+ onCleanup ( ( ) => {
126+ ro . disconnect ( ) ;
127+ window . removeEventListener ( "resize" , updateBounds ) ;
128+ clearInterval ( interval ) ;
129+ // Move offscreen when unmounting (tab switch)
130+ const wv = webviewInstances [ props . threadId ] ;
131+ if ( wv ) {
132+ import ( "@tauri-apps/api/dpi" ) . then ( ( { LogicalPosition } ) => {
133+ wv . setPosition ( new LogicalPosition ( - 9999 , - 9999 ) ) . catch ( ( ) => { } ) ;
134+ } ) ;
135+ }
136+ } ) ;
137+ } ) ;
138+
96139 return (
97140 < div class = "bp" >
98141 < div class = "bp-bar" >
99- < button class = "bp-btn" onClick = { ( ) => ipc . browserBack ( props . threadId ) } title = "Back" >
100- < svg width = "12" height = "12" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2.5" stroke-linecap = "round" > < polyline points = "15 18 9 12 15 6" /> </ svg >
101- </ button >
102- < button class = "bp-btn" onClick = { ( ) => ipc . browserForward ( props . threadId ) } title = "Forward" >
103- < svg width = "12" height = "12" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2.5" stroke-linecap = "round" > < polyline points = "9 18 15 12 9 6" /> </ svg >
104- </ button >
105- < button class = "bp-btn" onClick = { ( ) => { setLoading ( true ) ; ipc . browserReload ( props . threadId ) ; } } title = "Reload" >
106- < svg width = "12" height = "12" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" > < polyline points = "23 4 23 10 17 10" /> < path d = "M20.49 15a9 9 0 11-2.12-9.36L23 10" /> </ svg >
107- </ button >
108142 < input
109143 class = "bp-url"
110144 value = { urlInput ( ) }
@@ -117,29 +151,7 @@ export function BrowserPanel(props: Props) {
117151 < svg width = "12" height = "12" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2.5" stroke-linecap = "round" > < line x1 = "18" y1 = "6" x2 = "6" y2 = "18" /> < line x1 = "6" y1 = "6" x2 = "18" y2 = "18" /> </ svg >
118152 </ button >
119153 </ div >
120- < div
121- ref = { viewportRef }
122- class = "bp-viewport"
123- tabIndex = { 0 }
124- onKeyDown = { handleKeyDown }
125- >
126- < Show when = { screenshot ( ) } >
127- < img
128- ref = { imgRef }
129- class = "bp-img"
130- src = { screenshot ( ) ! }
131- onClick = { handleClick }
132- onWheel = { handleScroll }
133- draggable = { false }
134- />
135- </ Show >
136- < Show when = { loading ( ) && ! screenshot ( ) } >
137- < div class = "bp-status" > Loading...</ div >
138- </ Show >
139- < Show when = { ! loading ( ) && ! screenshot ( ) } >
140- < div class = "bp-status" > Enter a URL and press Go</ div >
141- </ Show >
142- </ div >
154+ < div ref = { containerRef } class = "bp-container" />
143155 </ div >
144156 ) ;
145157}
@@ -152,7 +164,6 @@ if (!document.getElementById("bp-styles")) {
152164 flex: 1;
153165 display: flex;
154166 flex-direction: column;
155- background: var(--bg-card);
156167 min-height: 0;
157168 overflow: hidden;
158169 }
@@ -164,6 +175,7 @@ if (!document.getElementById("bp-styles")) {
164175 border-bottom: 1px solid var(--border);
165176 background: var(--bg-surface);
166177 flex-shrink: 0;
178+ z-index: 10;
167179 }
168180 .bp-btn {
169181 width: 24px; height: 24px;
@@ -192,29 +204,9 @@ if (!document.getElementById("bp-styles")) {
192204 flex-shrink: 0;
193205 }
194206 .bp-go:hover { filter: brightness(1.15); }
195- .bp-viewport {
196- flex: 1; min-height: 0;
197- overflow: hidden;
198- background: #0a0a0a;
199- display: flex;
200- align-items: flex-start;
201- justify-content: center;
202- outline: none;
203- }
204- .bp-img {
205- width: 100%;
206- height: 100%;
207- object-fit: contain;
208- object-position: top left;
209- cursor: pointer;
210- image-rendering: auto;
211- }
212- .bp-status {
213- color: var(--text-tertiary);
214- font-size: 13px;
215- padding: 32px;
216- text-align: center;
217- align-self: center;
207+ .bp-container {
208+ flex: 1;
209+ min-height: 0;
218210 }
219211 ` ;
220212 document . head . appendChild ( s ) ;
0 commit comments