@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,504 @@
1
+ import { useMemo, useState } from 'react'
2
+ import {
3
+ Check,
4
+ Download,
5
+ FileText,
6
+ Folder,
7
+ LoaderCircle,
8
+ MoreHorizontal,
9
+ Package,
10
+ PanelLeftClose,
11
+ Pencil,
12
+ Plus,
13
+ Rocket,
14
+ Search,
15
+ Settings,
16
+ Sparkles,
17
+ Trash2,
18
+ X,
19
+ } from 'lucide-react'
20
+ import { useChats } from '../store/chats'
21
+ import { hasCredentials, useSettings } from '../store/settings'
22
+ import { useRuntime, useUi } from '../store/ui'
23
+ import { useWorkspace } from '../store/workspace'
24
+ import { stopRun } from '../agent'
25
+ import { getProviderName } from '../data/providers'
26
+ import { cn, downloadText, formatRelativeTime, groupChatsByDate } from '../lib/utils'
27
+ import { LogoLockup } from './Logo'
28
+
29
+ function shortTime(timestamp: number) {
30
+ return new Date(timestamp).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
31
+ }
32
+
33
+ interface ChatMenu {
34
+ id: string
35
+ x: number
36
+ y: number
37
+ }
38
+
39
+ export function Sidebar() {
40
+ const sessions = useChats((state) => state.sessions)
41
+ const activeId = useChats((state) => state.activeId)
42
+ const setActive = useChats((state) => state.setActive)
43
+ const newChat = useChats((state) => state.newChat)
44
+ const renameSession = useChats((state) => state.renameSession)
45
+ const deleteSession = useChats((state) => state.deleteSession)
46
+ const settings = useSettings()
47
+ const workspace = useWorkspace((state) => state.info)
48
+ const addWorkspace = useWorkspace((state) => state.addWorkspace)
49
+ const sidebarCollapsed = useUi((state) => state.sidebarCollapsed)
50
+ const toggleSidebar = useUi((state) => state.toggleSidebar)
51
+ const mobileOpen = useUi((state) => state.mobileSidebarOpen)
52
+ const setMobileOpen = useUi((state) => state.setMobileSidebarOpen)
53
+ const openSettings = useUi((state) => state.openSettings)
54
+ const setWorkspaceOpen = useUi((state) => state.setWorkspaceOpen)
55
+ const setDeployOpen = useUi((state) => state.setDeployOpen)
56
+ const askConfirm = useUi((state) => state.askConfirm)
57
+ const notify = useUi((state) => state.notify)
58
+ const focusComposer = useUi((state) => state.focusComposer)
59
+ const streaming = useRuntime((state) => state.streaming)
60
+
61
+ const [query, setQuery] = useState('')
62
+ const [menu, setMenu] = useState<ChatMenu | null>(null)
63
+ const [editingId, setEditingId] = useState<string | null>(null)
64
+ const [draftTitle, setDraftTitle] = useState('')
65
+ const [switching, setSwitching] = useState('')
66
+
67
+ const filtered = useMemo(() => {
68
+ const needle = query.trim().toLowerCase()
69
+ if (!needle) return sessions
70
+ return sessions.filter((session) => session.title.toLowerCase().includes(needle))
71
+ }, [sessions, query])
72
+ const groups = useMemo(() => groupChatsByDate(filtered), [filtered])
73
+ const workspaces = useMemo(
74
+ () => [...workspace.workspaces].sort((a, b) => b.lastOpened - a.lastOpened),
75
+ [workspace.workspaces],
76
+ )
77
+ const connected = hasCredentials(settings)
78
+ const profileName =
79
+ (settings.daemon?.home ?? '').split(/[\\/]/).filter(Boolean).pop() || workspace.name || 'Local'
80
+
81
+ const startNewChat = () => {
82
+ newChat()
83
+ setMobileOpen(false)
84
+ setMenu(null)
85
+ focusComposer()
86
+ }
87
+
88
+ const openChat = (id: string) => {
89
+ setActive(id)
90
+ setMobileOpen(false)
91
+ setMenu(null)
92
+ }
93
+
94
+ const switchWorkspace = async (path: string) => {
95
+ if (path === workspace.root) {
96
+ setWorkspaceOpen(true)
97
+ setMobileOpen(false)
98
+ return
99
+ }
100
+ setSwitching(path)
101
+ try {
102
+ await addWorkspace(path)
103
+ setMobileOpen(false)
104
+ } catch (error) {
105
+ notify(error instanceof Error ? error.message : 'Could not open that folder.', 'error')
106
+ } finally {
107
+ setSwitching('')
108
+ }
109
+ }
110
+
111
+ const beginRename = (id: string, title: string) => {
112
+ setEditingId(id)
113
+ setDraftTitle(title)
114
+ setMenu(null)
115
+ }
116
+
117
+ const commitRename = () => {
118
+ if (!editingId) return
119
+ if (draftTitle.trim()) renameSession(editingId, draftTitle)
120
+ setEditingId(null)
121
+ setDraftTitle('')
122
+ }
123
+
124
+ const removeChat = async (id: string, title: string) => {
125
+ setMenu(null)
126
+ const confirmed = await askConfirm({
127
+ title: 'Delete this conversation?',
128
+ body: `“${title}” and its messages will be removed from this computer. This cannot be undone.`,
129
+ confirmLabel: 'Delete conversation',
130
+ tone: 'danger',
131
+ })
132
+ if (!confirmed) return
133
+ stopRun(id)
134
+ deleteSession(id)
135
+ }
136
+
137
+ const exportMarkdown = (id: string) => {
138
+ const session = sessions.find((item) => item.id === id)
139
+ if (!session) return
140
+ setMenu(null)
141
+ const body = session.messages
142
+ .flatMap((message) => {
143
+ if (message.role === 'user') return [`## You\n\n${message.content}`]
144
+ if (message.role === 'assistant' && message.content.trim()) return [`## OpenSol\n\n${message.content.trim()}`]
145
+ return []
146
+ })
147
+ .join('\n\n')
148
+ downloadText(
149
+ `opensol-${session.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 40) || 'conversation'}.md`,
150
+ `# ${session.title}\n\n${body}\n`,
151
+ 'text/markdown',
152
+ )
153
+ notify('Conversation exported as Markdown.', 'success')
154
+ }
155
+
156
+ const exportChat = (id: string) => {
157
+ const session = sessions.find((item) => item.id === id)
158
+ if (!session) return
159
+ setMenu(null)
160
+ downloadText(
161
+ `opensol-${session.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 40) || 'conversation'}.json`,
162
+ JSON.stringify({ app: 'OpenSol', exportedAt: new Date().toISOString(), session }, null, 2),
163
+ 'application/json',
164
+ )
165
+ notify('Conversation exported.', 'success')
166
+ }
167
+
168
+ return (
169
+ <aside
170
+ className={cn(
171
+ 'fixed inset-y-0 left-0 z-50 flex h-dvh w-[264px] shrink-0 flex-col bg-canvas shadow-float transition-transform duration-300 ease-out lg:static lg:z-auto lg:shadow-none',
172
+ mobileOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0',
173
+ sidebarCollapsed && 'lg:hidden',
174
+ )}
175
+ >
176
+ <div className="flex h-12 items-center gap-1 pl-3 pr-2">
177
+ <LogoLockup />
178
+ <button
179
+ type="button"
180
+ className="icon-button ml-auto hidden lg:inline-flex"
181
+ onClick={toggleSidebar}
182
+ title="Collapse sidebar (Ctrl B)"
183
+ aria-label="Collapse sidebar"
184
+ >
185
+ <PanelLeftClose size={16} strokeWidth={1.7} />
186
+ </button>
187
+ <button
188
+ type="button"
189
+ className="icon-button ml-auto lg:hidden"
190
+ onClick={() => setMobileOpen(false)}
191
+ aria-label="Close menu"
192
+ >
193
+ <X size={17} />
194
+ </button>
195
+ </div>
196
+
197
+ <nav className="space-y-0.5 px-2 pt-1">
198
+ <button
199
+ type="button"
200
+ onClick={startNewChat}
201
+ className="group/new mb-2 flex h-9 w-full items-center gap-2 rounded-lg border border-line bg-panel px-2.5 text-left text-[13px] font-medium text-ink shadow-pill transition-all duration-150 hover:border-accent/40 hover:shadow-composer active:scale-[0.985]"
202
+ >
203
+ <Plus size={15} strokeWidth={2} className="text-accent transition-transform duration-300 group-hover/new:rotate-90" />
204
+ New chat
205
+ </button>
206
+ <button
207
+ type="button"
208
+ onClick={() => {
209
+ setMobileOpen(false)
210
+ openSettings('skills')
211
+ }}
212
+ className="nav-item"
213
+ >
214
+ <Sparkles size={15} strokeWidth={1.7} className="shrink-0 text-faint" />
215
+ Skills
216
+ </button>
217
+ <button
218
+ type="button"
219
+ onClick={() => {
220
+ setMobileOpen(false)
221
+ openSettings('plugins')
222
+ }}
223
+ className="nav-item"
224
+ >
225
+ <Package size={15} strokeWidth={1.7} className="shrink-0 text-faint" />
226
+ Plugins
227
+ </button>
228
+ <button
229
+ type="button"
230
+ onClick={() => {
231
+ setMobileOpen(false)
232
+ setDeployOpen(true)
233
+ }}
234
+ className="nav-item"
235
+ >
236
+ <Rocket size={15} strokeWidth={1.7} className="shrink-0 text-faint" />
237
+ Deploy
238
+ </button>
239
+ <div className="relative">
240
+ <Search
241
+ size={15}
242
+ strokeWidth={1.7}
243
+ className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-faint"
244
+ />
245
+ <input
246
+ value={query}
247
+ onChange={(event) => setQuery(event.target.value)}
248
+ placeholder="Search"
249
+ className="mt-1 h-8 w-full rounded-lg border border-transparent bg-transparent pl-[34px] pr-2.5 text-[13px] text-ink outline-none transition-colors placeholder:text-muted hover:bg-raised/80 focus:border-line focus:bg-panel"
250
+ aria-label="Search conversations"
251
+ />
252
+ </div>
253
+ </nav>
254
+
255
+ <div className="thin-scroll min-h-0 flex-1 overflow-y-auto px-2 pb-2 pt-5">
256
+ <div className="mb-5">
257
+ <div className="flex items-center justify-between px-2.5 pb-1.5">
258
+ <p className="eyebrow">Workspaces</p>
259
+ <button
260
+ type="button"
261
+ onClick={() => {
262
+ setMobileOpen(false)
263
+ setWorkspaceOpen(true)
264
+ }}
265
+ className="flex h-6 w-6 items-center justify-center rounded-md text-faint transition-colors hover:bg-raised hover:text-ink"
266
+ title="Open another folder"
267
+ aria-label="Open another folder"
268
+ >
269
+ <Plus size={13} />
270
+ </button>
271
+ </div>
272
+ {workspaces.length === 0 ? (
273
+ <button
274
+ type="button"
275
+ onClick={() => {
276
+ setMobileOpen(false)
277
+ setWorkspaceOpen(true)
278
+ }}
279
+ className="flex w-full items-center gap-2 rounded-lg border border-dashed border-line px-2.5 py-2.5 text-left text-[13px] text-muted transition-colors hover:border-faint/50 hover:text-ink"
280
+ >
281
+ <Folder size={15} strokeWidth={1.6} className="text-faint" />
282
+ Open a project folder
283
+ </button>
284
+ ) : (
285
+ <div className="space-y-0.5">
286
+ {workspaces.map((entry) => {
287
+ const active = entry.path === workspace.root
288
+ return (
289
+ <button
290
+ key={entry.path}
291
+ type="button"
292
+ onClick={() => void switchWorkspace(entry.path)}
293
+ title={entry.path}
294
+ className={cn(
295
+ 'flex h-8 w-full items-center gap-2.5 rounded-lg px-2.5 text-left text-[13px] transition-all duration-150',
296
+ active ? 'bg-raised text-ink' : 'text-muted hover:bg-raised/80 hover:text-ink',
297
+ )}
298
+ >
299
+ <Folder size={15} strokeWidth={1.6} className={cn('shrink-0', active ? 'text-accent' : 'text-faint')} />
300
+ <span className="min-w-0 flex-1 truncate">{entry.name}</span>
301
+ {switching === entry.path ? (
302
+ <LoaderCircle size={11} className="shrink-0 animate-spin text-faint" />
303
+ ) : active ? (
304
+ <Check size={11} className="shrink-0 text-accent" />
305
+ ) : (
306
+ <span className="shrink-0 text-[10px] tabular-nums text-faint">
307
+ {formatRelativeTime(entry.lastOpened)}
308
+ </span>
309
+ )}
310
+ </button>
311
+ )
312
+ })}
313
+ </div>
314
+ )}
315
+ </div>
316
+
317
+ <div>
318
+ <div className="flex items-center justify-between px-2.5 pb-1.5">
319
+ <p className="eyebrow">Recents</p>
320
+ <span className="text-[11px] tabular-nums text-faint">{filtered.length}</span>
321
+ </div>
322
+ {groups.length === 0 && (
323
+ <div className="px-2 py-7 text-center">
324
+ <p className="text-[13px] font-medium text-muted">
325
+ {query ? 'No conversations match' : 'No conversations yet'}
326
+ </p>
327
+ <p className="mt-1 text-[11.5px] leading-relaxed text-faint">
328
+ {query ? 'Try a shorter search.' : 'Start with a task, a question or a file.'}
329
+ </p>
330
+ </div>
331
+ )}
332
+ {groups.map((group, gi) => (
333
+ <div key={group.label} className="mb-3">
334
+ <p className="px-2.5 pb-1 pt-1 text-[11px] font-medium text-faint/80">{group.label}</p>
335
+ <div className="space-y-0.5">
336
+ {group.items.map((session, si) => (
337
+ <div key={session.id} className="group/item relative animate-slideleft" style={{ animationDelay: `${Math.min(gi * 40 + si * 18, 320)}ms` }}>
338
+ {editingId === session.id ? (
339
+ <input
340
+ value={draftTitle}
341
+ onChange={(event) => setDraftTitle(event.target.value)}
342
+ onBlur={commitRename}
343
+ onKeyDown={(event) => {
344
+ if (event.key === 'Enter') commitRename()
345
+ if (event.key === 'Escape') {
346
+ setEditingId(null)
347
+ setDraftTitle('')
348
+ }
349
+ }}
350
+ autoFocus
351
+ className="h-8 w-full rounded-lg border border-accent/50 bg-panel px-2.5 text-[13px] text-ink outline-none"
352
+ aria-label="Rename conversation"
353
+ />
354
+ ) : (
355
+ <>
356
+ <button
357
+ type="button"
358
+ onClick={() => openChat(session.id)}
359
+ onDoubleClick={() => beginRename(session.id, session.title)}
360
+ title={`${session.title} · ${formatRelativeTime(session.updatedAt)}`}
361
+ className={cn('sidebar-item pr-9', activeId === session.id && 'sidebar-item-active')}
362
+ >
363
+ <span className="min-w-0 flex-1 truncate">{session.title}</span>
364
+ {streaming[session.id] && (
365
+ <span
366
+ className="flex shrink-0 items-center gap-1 text-[10.5px] font-medium text-accent group-hover/item:hidden"
367
+ title="Agent is running"
368
+ >
369
+ <span className="h-1.5 w-1.5 animate-breathe rounded-full bg-accent" />
370
+ running
371
+ </span>
372
+ )}
373
+ </button>
374
+ <button
375
+ type="button"
376
+ onClick={(event) => {
377
+ event.stopPropagation()
378
+ if (menu?.id === session.id) {
379
+ setMenu(null)
380
+ return
381
+ }
382
+ const rect = event.currentTarget.getBoundingClientRect()
383
+ const width = 176
384
+ const height = 138
385
+ const x = Math.max(8, Math.min(rect.right - width, window.innerWidth - width - 8))
386
+ const y =
387
+ rect.bottom + height + 8 > window.innerHeight
388
+ ? Math.max(8, rect.top - height - 4)
389
+ : rect.bottom + 4
390
+ setMenu({ id: session.id, x, y })
391
+ }}
392
+ className={cn(
393
+ 'absolute right-1 top-1/2 flex h-7 w-7 -translate-y-1/2 items-center justify-center rounded-md text-faint transition-all hover:bg-panel hover:text-ink',
394
+ menu?.id === session.id
395
+ ? 'flex bg-panel text-ink opacity-100'
396
+ : 'opacity-0 group-hover/item:opacity-100 focus:opacity-100',
397
+ )}
398
+ aria-label={`Actions for ${session.title}`}
399
+ title="Conversation actions"
400
+ >
401
+ <MoreHorizontal size={14} />
402
+ </button>
403
+ </>
404
+ )}
405
+ </div>
406
+ ))}
407
+ </div>
408
+ </div>
409
+ ))}
410
+ </div>
411
+ </div>
412
+
413
+ <div className="p-2">
414
+ <div className="flex items-center gap-2.5 rounded-lg px-1.5 py-1.5 transition-colors hover:bg-raised/80">
415
+ <div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-ink text-[12px] font-semibold text-panel">
416
+ {profileName.charAt(0).toUpperCase()}
417
+ </div>
418
+ <button
419
+ type="button"
420
+ onClick={() => {
421
+ setMobileOpen(false)
422
+ openSettings('models')
423
+ }}
424
+ className="min-w-0 flex-1 text-left"
425
+ >
426
+ <p className="truncate text-[13px] font-medium leading-tight text-ink">{profileName}</p>
427
+ <p className="mt-0.5 truncate text-[11px] leading-tight text-faint">
428
+ {connected
429
+ ? `${getProviderName(settings.provider)}${settings.provider === 'ollama' ? ' · local' : ' · your key'}`
430
+ : 'Add a provider key'}
431
+ </p>
432
+ </button>
433
+ <button
434
+ type="button"
435
+ className="icon-button h-8 w-8"
436
+ onClick={() => {
437
+ setMobileOpen(false)
438
+ openSettings('appearance')
439
+ }}
440
+ title="Preferences"
441
+ aria-label="Preferences"
442
+ >
443
+ <Settings size={16} strokeWidth={1.7} />
444
+ </button>
445
+ </div>
446
+ </div>
447
+
448
+ {menu && (
449
+ <>
450
+ <button
451
+ type="button"
452
+ tabIndex={-1}
453
+ aria-label="Close conversation menu"
454
+ className="fixed inset-0 z-[60] cursor-default bg-transparent"
455
+ onClick={() => setMenu(null)}
456
+ />
457
+ <div
458
+ className="surface-lift fixed z-[70] w-[184px] overflow-hidden rounded-xl border border-line p-1 shadow-float"
459
+ style={{ left: menu.x, top: menu.y }}
460
+ >
461
+ {(() => {
462
+ const session = sessions.find((item) => item.id === menu.id)
463
+ if (!session) return null
464
+ return (
465
+ <>
466
+ <button
467
+ type="button"
468
+ onClick={() => beginRename(session.id, session.title)}
469
+ className="flex h-7 w-full items-center gap-2 rounded-md px-2 text-left text-[11.5px] text-muted hover:bg-raised hover:text-ink"
470
+ >
471
+ <Pencil size={12} /> Rename
472
+ </button>
473
+ <button
474
+ type="button"
475
+ onClick={() => exportChat(session.id)}
476
+ className="flex h-7 w-full items-center gap-2 rounded-md px-2 text-left text-[11.5px] text-muted hover:bg-raised hover:text-ink"
477
+ >
478
+ <Download size={12} /> Export JSON
479
+ </button>
480
+ <button
481
+ type="button"
482
+ onClick={() => exportMarkdown(session.id)}
483
+ className="flex h-7 w-full items-center gap-2 rounded-md px-2 text-left text-[11.5px] text-muted hover:bg-raised hover:text-ink"
484
+ >
485
+ <FileText size={12} /> Export Markdown
486
+ </button>
487
+ <div className="my-1 border-t border-line" />
488
+ <button
489
+ type="button"
490
+ onClick={() => void removeChat(session.id, session.title)}
491
+ className="flex h-7 w-full items-center gap-2 rounded-md px-2 text-left text-[11.5px] text-danger hover:bg-danger/[0.06]"
492
+ >
493
+ <Trash2 size={12} /> Delete
494
+ </button>
495
+ <p className="px-2 pb-1 pt-1.5 text-[9px] text-faint">Updated {shortTime(session.updatedAt)}</p>
496
+ </>
497
+ )
498
+ })()}
499
+ </div>
500
+ </>
501
+ )}
502
+ </aside>
503
+ )
504
+ }
@@ -0,0 +1,104 @@
1
+ import { useEffect, useState } from 'react'
2
+ import { AlertCircle, RefreshCw } from 'lucide-react'
3
+ import { BrandFooter } from './Logo'
4
+ import { cn } from '../lib/utils'
5
+
6
+ const STATUS = ['Waking the local daemon', 'Unlocking your keys', 'Loading workspace', 'Almost there']
7
+
8
+ const RAYS_PLUS = ['M32 7v8', 'M32 49v8', 'M7 32h8', 'M49 32h8']
9
+ const RAYS_DIAG = ['M14.3 14.3l5.5 5.5', 'M44.2 44.2l5.5 5.5', 'M14.3 49.7l5.5-5.5', 'M44.2 19.8l5.5-5.5']
10
+
11
+ function SunDraw({ size = 64 }: { size?: number }) {
12
+ return (
13
+ <div className="relative" style={{ width: size * 1.7, height: size * 1.7 }}>
14
+ <span className="absolute inset-0 m-auto animate-ping rounded-full bg-accent/20" style={{ width: size * 0.7, height: size * 0.7 }} />
15
+ <span
16
+ className="absolute inset-0 m-auto rounded-full bg-accent/10 blur-2xl"
17
+ style={{ width: size * 1.4, height: size * 1.4 }}
18
+ />
19
+ <svg viewBox="0 0 64 64" width={size} height={size} fill="none" className="splash-sun absolute inset-0 m-auto text-accent">
20
+ <circle className="core" cx="32" cy="32" r="12" fill="currentColor" />
21
+ <g className="rays" stroke="currentColor" strokeWidth="4.4" strokeLinecap="round">
22
+ {RAYS_PLUS.map((d, i) => (
23
+ <path key={d} d={d} className="ray" pathLength={8} style={{ animationDelay: `${0.35 + i * 0.07}s` }} />
24
+ ))}
25
+ {RAYS_DIAG.map((d, i) => (
26
+ <path key={d} d={d} className="ray" pathLength={8} opacity={0.55} style={{ animationDelay: `${0.65 + i * 0.07}s` }} />
27
+ ))}
28
+ </g>
29
+ </svg>
30
+ </div>
31
+ )
32
+ }
33
+
34
+ export function Splash({
35
+ phase,
36
+ error,
37
+ leaving,
38
+ onRetry,
39
+ }: {
40
+ phase: 'idle' | 'loading' | 'ready' | 'error'
41
+ error: string
42
+ leaving: boolean
43
+ onRetry: () => void
44
+ }) {
45
+ const [step, setStep] = useState(0)
46
+ useEffect(() => {
47
+ if (phase === 'error') return
48
+ const timer = window.setInterval(() => setStep((value) => Math.min(STATUS.length - 1, value + 1)), 650)
49
+ return () => window.clearInterval(timer)
50
+ }, [phase])
51
+
52
+ return (
53
+ <div className={cn('splash', leaving && 'is-leaving')} role="status" aria-live="polite">
54
+ <div className="hero-wash pointer-events-none absolute inset-0" aria-hidden="true" />
55
+ <div className="relative flex w-full max-w-[400px] flex-col items-center px-6 text-center">
56
+ <SunDraw />
57
+ <h1 className="splash-word -mt-1 text-[26px] font-semibold leading-none tracking-[-0.04em] text-ink" aria-label="OpenSol">
58
+ {'OpenSol'.split('').map((char, index) => (
59
+ <span key={index} style={{ animationDelay: `${0.5 + index * 0.05}s` }} className={index >= 4 ? 'text-accent' : undefined}>
60
+ {char}
61
+ </span>
62
+ ))}
63
+ </h1>
64
+ <p className="mt-2 animate-fade text-[11px] font-medium uppercase tracking-[0.22em] text-faint [animation-delay:1s]">
65
+ by OpenSoft
66
+ </p>
67
+
68
+ {phase === 'error' ? (
69
+ <div className="mt-8 w-full animate-rise">
70
+ <div className="w-full rounded-xl border border-danger/25 bg-danger/[0.05] px-4 py-3 text-left">
71
+ <p className="flex items-center gap-1.5 text-[12.5px] font-medium text-ink">
72
+ <AlertCircle size={14} className="text-danger" />
73
+ The local daemon did not answer
74
+ </p>
75
+ <p className="mt-1.5 whitespace-pre-wrap break-words text-[11.5px] leading-relaxed text-muted">{error}</p>
76
+ </div>
77
+ <button
78
+ type="button"
79
+ onClick={onRetry}
80
+ className="mt-4 inline-flex h-9 items-center gap-1.5 rounded-lg bg-ink px-4 text-[12.5px] font-medium text-panel transition-all hover:opacity-85 active:scale-95"
81
+ >
82
+ <RefreshCw size={13} />
83
+ Try again
84
+ </button>
85
+ <p className="mt-3 text-[11px] leading-relaxed text-faint">
86
+ Start OpenSol with <code className="rounded bg-raised px-1 py-0.5 font-mono text-[10.5px]">npm start</code> in the
87
+ OpenSol folder, then reload this page.
88
+ </p>
89
+ </div>
90
+ ) : (
91
+ <div className="mt-9 flex animate-fade flex-col items-center gap-3 [animation-delay:0.8s]">
92
+ <div className="loading-bar h-[2px] w-32 rounded-full" />
93
+ <p key={step} className="animate-rise text-[12px] text-muted">
94
+ {STATUS[step]}…
95
+ </p>
96
+ </div>
97
+ )}
98
+ <div className="mt-12 animate-fade [animation-delay:1.2s]">
99
+ <BrandFooter />
100
+ </div>
101
+ </div>
102
+ </div>
103
+ )
104
+ }