Skip to content

Commit 7023116

Browse files
feat: added browser to sidebar (#54)
* feat: added browser to sidebar * fix: submit URL in browser preview on enter/refresh * fix: fix refresh when on same URL Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> --------- Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
1 parent fdfef2b commit 7023116

File tree

3 files changed

+242
-7
lines changed

3 files changed

+242
-7
lines changed

src/components/BrowserPreview.tsx

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import { RefreshCw, Smartphone, Monitor, Terminal, Globe } from 'lucide-react';
2+
import { Button } from '@/components/ui/button';
3+
import { Input } from '@/components/ui/input';
4+
import { useState, useEffect, useRef } from 'react';
5+
import { consoleProxyScript } from '@/utils/consoleProxy';
6+
import { ScrollArea } from '@/components/ui/scroll-area';
7+
import type { FC } from 'react';
8+
9+
interface ConsoleMessage {
10+
level: 'log' | 'info' | 'warn' | 'error' | 'debug';
11+
args: unknown[];
12+
timestamp: number;
13+
}
14+
15+
interface Props {
16+
defaultUrl?: string;
17+
}
18+
19+
export const BrowserPreview: FC<Props> = ({ defaultUrl = 'http://localhost:8080' }) => {
20+
const [inputValue, setInputValue] = useState(defaultUrl);
21+
const [currentUrl, setCurrentUrl] = useState(defaultUrl);
22+
const [isMobile, setIsMobile] = useState(false);
23+
const [showConsole, setShowConsole] = useState(true);
24+
const [logs, setLogs] = useState<ConsoleMessage[]>([]);
25+
const iframeRef = useRef<HTMLIFrameElement>(null);
26+
27+
// Clear logs when URL changes
28+
useEffect(() => {
29+
setLogs([]);
30+
}, [currentUrl]);
31+
32+
const handleRefresh = () => {
33+
// Force refresh by appending a dummy parameter if URL is unchanged
34+
setCurrentUrl(inputValue === currentUrl ? `${inputValue}${inputValue.includes('?') ? '&' : '?'}_refresh=${Date.now()}` : inputValue);
35+
setLogs([]); // Clear logs on refresh
36+
};
37+
38+
const toggleMode = () => {
39+
setIsMobile((prev) => !prev);
40+
};
41+
42+
const toggleConsole = () => {
43+
setShowConsole((prev) => !prev);
44+
};
45+
46+
useEffect(() => {
47+
const handleMessage = (event: MessageEvent) => {
48+
if (event.data?.type === 'console') {
49+
setLogs((prev) => [
50+
...prev,
51+
{
52+
level: event.data.level,
53+
args: event.data.args,
54+
timestamp: Date.now(),
55+
},
56+
]);
57+
}
58+
};
59+
60+
window.addEventListener('message', handleMessage);
61+
return () => window.removeEventListener('message', handleMessage);
62+
}, []);
63+
64+
// Inject console proxy script when iframe loads
65+
// NOTE: only works with same-origin URLs (we need a workaround to capture logs from cross-origin iframes)
66+
const handleIframeLoad = () => {
67+
const iframe = iframeRef.current;
68+
if (iframe?.contentWindow) {
69+
// Use Function constructor instead of eval for better type safety
70+
const script = new Function(consoleProxyScript);
71+
iframe.contentWindow.document.head.appendChild(
72+
Object.assign(iframe.contentWindow.document.createElement('script'), {
73+
textContent: `(${script.toString()})();`,
74+
})
75+
);
76+
}
77+
};
78+
79+
const clearLogs = () => {
80+
setLogs([]);
81+
};
82+
83+
return (
84+
<div className="flex h-full flex-col">
85+
<div className="flex items-center gap-2 border-b p-2">
86+
<div className="relative flex-1">
87+
<Globe className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
88+
<Input
89+
type="text"
90+
value={inputValue}
91+
onChange={(e) => setInputValue(e.target.value)}
92+
onKeyDown={(e) => {
93+
if (e.key === 'Enter') {
94+
handleRefresh();
95+
}
96+
}}
97+
className="flex-1 pl-8"
98+
/>
99+
</div>
100+
<Button variant="ghost" size="icon" onClick={handleRefresh} title="Refresh">
101+
<RefreshCw className="h-4 w-4" />
102+
</Button>
103+
<Button
104+
variant="ghost"
105+
size="icon"
106+
onClick={toggleMode}
107+
title={isMobile ? 'Switch to desktop' : 'Switch to mobile'}
108+
>
109+
{isMobile ? <Smartphone className="h-4 w-4" /> : <Monitor className="h-4 w-4" />}
110+
</Button>
111+
<Button
112+
variant="ghost"
113+
size="icon"
114+
onClick={toggleConsole}
115+
title={showConsole ? 'Hide Console' : 'Show Console'}
116+
>
117+
<Terminal className="h-4 w-4" />
118+
</Button>
119+
</div>
120+
<div className={`relative flex-1 ${showConsole ? 'h-[60%]' : 'h-full'}`}>
121+
<div className="h-full border border-foreground/10 bg-muted/30 p-1">
122+
<iframe
123+
ref={iframeRef}
124+
src={currentUrl}
125+
onLoad={handleIframeLoad}
126+
className={`h-full w-full rounded-sm bg-background shadow-md ${
127+
isMobile ? 'mx-auto w-[375px]' : 'w-full'
128+
}`}
129+
title="Browser Preview"
130+
// sandbox="allow-scripts allow-same-origin"
131+
/>
132+
</div>
133+
</div>
134+
{showConsole && (
135+
<div className="h-[40%] border-t">
136+
<div className="flex items-center justify-between border-b px-2 py-1">
137+
<span className="text-sm font-medium">Console</span>
138+
<div className="flex gap-2">
139+
<Button variant="ghost" size="sm" onClick={clearLogs}>
140+
Clear
141+
</Button>
142+
</div>
143+
</div>
144+
<ScrollArea className="h-[calc(100%-2rem)]">
145+
<div className="space-y-1 p-2">
146+
{logs.map((log, i) => (
147+
<div
148+
key={i}
149+
className={`font-mono text-sm ${
150+
log.level === 'error'
151+
? 'text-red-500'
152+
: log.level === 'warn'
153+
? 'text-yellow-500'
154+
: 'text-foreground'
155+
}`}
156+
>
157+
{log.args.map((arg, j) => (
158+
<span key={j}>
159+
{typeof arg === 'object' ? JSON.stringify(arg, null, 2) : String(arg)}{' '}
160+
</span>
161+
))}
162+
</div>
163+
))}
164+
</div>
165+
</ScrollArea>
166+
</div>
167+
)}
168+
</div>
169+
);
170+
};

src/components/RightSidebar.tsx

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { PanelRightOpen, PanelRightClose, Monitor, Settings } from 'lucide-react';
1+
import { PanelRightOpen, PanelRightClose, Monitor, Settings, Globe } from 'lucide-react';
22
import { Button } from '@/components/ui/button';
33
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
44
import { useState } from 'react';
@@ -13,6 +13,7 @@ import type { FC } from 'react';
1313
import { type Observable } from '@legendapp/state';
1414
import { use$ } from '@legendapp/state/react';
1515
import { ConversationSettings } from './ConversationSettings';
16+
import { BrowserPreview } from './BrowserPreview';
1617

1718
const VNC_URL = 'http://localhost:6080/vnc.html';
1819

@@ -24,21 +25,29 @@ export const RightSidebar: FC<Props> = ({ isOpen$, onToggle, conversationId }) =
2425
<div className="relative h-full">
2526
<div
2627
className={`border-l transition-all duration-300 ${
27-
isOpen ? (activeTab === 'computer' ? 'w-[48rem]' : 'w-[32rem]') : 'w-0'
28+
isOpen
29+
? activeTab === 'computer' || activeTab === 'browser'
30+
? 'w-[48rem]'
31+
: 'w-[32rem]'
32+
: 'w-0'
2833
} h-full overflow-hidden`}
2934
>
3035
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex h-full flex-col">
3136
<div className="flex h-12 items-center justify-between border-b px-4">
3237
<TabsList>
33-
<TabsTrigger value="settings">
34-
<Settings className="mr-2 h-4 w-4" />
35-
Settings
36-
</TabsTrigger>
3738
<TabsTrigger value="details">Details</TabsTrigger>
39+
<TabsTrigger value="browser">
40+
<Globe className="mr-2 h-4 w-4" />
41+
Browser
42+
</TabsTrigger>
3843
<TabsTrigger value="computer">
3944
<Monitor className="mr-2 h-4 w-4" />
4045
Computer
4146
</TabsTrigger>
47+
<TabsTrigger value="settings">
48+
<Settings className="mr-2 h-4 w-4" />
49+
Settings
50+
</TabsTrigger>
4251
</TabsList>
4352
<Button variant="ghost" size="icon" onClick={onToggle} className="ml-2">
4453
<PanelRightClose className="h-5 w-5" />
@@ -59,11 +68,15 @@ export const RightSidebar: FC<Props> = ({ isOpen$, onToggle, conversationId }) =
5968
<TabsContent value="computer" className="m-0 h-full p-0">
6069
<iframe
6170
src={VNC_URL}
62-
className="h-full w-full border-0"
71+
className="h-full w-full rounded-md border-0 p-1"
6372
allow="clipboard-read; clipboard-write"
6473
title="VNC Viewer"
6574
/>
6675
</TabsContent>
76+
77+
<TabsContent value="browser" className="m-0 h-full p-0">
78+
<BrowserPreview />
79+
</TabsContent>
6780
</div>
6881
</Tabs>
6982
</div>

src/utils/consoleProxy.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
// Script to be injected into the iframe to capture console logs
2+
export const consoleProxyScript = `
3+
(function() {
4+
const originalConsole = {
5+
log: console.log,
6+
info: console.info,
7+
warn: console.warn,
8+
error: console.error,
9+
debug: console.debug
10+
};
11+
12+
function proxyConsole(type) {
13+
return function(...args) {
14+
// Call original console method
15+
originalConsole[type].apply(console, args);
16+
17+
// Forward to parent
18+
try {
19+
window.parent.postMessage({
20+
type: 'console',
21+
level: type,
22+
args: args.map(arg => {
23+
try {
24+
// Handle special cases like Error objects
25+
if (arg instanceof Error) {
26+
return {
27+
message: arg.message,
28+
stack: arg.stack,
29+
type: 'Error'
30+
};
31+
}
32+
// Try to serialize the argument
33+
return JSON.parse(JSON.stringify(arg));
34+
} catch (e) {
35+
return String(arg);
36+
}
37+
})
38+
}, '*');
39+
} catch (e) {
40+
originalConsole.error('Failed to forward console message:', e);
41+
}
42+
};
43+
}
44+
45+
// Override console methods
46+
console.log = proxyConsole('log');
47+
console.info = proxyConsole('info');
48+
console.warn = proxyConsole('warn');
49+
console.error = proxyConsole('error');
50+
console.debug = proxyConsole('debug');
51+
})();
52+
`;

0 commit comments

Comments
 (0)