1- import { createSignal , onMount , onCleanup , createEffect } from "solid-js" ;
1+ import { createSignal , onMount , onCleanup , Show } from "solid-js" ;
22import { appStore } from "../../stores/app-store" ;
33import * as ipc from "../../ipc" ;
44
5- interface BrowserPanelProps {
5+ interface Props {
66 threadId : string ;
77}
88
9- export function BrowserPanel ( props : BrowserPanelProps ) {
9+ export function BrowserPanel ( props : Props ) {
1010 const { store, setStore } = appStore ;
11- const currentUrl = ( ) => store . threadBrowserUrls [ props . threadId ] || "https://google.com" ;
12- const [ urlInput , setUrlInput ] = createSignal ( currentUrl ( ) ) ;
13- const [ started , setStarted ] = createSignal ( false ) ;
14- let viewportRef : HTMLDivElement | undefined ;
15-
16- // Sync URL input when thread changes
17- createEffect ( ( ) => {
18- setUrlInput ( currentUrl ( ) ) ;
19- } ) ;
20-
21- // Position the native webview to match our viewport div
22- function updateBounds ( ) {
23- if ( ! viewportRef || ! started ( ) ) return ;
24- const rect = viewportRef . getBoundingClientRect ( ) ;
25- if ( rect . width < 10 || rect . height < 10 ) return ;
26- ipc . browserSetBounds (
27- props . threadId ,
28- Math . round ( rect . left ) ,
29- Math . round ( rect . top ) ,
30- Math . round ( rect . width ) ,
31- Math . round ( rect . height ) ,
32- ) . catch ( ( ) => { } ) ;
33- }
3411
35- // Keep bounds in sync — ResizeObserver + polling fallback
36- onMount ( ( ) => {
37- const ro = new ResizeObserver ( ( ) => updateBounds ( ) ) ;
38- if ( viewportRef ) ro . observe ( viewportRef ) ;
39-
40- window . addEventListener ( "resize" , updateBounds ) ;
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 ;
4117
42- // Poll every 200ms as fallback for layout changes (sidebar resize, pane drag, etc.)
43- const interval = setInterval ( updateBounds , 200 ) ;
18+ // Viewport dimensions for coordinate mapping
19+ const VIEWPORT_W = 1200 ;
20+ const VIEWPORT_H = 800 ;
4421
45- onCleanup ( ( ) => {
46- ro . disconnect ( ) ;
47- window . removeEventListener ( "resize" , updateBounds ) ;
48- clearInterval ( interval ) ;
49- // Hide webview when panel unmounts (thread switch)
50- ipc . browserHide ( props . threadId ) . catch ( ( ) => { } ) ;
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+ }
5135 } ) ;
52- } ) ;
36+ onCleanup ( ( ) => unlisten ( ) ) ;
5337
54- // Open the native webview on first mount — delay to let layout settle
55- onMount ( ( ) => {
56- setTimeout ( ( ) => {
57- if ( ! viewportRef ) return ;
58- const rect = viewportRef . getBoundingClientRect ( ) ;
59- const url = currentUrl ( ) ;
60- ipc . browserOpen (
61- props . threadId ,
62- url ,
63- Math . round ( rect . left ) ,
64- Math . round ( rect . top ) ,
65- Math . round ( rect . width ) ,
66- Math . round ( rect . height ) ,
67- ) . then ( ( ) => {
68- setStarted ( true ) ;
69- } ) . catch ( ( e ) => {
70- console . error ( "Failed to open browser:" , e ) ;
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+ }
7148 } ) ;
72- } , 100 ) ;
73- } ) ;
74-
75- // When started, keep bounds in sync
76- createEffect ( ( ) => {
77- if ( started ( ) ) {
78- updateBounds ( ) ;
49+ ro . observe ( viewportRef ) ;
50+ onCleanup ( ( ) => ro . disconnect ( ) ) ;
7951 }
52+
53+ navigate ( ) ;
8054 } ) ;
8155
8256 function navigate ( ) {
8357 let url = urlInput ( ) . trim ( ) ;
8458 if ( ! url ) return ;
8559 if ( ! url . match ( / ^ h t t p s ? : \/ \/ / ) ) url = "https://" + url ;
86- setStore ( "threadBrowserUrls" , props . threadId , url ) ;
8760 setUrlInput ( url ) ;
88- ipc . browserNavigate ( props . threadId , url ) . catch ( ( e ) => {
89- console . error ( "Navigate failed:" , e ) ;
90- } ) ;
61+ setStore ( "threadBrowserUrls" , props . threadId , url ) ;
62+ setLoading ( true ) ;
63+ ipc . browserNavigate ( props . threadId , url ) . catch ( ( ) => setLoading ( false ) ) ;
64+ }
65+
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 ( ( ) => { } ) ;
78+ }
79+
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 ( ( ) => { } ) ;
88+ }
9189 }
9290
9391 function close ( ) {
9492 setStore ( "threadBrowserOpen" , props . threadId , false ) ;
95- ipc . browserHide ( props . threadId ) . catch ( ( ) => { } ) ;
93+ ipc . browserClose ( props . threadId ) . catch ( ( ) => { } ) ;
9694 }
9795
9896 return (
9997 < div class = "bp" >
10098 < 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 >
101108 < input
102109 class = "bp-url"
103- type = "text"
104110 value = { urlInput ( ) }
105111 onInput = { ( e ) => setUrlInput ( e . currentTarget . value ) }
106112 onKeyDown = { ( e ) => { if ( e . key === "Enter" ) navigate ( ) ; } }
107- placeholder = "Enter URL..."
113+ placeholder = "URL..."
108114 />
109115 < button class = "bp-go" onClick = { navigate } > Go</ button >
110- < button class = "bp-nav" onClick = { close } title = "Close" >
111- < svg width = "12" height = "12" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2.5" stroke-linecap = "round" >
112- < line x1 = "18" y1 = "6" x2 = "6" y2 = "18" /> < line x1 = "6" y1 = "6" x2 = "18" y2 = "18" />
113- </ svg >
116+ < button class = "bp-btn" onClick = { close } title = "Close" >
117+ < 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 >
114118 </ button >
115119 </ div >
116- { /* This div reserves space — the native webview is positioned over it */ }
117- < div ref = { viewportRef } class = "bp-viewport" />
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 >
118143 </ div >
119144 ) ;
120145}
121146
122- if ( ! document . getElementById ( "browser-panel -styles" ) ) {
147+ if ( ! document . getElementById ( "bp -styles" ) ) {
123148 const s = document . createElement ( "style" ) ;
124- s . id = "browser-panel -styles" ;
149+ s . id = "bp -styles" ;
125150 s . textContent = `
126151 .bp {
127152 flex: 1;
@@ -134,53 +159,62 @@ if (!document.getElementById("browser-panel-styles")) {
134159 .bp-bar {
135160 display: flex;
136161 align-items: center;
137- gap: 4px ;
138- padding: 6px 8px ;
162+ gap: 3px ;
163+ padding: 5px 6px ;
139164 border-bottom: 1px solid var(--border);
140165 background: var(--bg-surface);
141166 flex-shrink: 0;
142167 }
143- .bp-nav {
144- width: 24px;
145- height: 24px;
168+ .bp-btn {
169+ width: 24px; height: 24px;
146170 border-radius: var(--radius-sm);
147- display: flex;
148- align-items: center;
149- justify-content: center;
171+ display: flex; align-items: center; justify-content: center;
150172 color: var(--text-tertiary);
151173 transition: background 0.1s, color 0.1s;
152174 flex-shrink: 0;
153175 }
154- .bp-nav :hover { background: var(--bg-accent); color: var(--text-secondary); }
176+ .bp-btn :hover { background: var(--bg-accent); color: var(--text-secondary); }
155177 .bp-url {
156- flex: 1;
157- min-width: 0;
158- height: 26px;
159- padding: 0 8px;
160- font-size: 12px;
178+ flex: 1; min-width: 0; height: 24px;
179+ padding: 0 8px; font-size: 12px;
161180 font-family: var(--font-mono);
162181 background: var(--bg-muted);
163182 border: 1px solid var(--border);
164183 border-radius: var(--radius-sm);
165- color: var(--text);
166- outline: none;
184+ color: var(--text); outline: none;
167185 }
168186 .bp-url:focus { border-color: var(--primary); }
169187 .bp-go {
170- height: 26px;
171- padding: 0 10px;
172- font-size: 11px;
173- font-weight: 600;
174- background: var(--primary);
175- color: #fff;
188+ height: 24px; padding: 0 10px;
189+ font-size: 11px; font-weight: 600;
190+ background: var(--primary); color: #fff;
176191 border-radius: var(--radius-sm);
177192 flex-shrink: 0;
178- transition: filter 0.1s;
179193 }
180194 .bp-go:hover { filter: brightness(1.15); }
181195 .bp-viewport {
182- flex: 1;
183- min-height: 0;
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;
184218 }
185219 ` ;
186220 document . head . appendChild ( s ) ;
0 commit comments