Skip to content

Commit 8e24056

Browse files
feat(app): add native webview browser pane and auto-naming threads
Browser uses Tauri child webview for full-fidelity web rendering per thread. Auto-naming spawns a separate claude/codex process after 3 messages to generate thread titles. Toggleable in settings.
1 parent 894291c commit 8e24056

4 files changed

Lines changed: 592 additions & 0 deletions

File tree

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Playwright browser sidecar for CodeForge.
4+
* Communicates via stdin/stdout JSON lines.
5+
*
6+
* Commands (send as JSON lines on stdin):
7+
* { "cmd": "navigate", "url": "https://..." }
8+
* { "cmd": "screenshot" } → returns { "type": "screenshot", "data": "<base64 png>" }
9+
* { "cmd": "click", "x": 100, "y": 200 }
10+
* { "cmd": "type", "text": "hello" }
11+
* { "cmd": "scroll", "deltaY": 300 }
12+
* { "cmd": "back" }
13+
* { "cmd": "forward" }
14+
* { "cmd": "reload" }
15+
* { "cmd": "extract", "x": 100, "y": 200 } → returns { "type": "extraction", "html": "...", "css": "..." }
16+
* { "cmd": "get_url" } → returns { "type": "url", "url": "..." }
17+
* { "cmd": "resize", "width": 800, "height": 600 }
18+
* { "cmd": "close" }
19+
*/
20+
21+
import { fileURLToPath } from 'url';
22+
import { dirname, join } from 'path';
23+
const __dirname = dirname(fileURLToPath(import.meta.url));
24+
const { chromium } = await import(join(__dirname, '..', 'frontend', 'node_modules', 'playwright', 'index.mjs'));
25+
import { createInterface } from 'readline';
26+
27+
const VIEWPORT = { width: 900, height: 600 };
28+
29+
let browser, context, page;
30+
31+
function send(obj) {
32+
process.stdout.write(JSON.stringify(obj) + '\n');
33+
}
34+
35+
function sendError(msg) {
36+
send({ type: 'error', message: msg });
37+
}
38+
39+
async function init() {
40+
try {
41+
browser = await chromium.launch({
42+
headless: true,
43+
args: ['--disable-blink-features=AutomationControlled'],
44+
});
45+
context = await browser.newContext({
46+
viewport: VIEWPORT,
47+
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
48+
});
49+
page = await context.newPage();
50+
await page.goto('about:blank');
51+
send({ type: 'ready' });
52+
} catch (e) {
53+
sendError(`Failed to launch browser: ${e.message}`);
54+
process.exit(1);
55+
}
56+
}
57+
58+
async function handleCommand(line) {
59+
let cmd;
60+
try {
61+
cmd = JSON.parse(line);
62+
} catch {
63+
return sendError('Invalid JSON');
64+
}
65+
66+
try {
67+
switch (cmd.cmd) {
68+
case 'navigate': {
69+
let url = cmd.url || '';
70+
if (url && !url.match(/^https?:\/\//)) url = 'https://' + url;
71+
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 }).catch(() => {});
72+
// Wait a bit for rendering
73+
await page.waitForTimeout(500);
74+
const screenshot = await page.screenshot({ type: 'png' });
75+
send({ type: 'navigated', url: page.url() });
76+
send({ type: 'screenshot', data: screenshot.toString('base64') });
77+
break;
78+
}
79+
80+
case 'screenshot': {
81+
const screenshot = await page.screenshot({ type: 'png' });
82+
send({ type: 'screenshot', data: screenshot.toString('base64') });
83+
break;
84+
}
85+
86+
case 'click': {
87+
await page.mouse.click(cmd.x || 0, cmd.y || 0);
88+
await page.waitForTimeout(300);
89+
const screenshot = await page.screenshot({ type: 'png' });
90+
send({ type: 'screenshot', data: screenshot.toString('base64') });
91+
break;
92+
}
93+
94+
case 'type': {
95+
await page.keyboard.type(cmd.text || '', { delay: 20 });
96+
await page.waitForTimeout(200);
97+
const screenshot = await page.screenshot({ type: 'png' });
98+
send({ type: 'screenshot', data: screenshot.toString('base64') });
99+
break;
100+
}
101+
102+
case 'keypress': {
103+
await page.keyboard.press(cmd.key || 'Enter');
104+
await page.waitForTimeout(300);
105+
const screenshot = await page.screenshot({ type: 'png' });
106+
send({ type: 'screenshot', data: screenshot.toString('base64') });
107+
break;
108+
}
109+
110+
case 'scroll': {
111+
await page.mouse.wheel(0, cmd.deltaY || 300);
112+
await page.waitForTimeout(200);
113+
const screenshot = await page.screenshot({ type: 'png' });
114+
send({ type: 'screenshot', data: screenshot.toString('base64') });
115+
break;
116+
}
117+
118+
case 'back':
119+
await page.goBack({ timeout: 5000 }).catch(() => {});
120+
await page.waitForTimeout(500);
121+
send({ type: 'navigated', url: page.url() });
122+
send({ type: 'screenshot', data: (await page.screenshot({ type: 'png' })).toString('base64') });
123+
break;
124+
125+
case 'forward':
126+
await page.goForward({ timeout: 5000 }).catch(() => {});
127+
await page.waitForTimeout(500);
128+
send({ type: 'navigated', url: page.url() });
129+
send({ type: 'screenshot', data: (await page.screenshot({ type: 'png' })).toString('base64') });
130+
break;
131+
132+
case 'reload':
133+
await page.reload({ timeout: 10000 }).catch(() => {});
134+
await page.waitForTimeout(500);
135+
send({ type: 'screenshot', data: (await page.screenshot({ type: 'png' })).toString('base64') });
136+
break;
137+
138+
case 'extract': {
139+
const result = await page.evaluate(({ x, y }) => {
140+
const el = document.elementFromPoint(x, y);
141+
if (!el) return { html: '', css: '{}' };
142+
143+
const html = el.outerHTML.length > 3000
144+
? el.outerHTML.substring(0, 3000) + '...'
145+
: el.outerHTML;
146+
147+
const computed = window.getComputedStyle(el);
148+
const keep = ['color','background','background-color','font-size','font-weight','font-family',
149+
'padding','margin','border','border-radius','display','flex-direction','align-items',
150+
'justify-content','gap','width','height','max-width','position','box-shadow','text-align',
151+
'line-height','letter-spacing','overflow'];
152+
const styles = {};
153+
const dummy = document.createElement(el.tagName);
154+
document.body.appendChild(dummy);
155+
const defaults = window.getComputedStyle(dummy);
156+
for (const p of keep) {
157+
const v = computed.getPropertyValue(p);
158+
const d = defaults.getPropertyValue(p);
159+
if (v && v !== d && v !== 'none' && v !== 'normal' && v !== 'auto' && v !== '0px')
160+
styles[p] = v;
161+
}
162+
dummy.remove();
163+
return { html, css: JSON.stringify(styles, null, 2) };
164+
}, { x: cmd.x || 0, y: cmd.y || 0 });
165+
166+
send({ type: 'extraction', html: result.html, css: result.css });
167+
break;
168+
}
169+
170+
case 'get_url':
171+
send({ type: 'url', url: page.url() });
172+
break;
173+
174+
case 'resize':
175+
await page.setViewportSize({
176+
width: cmd.width || VIEWPORT.width,
177+
height: cmd.height || VIEWPORT.height,
178+
});
179+
const screenshot = await page.screenshot({ type: 'png' });
180+
send({ type: 'screenshot', data: screenshot.toString('base64') });
181+
break;
182+
183+
case 'close':
184+
await browser.close();
185+
process.exit(0);
186+
187+
default:
188+
sendError(`Unknown command: ${cmd.cmd}`);
189+
}
190+
} catch (e) {
191+
sendError(`Command "${cmd.cmd}" failed: ${e.message}`);
192+
}
193+
}
194+
195+
async function main() {
196+
await init();
197+
198+
const rl = createInterface({ input: process.stdin });
199+
rl.on('line', (line) => handleCommand(line));
200+
rl.on('close', async () => {
201+
if (browser) await browser.close().catch(() => {});
202+
process.exit(0);
203+
});
204+
}
205+
206+
main();
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import { createSignal, onMount, onCleanup, createEffect } from "solid-js";
2+
import { appStore } from "../../stores/app-store";
3+
import * as ipc from "../../ipc";
4+
5+
interface BrowserPanelProps {
6+
threadId: string;
7+
}
8+
9+
export function BrowserPanel(props: BrowserPanelProps) {
10+
const { store, setStore } = appStore;
11+
const currentUrl = () => store.threadBrowserUrls[props.threadId] || "https://example.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+
}
34+
35+
// Observe viewport resizes
36+
onMount(() => {
37+
const ro = new ResizeObserver(() => updateBounds());
38+
if (viewportRef) ro.observe(viewportRef);
39+
40+
// Also update on scroll/resize of the window
41+
window.addEventListener("resize", updateBounds);
42+
43+
onCleanup(() => {
44+
ro.disconnect();
45+
window.removeEventListener("resize", updateBounds);
46+
// Hide webview when panel unmounts (thread switch)
47+
ipc.browserHide(props.threadId).catch(() => {});
48+
});
49+
});
50+
51+
// Open the native webview on first mount
52+
onMount(() => {
53+
requestAnimationFrame(() => {
54+
if (!viewportRef) return;
55+
const rect = viewportRef.getBoundingClientRect();
56+
const url = currentUrl();
57+
ipc.browserOpen(
58+
props.threadId,
59+
url,
60+
Math.round(rect.left),
61+
Math.round(rect.top),
62+
Math.round(rect.width),
63+
Math.round(rect.height),
64+
).then(() => {
65+
setStarted(true);
66+
}).catch((e) => {
67+
console.error("Failed to open browser:", e);
68+
});
69+
});
70+
});
71+
72+
// When started, keep bounds in sync
73+
createEffect(() => {
74+
if (started()) {
75+
updateBounds();
76+
}
77+
});
78+
79+
function navigate() {
80+
let url = urlInput().trim();
81+
if (!url) return;
82+
if (!url.match(/^https?:\/\//)) url = "https://" + url;
83+
setStore("threadBrowserUrls", props.threadId, url);
84+
setUrlInput(url);
85+
ipc.browserNavigate(props.threadId, url).catch((e) => {
86+
console.error("Navigate failed:", e);
87+
});
88+
}
89+
90+
function close() {
91+
setStore("threadBrowserOpen", props.threadId, false);
92+
ipc.browserHide(props.threadId).catch(() => {});
93+
}
94+
95+
return (
96+
<div class="bp">
97+
<div class="bp-bar">
98+
<input
99+
class="bp-url"
100+
type="text"
101+
value={urlInput()}
102+
onInput={(e) => setUrlInput(e.currentTarget.value)}
103+
onKeyDown={(e) => { if (e.key === "Enter") navigate(); }}
104+
placeholder="Enter URL..."
105+
/>
106+
<button class="bp-go" onClick={navigate}>Go</button>
107+
<button class="bp-nav" onClick={close} title="Close">
108+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
109+
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
110+
</svg>
111+
</button>
112+
</div>
113+
{/* This div reserves space — the native webview is positioned over it */}
114+
<div ref={viewportRef} class="bp-viewport" />
115+
</div>
116+
);
117+
}
118+
119+
if (!document.getElementById("browser-panel-styles")) {
120+
const s = document.createElement("style");
121+
s.id = "browser-panel-styles";
122+
s.textContent = `
123+
.bp {
124+
flex: 1;
125+
display: flex;
126+
flex-direction: column;
127+
background: var(--bg-card);
128+
min-height: 0;
129+
overflow: hidden;
130+
}
131+
.bp-bar {
132+
display: flex;
133+
align-items: center;
134+
gap: 4px;
135+
padding: 6px 8px;
136+
border-bottom: 1px solid var(--border);
137+
background: var(--bg-surface);
138+
flex-shrink: 0;
139+
}
140+
.bp-nav {
141+
width: 24px;
142+
height: 24px;
143+
border-radius: var(--radius-sm);
144+
display: flex;
145+
align-items: center;
146+
justify-content: center;
147+
color: var(--text-tertiary);
148+
transition: background 0.1s, color 0.1s;
149+
flex-shrink: 0;
150+
}
151+
.bp-nav:hover { background: var(--bg-accent); color: var(--text-secondary); }
152+
.bp-url {
153+
flex: 1;
154+
min-width: 0;
155+
height: 26px;
156+
padding: 0 8px;
157+
font-size: 12px;
158+
font-family: var(--font-mono);
159+
background: var(--bg-muted);
160+
border: 1px solid var(--border);
161+
border-radius: var(--radius-sm);
162+
color: var(--text);
163+
outline: none;
164+
}
165+
.bp-url:focus { border-color: var(--primary); }
166+
.bp-go {
167+
height: 26px;
168+
padding: 0 10px;
169+
font-size: 11px;
170+
font-weight: 600;
171+
background: var(--primary);
172+
color: #fff;
173+
border-radius: var(--radius-sm);
174+
flex-shrink: 0;
175+
transition: filter 0.1s;
176+
}
177+
.bp-go:hover { filter: brightness(1.15); }
178+
.bp-viewport {
179+
flex: 1;
180+
min-height: 0;
181+
}
182+
`;
183+
document.head.appendChild(s);
184+
}

0 commit comments

Comments
 (0)