@bibobonking/opensol 0.0.0-stage → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/.github/workflows/build.yml +25 -0
  2. package/README.md +74 -2
  3. package/dist/assets/index-BmN7pBji.css +1 -0
  4. package/dist/assets/index-DgNDjkOz.js +301 -0
  5. package/dist/favicon.svg +15 -0
  6. package/dist/index.html +55 -0
  7. package/dist/preview-host.html +33 -0
  8. package/index.html +54 -0
  9. package/package.json +43 -4
  10. package/postcss.config.js +6 -0
  11. package/public/favicon.svg +15 -0
  12. package/public/preview-host.html +33 -0
  13. package/shared/plugin-builder.test.mjs +43 -0
  14. package/shared/plugin-package.d.mts +34 -0
  15. package/shared/plugin-package.mjs +197 -0
  16. package/shared/plugin-package.test.mjs +94 -0
  17. package/src/App.tsx +152 -0
  18. package/src/agent/adapters/anthropic.ts +233 -0
  19. package/src/agent/adapters/gemini.ts +225 -0
  20. package/src/agent/adapters/openai-responses.ts +215 -0
  21. package/src/agent/adapters/openai.ts +200 -0
  22. package/src/agent/adapters/shared.ts +96 -0
  23. package/src/agent/approval.ts +20 -0
  24. package/src/agent/compact.ts +234 -0
  25. package/src/agent/index.ts +36 -0
  26. package/src/agent/model.ts +20 -0
  27. package/src/agent/prompt.ts +107 -0
  28. package/src/agent/queue.test.mjs +111 -0
  29. package/src/agent/queue.ts +84 -0
  30. package/src/agent/subagent.ts +109 -0
  31. package/src/agent/tools.ts +541 -0
  32. package/src/components/ChatView.tsx +403 -0
  33. package/src/components/CommandPalette.tsx +416 -0
  34. package/src/components/Composer.tsx +678 -0
  35. package/src/components/ContextBar.tsx +58 -0
  36. package/src/components/DeployModal.tsx +512 -0
  37. package/src/components/ErrorBoundary.tsx +75 -0
  38. package/src/components/HomeDashboard.tsx +189 -0
  39. package/src/components/Logo.tsx +41 -0
  40. package/src/components/Markdown.tsx +232 -0
  41. package/src/components/MessageItem.tsx +655 -0
  42. package/src/components/ModelPicker.tsx +487 -0
  43. package/src/components/PixleSol.tsx +243 -0
  44. package/src/components/PluginBuilder.tsx +285 -0
  45. package/src/components/PluginManager.tsx +341 -0
  46. package/src/components/PreviewPane.tsx +166 -0
  47. package/src/components/SettingsModal.tsx +2052 -0
  48. package/src/components/Sidebar.tsx +504 -0
  49. package/src/components/Splash.tsx +104 -0
  50. package/src/components/ToolCallCard.tsx +177 -0
  51. package/src/components/TopBar.tsx +182 -0
  52. package/src/components/TrajectoryView.tsx +178 -0
  53. package/src/components/WorkspacePanel.tsx +710 -0
  54. package/src/components/ui.tsx +311 -0
  55. package/src/core/kernel.ts +45 -0
  56. package/src/core/plugins/tools/fs.ts +219 -0
  57. package/src/core/plugins/tools/git.ts +83 -0
  58. package/src/core/plugins/tools/grep.ts +98 -0
  59. package/src/core/plugins/tools/shell.ts +88 -0
  60. package/src/core/plugins/tools/subagent.ts +36 -0
  61. package/src/core/plugins/tools/web.ts +97 -0
  62. package/src/core/services/agent-loop.ts +417 -0
  63. package/src/core/services/approval.ts +66 -0
  64. package/src/core/services/compaction.ts +45 -0
  65. package/src/core/services/llm.ts +32 -0
  66. package/src/core/services/sessions.ts +47 -0
  67. package/src/core/services/subagents.ts +15 -0
  68. package/src/core/services/system-prompt.ts +177 -0
  69. package/src/core/services/tools.ts +135 -0
  70. package/src/core/services/ui.ts +109 -0
  71. package/src/core/types.ts +139 -0
  72. package/src/data/providers.ts +125 -0
  73. package/src/data/skills.ts +68 -0
  74. package/src/index.css +776 -0
  75. package/src/lib/boot.ts +94 -0
  76. package/src/lib/context.ts +75 -0
  77. package/src/lib/conversation.test.mjs +64 -0
  78. package/src/lib/conversation.ts +59 -0
  79. package/src/lib/daemon.ts +475 -0
  80. package/src/lib/hooks.ts +33 -0
  81. package/src/lib/permissions.ts +60 -0
  82. package/src/lib/pixlesol.test.mjs +51 -0
  83. package/src/lib/pixlesol.ts +55 -0
  84. package/src/lib/plugin-folder.test.mjs +145 -0
  85. package/src/lib/plugin-folder.ts +96 -0
  86. package/src/lib/reasoning.ts +87 -0
  87. package/src/lib/sse.ts +75 -0
  88. package/src/lib/themes.test.mjs +72 -0
  89. package/src/lib/themes.ts +52 -0
  90. package/src/lib/usage.test.mjs +72 -0
  91. package/src/lib/usage.ts +155 -0
  92. package/src/lib/utils.ts +73 -0
  93. package/src/main.tsx +16 -0
  94. package/src/plugins/api.test.mjs +77 -0
  95. package/src/plugins/api.ts +113 -0
  96. package/src/plugins/bridge.d.mts +1 -0
  97. package/src/plugins/bridge.mjs +26 -0
  98. package/src/plugins/bridge.test.mjs +17 -0
  99. package/src/plugins/index.ts +52 -0
  100. package/src/plugins/preview.test.mjs +18 -0
  101. package/src/plugins/preview.ts +34 -0
  102. package/src/plugins/runtime.ts +194 -0
  103. package/src/plugins/types.ts +40 -0
  104. package/src/store/chats.ts +278 -0
  105. package/src/store/models.ts +120 -0
  106. package/src/store/settings.ts +445 -0
  107. package/src/store/ui.ts +132 -0
  108. package/src/store/workspace.ts +299 -0
  109. package/src/types.ts +337 -0
  110. package/src/vite-env.d.ts +1 -0
  111. package/tailwind.config.js +83 -0
  112. package/tsconfig.json +22 -0
  113. package/tsconfig.tsbuildinfo +1 -0
  114. package/vite.config.ts +19 -0
@@ -0,0 +1,341 @@
1
+ import { useEffect, useRef, useState } from 'react'
2
+ import { Eye, FolderOpen, Package, ShieldAlert, ShieldCheck, Trash2 } from 'lucide-react'
3
+ import type { InstalledPlugin } from '../lib/daemon'
4
+ import {
5
+ getInstalledPluginArchive,
6
+ installPluginArchive,
7
+ listInstalledPlugins,
8
+ removeInstalledPlugin,
9
+ savePluginGrants,
10
+ setPluginActivation,
11
+ setPluginTrust,
12
+ } from '../lib/daemon'
13
+ import { attachPreviewBridge, previewDocument } from '../plugins/preview'
14
+ import type { PluginCapability } from '../../shared/plugin-package.mjs'
15
+ import { readPluginArchive } from '../../shared/plugin-package.mjs'
16
+ import { useUi } from '../store/ui'
17
+ import { cn } from '../lib/utils'
18
+ import { Switch } from './ui'
19
+ import { PluginBuilder } from './PluginBuilder'
20
+
21
+ const PREVIEW_LOGS = 6
22
+
23
+ function PluginPreview({ plugin }: { plugin: InstalledPlugin }) {
24
+ const notify = useUi((state) => state.notify)
25
+ const [html, setHtml] = useState<string | null>(null)
26
+ const [log, setLog] = useState<string[]>([])
27
+ const frameRef = useRef<HTMLIFrameElement>(null)
28
+ const [loading, setLoading] = useState(false)
29
+
30
+ useEffect(() => {
31
+ if (!frameRef.current || !html) return
32
+ return attachPreviewBridge(frameRef.current, (message) => {
33
+ const entry = message as { type: string; message?: string; label?: string }
34
+ const text =
35
+ entry.type === 'ready'
36
+ ? 'Sandbox ready — no app access, no network.'
37
+ : `${entry.type}: ${entry.message ?? entry.label ?? ''}`
38
+ setLog((current) => [...current.slice(-(PREVIEW_LOGS - 1)), text])
39
+ })
40
+ }, [html])
41
+
42
+ const start = async () => {
43
+ setLoading(true)
44
+ try {
45
+ const archive = await getInstalledPluginArchive(plugin.id)
46
+ const { files, manifest } = readPluginArchive(archive)
47
+ setLog([])
48
+ setHtml(previewDocument(manifest, files[manifest.entry] ?? '', files[manifest.stylesheet] ?? ''))
49
+ } catch (error) {
50
+ notify(error instanceof Error ? error.message : 'This plugin could not be previewed.', 'error')
51
+ } finally {
52
+ setLoading(false)
53
+ }
54
+ }
55
+
56
+ return (
57
+ <>
58
+ <button
59
+ type="button"
60
+ disabled={loading}
61
+ onClick={() => (html ? setHtml(null) : void start())}
62
+ className="inline-flex h-7 items-center gap-1.5 rounded-lg border border-line px-2.5 text-[10.5px] font-medium text-muted hover:text-ink disabled:opacity-40"
63
+ >
64
+ <Eye size={11} />
65
+ {html ? 'Stop preview' : 'Preview in sandbox'}
66
+ </button>
67
+ {html && (
68
+ <div className="w-full overflow-hidden rounded-lg border border-line">
69
+ <iframe
70
+ ref={frameRef}
71
+ title={`${plugin.manifest.name} preview`}
72
+ sandbox="allow-scripts"
73
+ srcDoc={html}
74
+ className="h-52 w-full bg-panel"
75
+ />
76
+ <div className="max-h-24 overflow-y-auto border-t border-line bg-raised/40 px-2.5 py-1.5 font-mono text-[9.5px] leading-relaxed text-faint">
77
+ {log.map((entry, index) => (
78
+ <p key={index} className="truncate">
79
+ {entry}
80
+ </p>
81
+ ))}
82
+ </div>
83
+ </div>
84
+ )}
85
+ </>
86
+ )
87
+ }
88
+
89
+ const CAPABILITY_LABELS: Record<PluginCapability, string> = {
90
+ 'workspace.list': 'List files in the open workspace',
91
+ 'workspace.read': 'Read workspace files',
92
+ 'workspace.write': 'Create and overwrite workspace files',
93
+ 'workspace.edit': 'Edit workspace files in place',
94
+ 'ui.commands': 'Add commands to OpenSol',
95
+ 'ui.panels': 'Add panels to OpenSol',
96
+ 'ui.themes': 'Add themes to OpenSol',
97
+ 'network.request': 'Call the HTTPS domains it declared',
98
+ }
99
+
100
+ export function PluginManager() {
101
+ const notify = useUi((state) => state.notify)
102
+ const askConfirm = useUi((state) => state.askConfirm)
103
+ const [plugins, setPlugins] = useState<InstalledPlugin[]>([])
104
+ const [busy, setBusy] = useState(false)
105
+ const [open, setOpen] = useState<string | null>(null)
106
+ const fileRef = useRef<HTMLInputElement>(null)
107
+
108
+ const refresh = async () => {
109
+ try {
110
+ setPlugins((await listInstalledPlugins()).plugins)
111
+ } catch (error) {
112
+ notify(error instanceof Error ? error.message : 'Could not list plugins.', 'error')
113
+ }
114
+ }
115
+
116
+ useEffect(() => {
117
+ void refresh()
118
+ }, [])
119
+
120
+ const replace = (plugin: InstalledPlugin) => {
121
+ setPlugins((current) => current.map((entry) => (entry.id === plugin.id ? plugin : entry)))
122
+ }
123
+
124
+ const install = async (file: File) => {
125
+ setBusy(true)
126
+ try {
127
+ // Read it locally first: a malformed file is rejected before it reaches the daemon.
128
+ readPluginArchive(new Uint8Array(await file.arrayBuffer()))
129
+ const { plugin } = await installPluginArchive(new Uint8Array(await file.arrayBuffer()))
130
+ setPlugins((current) => [...current.filter((entry) => entry.id !== plugin.id), plugin])
131
+ setOpen(plugin.id)
132
+ notify(`${plugin.manifest.name} installed. It starts sandboxed and disabled.`, 'success')
133
+ } catch (error) {
134
+ notify(error instanceof Error ? error.message : 'That file is not a valid plugin package.', 'error')
135
+ } finally {
136
+ setBusy(false)
137
+ }
138
+ }
139
+
140
+ const grant = async (plugin: InstalledPlugin, capabilities: PluginCapability[], domains: string[]) => {
141
+ try {
142
+ replace((await savePluginGrants(plugin.id, { capabilities, domains })).plugin)
143
+ } catch (error) {
144
+ notify(error instanceof Error ? error.message : 'Could not save plugin permissions.', 'error')
145
+ }
146
+ }
147
+
148
+ const trust = async (plugin: InstalledPlugin) => {
149
+ if (plugin.safe) {
150
+ replace((await setPluginTrust(plugin.id, false)).plugin)
151
+ return
152
+ }
153
+ const ok = await askConfirm({
154
+ title: `Run ${plugin.manifest.name} outside the sandbox?`,
155
+ body: 'Safe plugins run as real JavaScript inside OpenSol. They can do anything this app can, so only mark plugins you wrote or have reviewed.',
156
+ confirmLabel: 'Mark as Safe',
157
+ tone: 'danger',
158
+ })
159
+ if (!ok) return
160
+ replace((await setPluginTrust(plugin.id, true)).plugin)
161
+ }
162
+
163
+ return (
164
+ <>
165
+ <section className="rounded-xl border border-line bg-raised/20 p-3">
166
+ <h3 className="text-[12px] font-medium text-ink">Install a local plugin</h3>
167
+ <p className="mt-1 text-[10.5px] leading-relaxed text-faint">
168
+ OpenSol installs <code className="font-mono">.plugin.json</code> packages you pick from this machine. New plugins
169
+ start sandboxed and disabled until you review them.
170
+ </p>
171
+ <div className="mt-2.5 flex flex-wrap items-center gap-2">
172
+ <button
173
+ type="button"
174
+ disabled={busy}
175
+ onClick={() => fileRef.current?.click()}
176
+ className="inline-flex h-8 items-center gap-1.5 rounded-lg bg-accent px-3 text-[10.5px] font-medium text-on-accent hover:bg-accent/90 disabled:opacity-40"
177
+ >
178
+ <FolderOpen size={12} />
179
+ Choose plugin package
180
+ </button>
181
+ <input
182
+ ref={fileRef}
183
+ type="file"
184
+ accept=".plugin.json,.pluginmax,application/json,application/zip"
185
+ className="hidden"
186
+ onChange={(event) => {
187
+ const file = event.target.files?.[0]
188
+ event.target.value = ''
189
+ if (file) void install(file)
190
+ }}
191
+ />
192
+ </div>
193
+ </section>
194
+
195
+ {plugins.length === 0 ? (
196
+ <p className="rounded-lg border border-line bg-raised/30 px-3 py-3 text-[10.5px] text-faint">
197
+ No plugins installed.
198
+ </p>
199
+ ) : (
200
+ <div className="space-y-2">
201
+ {plugins.map((plugin) => {
202
+ const expanded = open === plugin.id
203
+ return (
204
+ <div key={plugin.id} className="overflow-hidden rounded-xl border border-line">
205
+ <div className="flex items-center gap-2 px-3 py-2.5">
206
+ <Package size={14} className="shrink-0 text-faint" />
207
+ <button
208
+ type="button"
209
+ onClick={() => setOpen(expanded ? null : plugin.id)}
210
+ className="min-w-0 flex-1 text-left"
211
+ >
212
+ <span className="block truncate text-[11.5px] font-medium text-ink">{plugin.manifest.name}</span>
213
+ <span className="block truncate text-[9.5px] text-faint">
214
+ {plugin.id} · v{plugin.manifest.version} ·{' '}
215
+ {plugin.safe ? 'Safe — runs unsandboxed' : 'Sandboxed'}
216
+ </span>
217
+ </button>
218
+ <Switch
219
+ checked={plugin.enabled}
220
+ disabled={!plugin.safe}
221
+ label={`Enable ${plugin.manifest.name}`}
222
+ onChange={async (enabled) => {
223
+ replace((await setPluginActivation(plugin.id, enabled)).plugin)
224
+ }}
225
+ />
226
+ </div>
227
+
228
+ {expanded && (
229
+ <div className="space-y-2.5 border-t border-line px-3 py-3">
230
+ <div className={cn('flex items-start gap-2 rounded-lg px-2.5 py-2 text-[10px] leading-relaxed', plugin.safe ? 'bg-warning/10 text-ink' : 'bg-raised/60 text-muted')}>
231
+ {plugin.safe ? <ShieldAlert size={13} className="mt-0.5 shrink-0 text-warning" /> : <ShieldCheck size={13} className="mt-0.5 shrink-0 text-faint" />}
232
+ <span>
233
+ {plugin.safe
234
+ ? 'This plugin runs as real JavaScript in OpenSol with the permissions below. It is not sandboxed any more.'
235
+ : 'This plugin is sandboxed. Mark it Safe to run its code, or preview it first.'}
236
+ </span>
237
+ </div>
238
+
239
+ <div>
240
+ <p className="text-[10.5px] font-medium text-ink">Capabilities</p>
241
+ <div className="mt-1.5 space-y-1">
242
+ {plugin.manifest.capabilities.length === 0 && (
243
+ <p className="text-[10px] text-faint">This plugin requests no capabilities.</p>
244
+ )}
245
+ {plugin.manifest.capabilities.map((capability) => (
246
+ <label key={capability} className="flex items-center gap-2 text-[10.5px] text-muted">
247
+ <input
248
+ type="checkbox"
249
+ className="accent-[rgb(var(--accent))]"
250
+ checked={plugin.grants.capabilities.includes(capability)}
251
+ onChange={(event) =>
252
+ void grant(
253
+ plugin,
254
+ event.target.checked
255
+ ? [...plugin.grants.capabilities, capability]
256
+ : plugin.grants.capabilities.filter((entry) => entry !== capability),
257
+ plugin.grants.domains,
258
+ )
259
+ }
260
+ />
261
+ <span className="flex-1">{CAPABILITY_LABELS[capability]}</span>
262
+ <code className="font-mono text-[9.5px] text-faint">{capability}</code>
263
+ </label>
264
+ ))}
265
+ </div>
266
+ </div>
267
+
268
+ <div>
269
+ <p className="text-[10.5px] font-medium text-ink">Network domains</p>
270
+ {plugin.manifest.domains.length === 0 ? (
271
+ <p className="mt-1 text-[10px] text-faint">This plugin declares no network access.</p>
272
+ ) : (
273
+ <div className="mt-1.5 space-y-1">
274
+ {plugin.manifest.domains.map((domain) => (
275
+ <label key={domain} className="flex items-center gap-2 text-[10.5px] text-muted">
276
+ <input
277
+ type="checkbox"
278
+ className="accent-[rgb(var(--accent))]"
279
+ checked={plugin.grants.domains.includes(domain)}
280
+ onChange={(event) =>
281
+ void grant(
282
+ plugin,
283
+ plugin.grants.capabilities,
284
+ event.target.checked
285
+ ? [...plugin.grants.domains, domain]
286
+ : plugin.grants.domains.filter((entry) => entry !== domain),
287
+ )
288
+ }
289
+ />
290
+ <code className="flex-1 font-mono text-[10px]">{domain}</code>
291
+ </label>
292
+ ))}
293
+ </div>
294
+ )}
295
+ </div>
296
+
297
+ <div className="flex flex-wrap items-center gap-2 pt-0.5">
298
+ <PluginPreview plugin={plugin} />
299
+ <button
300
+ type="button"
301
+ onClick={() => void trust(plugin)}
302
+ className={cn(
303
+ 'inline-flex h-7 items-center gap-1.5 rounded-lg border px-2.5 text-[10.5px] font-medium',
304
+ plugin.safe
305
+ ? 'border-line bg-panel text-muted hover:text-ink'
306
+ : 'border-warning/40 bg-warning/10 text-ink',
307
+ )}
308
+ >
309
+ {plugin.safe ? 'Return to sandbox' : 'Mark as Safe'}
310
+ </button>
311
+ <button
312
+ type="button"
313
+ onClick={async () => {
314
+ const ok = await askConfirm({
315
+ title: `Uninstall ${plugin.manifest.name}?`,
316
+ body: 'Its permissions, styles and commands are removed from OpenSol.',
317
+ confirmLabel: 'Uninstall',
318
+ tone: 'danger',
319
+ })
320
+ if (!ok) return
321
+ await removeInstalledPlugin(plugin.id)
322
+ setPlugins((current) => current.filter((entry) => entry.id !== plugin.id))
323
+ }}
324
+ className="inline-flex h-7 items-center gap-1.5 rounded-lg border border-line px-2.5 text-[10.5px] font-medium text-muted hover:text-danger"
325
+ >
326
+ <Trash2 size={11} />
327
+ Uninstall
328
+ </button>
329
+ </div>
330
+ </div>
331
+ )}
332
+ </div>
333
+ )
334
+ })}
335
+ </div>
336
+ )}
337
+
338
+ <PluginBuilder onInstalled={(plugin) => setPlugins((current) => [...current.filter((entry) => entry.id !== plugin.id), plugin])} />
339
+ </>
340
+ )
341
+ }
@@ -0,0 +1,166 @@
1
+ import { useCallback, useEffect, useMemo, useState } from 'react'
2
+ import { ExternalLink, Monitor, RefreshCw, Smartphone, Tablet } from 'lucide-react'
3
+ import { getPreviewToken } from '../lib/daemon'
4
+ import { cn } from '../lib/utils'
5
+ import { useWorkspace } from '../store/workspace'
6
+ import { Spinner } from './ui'
7
+
8
+ const DEVICES = [
9
+ { id: 'desktop', label: 'Desktop', icon: Monitor, width: '100%' },
10
+ { id: 'tablet', label: 'Tablet', icon: Tablet, width: '768px' },
11
+ { id: 'phone', label: 'Phone', icon: Smartphone, width: '390px' },
12
+ ] as const
13
+
14
+ type DeviceId = (typeof DEVICES)[number]['id']
15
+
16
+ function pickEntry(pages: string[]): string {
17
+ if (pages.includes('index.html')) return 'index.html'
18
+ return [...pages].sort((a, b) => a.split('/').length - b.split('/').length || a.localeCompare(b))[0] ?? ''
19
+ }
20
+
21
+ /** Live, sandboxed preview of an HTML file in the open workspace. Reloads when files change. */
22
+ export function PreviewPane() {
23
+ const entries = useWorkspace((state) => state.entries)
24
+ const [token, setToken] = useState('')
25
+ const [error, setError] = useState('')
26
+ const [selected, setSelected] = useState('')
27
+ const [device, setDevice] = useState<DeviceId>('desktop')
28
+ const [nonce, setNonce] = useState(0)
29
+
30
+ const pages = useMemo(
31
+ () => entries.filter((entry) => entry.type === 'file' && /\.html?$/i.test(entry.path)).map((entry) => entry.path),
32
+ [entries],
33
+ )
34
+ // Any file change reloads the page, so edits from the agent or the editor show up on their own.
35
+ const version = useMemo(() => {
36
+ const files = entries.filter((entry) => entry.type === 'file')
37
+ return `${files.length}-${files.reduce((latest, entry) => Math.max(latest, entry.mtime), 0)}`
38
+ }, [entries])
39
+
40
+ const page = pages.includes(selected) ? selected : pickEntry(pages)
41
+
42
+ const fetchToken = useCallback(async () => {
43
+ setError('')
44
+ try {
45
+ setToken((await getPreviewToken()).token)
46
+ } catch (cause) {
47
+ setToken('')
48
+ setError(cause instanceof Error ? cause.message : 'Could not start the preview.')
49
+ }
50
+ }, [])
51
+
52
+ useEffect(() => {
53
+ void fetchToken()
54
+ }, [fetchToken])
55
+
56
+ const src = token && page ? `/preview/${token}/${encodeURI(page)}?v=${version}.${nonce}` : ''
57
+ const width = DEVICES.find((entry) => entry.id === device)?.width ?? '100%'
58
+
59
+ if (pages.length === 0) {
60
+ return (
61
+ <div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-2 px-8 text-center">
62
+ <span className="flex h-10 w-10 items-center justify-center rounded-xl bg-raised text-faint">
63
+ <Monitor size={18} />
64
+ </span>
65
+ <p className="text-[13px] font-medium text-ink">Nothing to preview yet</p>
66
+ <p className="max-w-[260px] text-[11.5px] leading-relaxed text-muted">
67
+ Ask OpenSol to build a page or a game. The first HTML file it writes shows up here and reloads as it changes.
68
+ </p>
69
+ </div>
70
+ )
71
+ }
72
+
73
+ return (
74
+ <div className="flex min-h-0 flex-1 flex-col">
75
+ <div className="flex shrink-0 items-center gap-1.5 border-b border-line px-2.5 py-2">
76
+ <select
77
+ value={page}
78
+ onChange={(event) => setSelected(event.target.value)}
79
+ className="h-8 min-w-0 flex-1 truncate rounded-lg border border-line bg-transparent px-2 font-mono text-[11.5px] text-ink outline-none focus:border-accent/50"
80
+ aria-label="Page to preview"
81
+ >
82
+ {pages.map((entry) => (
83
+ <option key={entry} value={entry}>
84
+ {entry}
85
+ </option>
86
+ ))}
87
+ </select>
88
+ <div className="flex items-center rounded-lg bg-raised/70 p-0.5">
89
+ {DEVICES.map((entry) => (
90
+ <button
91
+ key={entry.id}
92
+ type="button"
93
+ onClick={() => setDevice(entry.id)}
94
+ aria-pressed={device === entry.id}
95
+ title={entry.label}
96
+ aria-label={entry.label}
97
+ className={cn(
98
+ 'flex h-7 w-7 items-center justify-center rounded-md transition-colors',
99
+ device === entry.id ? 'bg-panel text-ink shadow-pill' : 'text-muted hover:text-ink',
100
+ )}
101
+ >
102
+ <entry.icon size={14} strokeWidth={1.7} />
103
+ </button>
104
+ ))}
105
+ </div>
106
+ <button
107
+ type="button"
108
+ className="icon-button h-8 w-8"
109
+ onClick={() => {
110
+ void fetchToken()
111
+ setNonce((value) => value + 1)
112
+ }}
113
+ title="Reload preview"
114
+ aria-label="Reload preview"
115
+ >
116
+ <RefreshCw size={14} strokeWidth={1.7} />
117
+ </button>
118
+ <button
119
+ type="button"
120
+ className="icon-button h-8 w-8"
121
+ disabled={!src}
122
+ onClick={() => window.open(src, '_blank', 'noopener')}
123
+ title="Open in a new tab"
124
+ aria-label="Open in a new tab"
125
+ >
126
+ <ExternalLink size={14} strokeWidth={1.7} />
127
+ </button>
128
+ </div>
129
+
130
+ <div className="relative min-h-0 flex-1 overflow-auto bg-raised/40 p-2">
131
+ {error && (
132
+ <div className="mx-auto mt-6 max-w-[300px] rounded-xl border border-danger/25 bg-danger/[0.05] px-3.5 py-3 text-center">
133
+ <p className="text-[12px] font-medium text-ink">The preview could not start</p>
134
+ <p className="mt-1 text-[11.5px] leading-relaxed text-muted">{error}</p>
135
+ <button
136
+ type="button"
137
+ onClick={() => void fetchToken()}
138
+ className="mt-2 text-[11.5px] font-medium text-accent hover:underline"
139
+ >
140
+ Try again
141
+ </button>
142
+ </div>
143
+ )}
144
+ {!error && !src && (
145
+ <p className="flex items-center justify-center gap-2 py-8 text-[11.5px] text-faint">
146
+ <Spinner size={12} /> Starting preview…
147
+ </p>
148
+ )}
149
+ {src && (
150
+ <iframe
151
+ key={`${page}-${nonce}`}
152
+ title={`Preview of ${page}`}
153
+ src={src}
154
+ sandbox="allow-scripts allow-forms allow-modals allow-popups allow-pointer-lock"
155
+ className="mx-auto block h-full min-h-[320px] max-w-full rounded-lg border border-line bg-white shadow-pill"
156
+ style={{ width }}
157
+ />
158
+ )}
159
+ </div>
160
+
161
+ <p className="shrink-0 border-t border-line px-3 py-1.5 text-[11px] text-faint">
162
+ Runs in a sandbox: it can load your files and public CDNs, but cannot reach OpenSol or your keys.
163
+ </p>
164
+ </div>
165
+ )
166
+ }