@bibobonking/opensol 0.0.0-stage → 3.0.2

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 (127) 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/server/README.md +75 -0
  14. package/server/daemon.mjs +1082 -0
  15. package/server/lib/checkpoints.mjs +124 -0
  16. package/server/lib/checkpoints.test.mjs +110 -0
  17. package/server/lib/deploy.mjs +436 -0
  18. package/server/lib/http.mjs +237 -0
  19. package/server/lib/plugins.mjs +321 -0
  20. package/server/lib/plugins.test.mjs +140 -0
  21. package/server/lib/providers.mjs +594 -0
  22. package/server/lib/store.mjs +491 -0
  23. package/server/lib/tools.mjs +990 -0
  24. package/server/opensol.mjs +202 -0
  25. package/server/package.json +42 -0
  26. package/shared/plugin-builder.test.mjs +43 -0
  27. package/shared/plugin-package.d.mts +34 -0
  28. package/shared/plugin-package.mjs +197 -0
  29. package/shared/plugin-package.test.mjs +94 -0
  30. package/src/App.tsx +152 -0
  31. package/src/agent/adapters/anthropic.ts +233 -0
  32. package/src/agent/adapters/gemini.ts +225 -0
  33. package/src/agent/adapters/openai-responses.ts +215 -0
  34. package/src/agent/adapters/openai.ts +200 -0
  35. package/src/agent/adapters/shared.ts +96 -0
  36. package/src/agent/approval.ts +20 -0
  37. package/src/agent/compact.ts +234 -0
  38. package/src/agent/index.ts +36 -0
  39. package/src/agent/model.ts +20 -0
  40. package/src/agent/prompt.ts +107 -0
  41. package/src/agent/queue.test.mjs +111 -0
  42. package/src/agent/queue.ts +84 -0
  43. package/src/agent/subagent.ts +109 -0
  44. package/src/agent/tools.ts +541 -0
  45. package/src/components/ChatView.tsx +403 -0
  46. package/src/components/CommandPalette.tsx +416 -0
  47. package/src/components/Composer.tsx +678 -0
  48. package/src/components/ContextBar.tsx +58 -0
  49. package/src/components/DeployModal.tsx +512 -0
  50. package/src/components/ErrorBoundary.tsx +75 -0
  51. package/src/components/HomeDashboard.tsx +189 -0
  52. package/src/components/Logo.tsx +41 -0
  53. package/src/components/Markdown.tsx +232 -0
  54. package/src/components/MessageItem.tsx +655 -0
  55. package/src/components/ModelPicker.tsx +487 -0
  56. package/src/components/PixleSol.tsx +243 -0
  57. package/src/components/PluginBuilder.tsx +285 -0
  58. package/src/components/PluginManager.tsx +341 -0
  59. package/src/components/PreviewPane.tsx +166 -0
  60. package/src/components/SettingsModal.tsx +2052 -0
  61. package/src/components/Sidebar.tsx +504 -0
  62. package/src/components/Splash.tsx +104 -0
  63. package/src/components/ToolCallCard.tsx +177 -0
  64. package/src/components/TopBar.tsx +182 -0
  65. package/src/components/TrajectoryView.tsx +178 -0
  66. package/src/components/WorkspacePanel.tsx +710 -0
  67. package/src/components/ui.tsx +311 -0
  68. package/src/core/kernel.ts +45 -0
  69. package/src/core/plugins/tools/fs.ts +219 -0
  70. package/src/core/plugins/tools/git.ts +83 -0
  71. package/src/core/plugins/tools/grep.ts +98 -0
  72. package/src/core/plugins/tools/shell.ts +88 -0
  73. package/src/core/plugins/tools/subagent.ts +36 -0
  74. package/src/core/plugins/tools/web.ts +97 -0
  75. package/src/core/services/agent-loop.ts +417 -0
  76. package/src/core/services/approval.ts +66 -0
  77. package/src/core/services/compaction.ts +45 -0
  78. package/src/core/services/llm.ts +32 -0
  79. package/src/core/services/sessions.ts +47 -0
  80. package/src/core/services/subagents.ts +15 -0
  81. package/src/core/services/system-prompt.ts +177 -0
  82. package/src/core/services/tools.ts +135 -0
  83. package/src/core/services/ui.ts +109 -0
  84. package/src/core/types.ts +139 -0
  85. package/src/data/providers.ts +125 -0
  86. package/src/data/skills.ts +68 -0
  87. package/src/index.css +776 -0
  88. package/src/lib/boot.ts +94 -0
  89. package/src/lib/context.ts +75 -0
  90. package/src/lib/conversation.test.mjs +64 -0
  91. package/src/lib/conversation.ts +59 -0
  92. package/src/lib/daemon.ts +475 -0
  93. package/src/lib/hooks.ts +33 -0
  94. package/src/lib/permissions.ts +60 -0
  95. package/src/lib/pixlesol.test.mjs +51 -0
  96. package/src/lib/pixlesol.ts +55 -0
  97. package/src/lib/plugin-folder.test.mjs +145 -0
  98. package/src/lib/plugin-folder.ts +96 -0
  99. package/src/lib/reasoning.ts +87 -0
  100. package/src/lib/sse.ts +75 -0
  101. package/src/lib/themes.test.mjs +72 -0
  102. package/src/lib/themes.ts +52 -0
  103. package/src/lib/usage.test.mjs +72 -0
  104. package/src/lib/usage.ts +155 -0
  105. package/src/lib/utils.ts +73 -0
  106. package/src/main.tsx +16 -0
  107. package/src/plugins/api.test.mjs +77 -0
  108. package/src/plugins/api.ts +113 -0
  109. package/src/plugins/bridge.d.mts +1 -0
  110. package/src/plugins/bridge.mjs +26 -0
  111. package/src/plugins/bridge.test.mjs +17 -0
  112. package/src/plugins/index.ts +52 -0
  113. package/src/plugins/preview.test.mjs +18 -0
  114. package/src/plugins/preview.ts +34 -0
  115. package/src/plugins/runtime.ts +194 -0
  116. package/src/plugins/types.ts +40 -0
  117. package/src/store/chats.ts +278 -0
  118. package/src/store/models.ts +120 -0
  119. package/src/store/settings.ts +445 -0
  120. package/src/store/ui.ts +132 -0
  121. package/src/store/workspace.ts +299 -0
  122. package/src/types.ts +337 -0
  123. package/src/vite-env.d.ts +1 -0
  124. package/tailwind.config.js +83 -0
  125. package/tsconfig.json +22 -0
  126. package/tsconfig.tsbuildinfo +1 -0
  127. package/vite.config.ts +19 -0
@@ -0,0 +1,710 @@
1
+ import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
2
+ import {
3
+ ArrowLeft,
4
+ Check,
5
+ ChevronRight,
6
+ Copy,
7
+ FilePlus2,
8
+ FileText,
9
+ Folder,
10
+ FolderOpen,
11
+ FolderPlus,
12
+ GitBranch,
13
+ HardDrive,
14
+ LoaderCircle,
15
+ Pencil,
16
+ Plus,
17
+ RefreshCw,
18
+ Save,
19
+ Search,
20
+ Trash2,
21
+ X,
22
+ } from 'lucide-react'
23
+ import type { WorkspaceEntry } from '../types'
24
+ import { browseFolders } from '../lib/daemon'
25
+ import { cn, formatBytes } from '../lib/utils'
26
+ import { useUi } from '../store/ui'
27
+ import { useWorkspace } from '../store/workspace'
28
+ import { PreviewPane } from './PreviewPane'
29
+ import { Spinner } from './ui'
30
+
31
+ interface TreeNode {
32
+ entry: WorkspaceEntry
33
+ children: TreeNode[]
34
+ }
35
+
36
+ function buildTree(entries: WorkspaceEntry[]): TreeNode[] {
37
+ const nodes = new Map<string, TreeNode>()
38
+ for (const entry of entries) nodes.set(entry.path, { entry, children: [] })
39
+ const roots: TreeNode[] = []
40
+ const sorted = [...entries].sort((a, b) => a.path.split('/').length - b.path.split('/').length)
41
+ for (const entry of sorted) {
42
+ const node = nodes.get(entry.path)
43
+ if (!node) continue
44
+ const slash = entry.path.lastIndexOf('/')
45
+ const parent = slash === -1 ? null : nodes.get(entry.path.slice(0, slash))
46
+ if (parent) parent.children.push(node)
47
+ else roots.push(node)
48
+ }
49
+ const sort = (items: TreeNode[]) => {
50
+ items.sort((a, b) =>
51
+ a.entry.type === b.entry.type
52
+ ? a.entry.name.localeCompare(b.entry.name)
53
+ : a.entry.type === 'dir'
54
+ ? -1
55
+ : 1,
56
+ )
57
+ items.forEach((item) => sort(item.children))
58
+ }
59
+ sort(roots)
60
+ return roots
61
+ }
62
+
63
+ function extension(path: string) {
64
+ const match = /\.([a-z0-9]+)$/i.exec(path)
65
+ return match ? match[1].toUpperCase() : 'TEXT'
66
+ }
67
+
68
+ function FolderPicker({ onDone }: { onDone?: () => void }) {
69
+ const addWorkspace = useWorkspace((state) => state.addWorkspace)
70
+ const workspaces = useWorkspace((state) => state.info.workspaces)
71
+ const notify = useUi((state) => state.notify)
72
+ const [path, setPath] = useState('')
73
+ const [parent, setParent] = useState('')
74
+ const [home, setHome] = useState('')
75
+ const [entries, setEntries] = useState<Array<{ name: string; path: string }>>([])
76
+ const [loading, setLoading] = useState(true)
77
+ const [error, setError] = useState('')
78
+ const [opening, setOpening] = useState(false)
79
+
80
+ const load = async (target: string) => {
81
+ setLoading(true)
82
+ setError('')
83
+ try {
84
+ const result = await browseFolders(target)
85
+ setPath(result.path)
86
+ setParent(result.parent)
87
+ setHome(result.home)
88
+ setEntries(result.entries)
89
+ } catch (cause) {
90
+ setError(cause instanceof Error ? cause.message : 'Could not read that folder.')
91
+ } finally {
92
+ setLoading(false)
93
+ }
94
+ }
95
+
96
+ useEffect(() => {
97
+ void load('')
98
+ }, [])
99
+
100
+ const open = async (target: string) => {
101
+ setOpening(true)
102
+ try {
103
+ await addWorkspace(target)
104
+ onDone?.()
105
+ } catch (cause) {
106
+ notify(cause instanceof Error ? cause.message : 'Could not open that folder.', 'error')
107
+ } finally {
108
+ setOpening(false)
109
+ }
110
+ }
111
+
112
+ return (
113
+ <div className="flex min-h-0 flex-1 flex-col">
114
+ <div className="border-b border-line px-4 py-4">
115
+ <p className="font-display text-[19px] font-semibold tracking-[-0.02em] text-ink">Choose a real folder</p>
116
+ <p className="mt-1 text-[11.5px] leading-relaxed text-muted">
117
+ OpenSol works directly in this folder through the local daemon. Nothing is uploaded.
118
+ </p>
119
+ </div>
120
+
121
+ <div className="flex items-center gap-1.5 border-b border-line px-3 py-2">
122
+ <button
123
+ type="button"
124
+ className="icon-button h-7 w-7"
125
+ onClick={() => void load(parent)}
126
+ disabled={!parent || loading}
127
+ aria-label="Parent folder"
128
+ >
129
+ <ArrowLeft size={13} />
130
+ </button>
131
+ <button
132
+ type="button"
133
+ className="icon-button h-7 w-7"
134
+ onClick={() => void load(home)}
135
+ disabled={!home || loading}
136
+ aria-label="Home folder"
137
+ >
138
+ <HardDrive size={13} />
139
+ </button>
140
+ <p className="min-w-0 flex-1 truncate font-mono text-[10px] text-muted" title={path}>
141
+ {path || 'Home'}
142
+ </p>
143
+ {loading && <Spinner size={12} />}
144
+ <button
145
+ type="button"
146
+ onClick={() => void open(path)}
147
+ disabled={!path || opening}
148
+ className="inline-flex h-7 items-center gap-1 rounded-lg bg-ink px-2.5 text-[10.5px] font-medium text-panel disabled:opacity-40"
149
+ >
150
+ {opening ? <Spinner size={11} /> : <Check size={11} />}
151
+ Open
152
+ </button>
153
+ </div>
154
+
155
+ {workspaces.length > 0 && (
156
+ <div className="border-b border-line px-3 py-2">
157
+ <p className="mb-1.5 text-[9px] font-semibold uppercase tracking-[0.13em] text-faint">Recent</p>
158
+ <div className="flex flex-wrap gap-1">
159
+ {workspaces.slice(0, 6).map((workspace) => (
160
+ <button
161
+ key={workspace.path}
162
+ type="button"
163
+ onClick={() => void open(workspace.path)}
164
+ className="max-w-full truncate rounded-md border border-line bg-raised/60 px-2 py-1 text-left text-[10px] text-muted hover:text-ink"
165
+ title={workspace.path}
166
+ >
167
+ {workspace.name}
168
+ </button>
169
+ ))}
170
+ </div>
171
+ </div>
172
+ )}
173
+
174
+ <div className="thin-scroll min-h-0 flex-1 overflow-y-auto px-2 py-2">
175
+ {error && <p className="px-2 py-3 text-[11px] text-danger">{error}</p>}
176
+ {!loading && !error && entries.length === 0 && (
177
+ <p className="px-2 py-4 text-[11px] text-faint">This folder has no subfolders.</p>
178
+ )}
179
+ {entries.map((entry) => (
180
+ <div key={entry.path} className="group flex items-center gap-1">
181
+ <button
182
+ type="button"
183
+ onClick={() => void load(entry.path)}
184
+ className="flex h-8 min-w-0 flex-1 items-center gap-2 rounded-md px-2 text-left text-[11.5px] text-muted transition-colors hover:bg-raised hover:text-ink"
185
+ >
186
+ <Folder size={13} className="shrink-0 text-faint" />
187
+ <span className="truncate">{entry.name}</span>
188
+ </button>
189
+ <button
190
+ type="button"
191
+ onClick={() => void open(entry.path)}
192
+ className="hidden h-7 rounded-md px-2 text-[10px] font-medium text-accent group-hover:block"
193
+ >
194
+ Use
195
+ </button>
196
+ </div>
197
+ ))}
198
+ </div>
199
+ </div>
200
+ )
201
+ }
202
+
203
+ export function WorkspacePanel() {
204
+ const open = useUi((state) => state.workspaceOpen)
205
+ const setOpen = useUi((state) => state.setWorkspaceOpen)
206
+ const notify = useUi((state) => state.notify)
207
+ const askConfirm = useUi((state) => state.askConfirm)
208
+ const info = useWorkspace((state) => state.info)
209
+ const root = useWorkspace((state) => state.root)
210
+ const entries = useWorkspace((state) => state.entries)
211
+ const loadingTree = useWorkspace((state) => state.loadingTree)
212
+ const treeError = useWorkspace((state) => state.treeError)
213
+ const activeFile = useWorkspace((state) => state.activeFile)
214
+ const file = useWorkspace((state) => state.file)
215
+ const content = useWorkspace((state) => state.content)
216
+ const dirty = useWorkspace((state) => state.dirty)
217
+ const saving = useWorkspace((state) => state.saving)
218
+ const openingFile = useWorkspace((state) => state.openingFile)
219
+ const git = useWorkspace((state) => state.git)
220
+ const gitLoading = useWorkspace((state) => state.gitLoading)
221
+ const setActiveFile = useWorkspace((state) => state.setActiveFile)
222
+ const saveFile = useWorkspace((state) => state.saveFile)
223
+ const setContent = useWorkspace((state) => state.setContent)
224
+ const createEntry = useWorkspace((state) => state.createEntry)
225
+ const renameEntry = useWorkspace((state) => state.renameEntry)
226
+ const deleteEntry = useWorkspace((state) => state.deleteEntry)
227
+ const forgetWorkspace = useWorkspace((state) => state.forgetWorkspace)
228
+ const loadGit = useWorkspace((state) => state.loadGit)
229
+ const refresh = useWorkspace((state) => state.refresh)
230
+
231
+ const [query, setQuery] = useState('')
232
+ const [collapsed, setCollapsed] = useState<Set<string>>(new Set())
233
+ const [tab, setTab] = useState<'files' | 'preview' | 'git'>('files')
234
+ const [choosing, setChoosing] = useState(!info.available)
235
+ const [editor, setEditor] = useState<{ mode: 'create-file' | 'create-dir' | 'rename'; value: string } | null>(
236
+ null,
237
+ )
238
+ const [copied, setCopied] = useState(false)
239
+ const editorRef = useRef<HTMLInputElement>(null)
240
+
241
+ const tree = useMemo(() => buildTree(entries), [entries])
242
+ const filtered = useMemo(() => {
243
+ const clean = query.trim().toLowerCase()
244
+ if (!clean) return null
245
+ return entries.filter((entry) => entry.path.toLowerCase().includes(clean)).slice(0, 300)
246
+ }, [entries, query])
247
+ const lineCount = content ? content.split('\n').length : 0
248
+
249
+ useEffect(() => {
250
+ if (editor) editorRef.current?.focus()
251
+ }, [editor])
252
+
253
+ useEffect(() => {
254
+ setChoosing(!info.available)
255
+ }, [info.available])
256
+
257
+ useEffect(() => {
258
+ const onKey = (event: KeyboardEvent) => {
259
+ if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== 's') return
260
+ if (!useUi.getState().workspaceOpen || !useWorkspace.getState().activeFile) return
261
+ event.preventDefault()
262
+ void useWorkspace.getState().saveFile()
263
+ }
264
+ window.addEventListener('keydown', onKey)
265
+ return () => window.removeEventListener('keydown', onKey)
266
+ }, [])
267
+
268
+ if (!open) return null
269
+
270
+ const submitEditor = async () => {
271
+ if (!editor) return
272
+ const value = editor.value.trim().replace(/^\/+/, '')
273
+ if (!value) return setEditor(null)
274
+ try {
275
+ if (editor.mode === 'rename') {
276
+ if (!activeFile) return
277
+ await renameEntry(activeFile, value)
278
+ } else {
279
+ await createEntry(value, editor.mode === 'create-dir' ? 'dir' : 'file')
280
+ }
281
+ setEditor(null)
282
+ } catch (cause) {
283
+ notify(cause instanceof Error ? cause.message : 'That change failed.', 'error')
284
+ }
285
+ }
286
+
287
+ const removeEntry = async (path: string) => {
288
+ const confirmed = await askConfirm({
289
+ title: 'Delete this path?',
290
+ body: `${path} will be deleted from disk. This cannot be undone.`,
291
+ confirmLabel: 'Delete',
292
+ tone: 'danger',
293
+ })
294
+ if (!confirmed) return
295
+ try {
296
+ await deleteEntry(path)
297
+ } catch (cause) {
298
+ notify(cause instanceof Error ? cause.message : 'Could not delete that path.', 'error')
299
+ }
300
+ }
301
+
302
+ const copyFile = async () => {
303
+ try {
304
+ await navigator.clipboard.writeText(content)
305
+ setCopied(true)
306
+ window.setTimeout(() => setCopied(false), 1500)
307
+ } catch {
308
+ notify('Clipboard access was blocked.', 'error')
309
+ }
310
+ }
311
+
312
+ const renderNode = (node: TreeNode, depth: number): ReactNode => {
313
+ const path = node.entry.path
314
+ if (node.entry.type === 'dir') {
315
+ const closed = collapsed.has(path)
316
+ return (
317
+ <div key={path}>
318
+ <button
319
+ type="button"
320
+ onClick={() =>
321
+ setCollapsed((current) => {
322
+ const next = new Set(current)
323
+ if (next.has(path)) next.delete(path)
324
+ else next.add(path)
325
+ return next
326
+ })
327
+ }
328
+ className="group flex h-7 w-full items-center gap-1.5 rounded-md pr-1.5 text-left text-[11.5px] font-medium text-muted transition-colors hover:bg-raised hover:text-ink"
329
+ style={{ paddingLeft: 8 + depth * 13 }}
330
+ title={path}
331
+ >
332
+ <ChevronRight size={10} className={cn('shrink-0 text-faint transition-transform', !closed && 'rotate-90')} />
333
+ {closed ? (
334
+ <Folder size={12} className="shrink-0 text-faint" />
335
+ ) : (
336
+ <FolderOpen size={12} className="shrink-0 text-faint" />
337
+ )}
338
+ <span className="min-w-0 flex-1 truncate">{node.entry.name}</span>
339
+ <span
340
+ role="button"
341
+ tabIndex={0}
342
+ onClick={(event) => {
343
+ event.stopPropagation()
344
+ void removeEntry(path)
345
+ }}
346
+ onKeyDown={(event) => {
347
+ if (event.key === 'Enter') void removeEntry(path)
348
+ }}
349
+ className="hidden h-5 w-5 items-center justify-center rounded text-faint hover:text-danger group-hover:flex"
350
+ aria-label={`Delete ${path}`}
351
+ >
352
+ <Trash2 size={10} />
353
+ </span>
354
+ </button>
355
+ {!closed && node.children.map((child) => renderNode(child, depth + 1))}
356
+ </div>
357
+ )
358
+ }
359
+ return (
360
+ <button
361
+ key={path}
362
+ type="button"
363
+ onClick={() => setActiveFile(path)}
364
+ className={cn(
365
+ 'flex h-7 w-full items-center gap-1.5 rounded-md pr-1.5 text-left font-mono text-[10.5px] transition-colors',
366
+ activeFile === path ? 'bg-accent-soft font-medium text-accent' : 'text-muted hover:bg-raised hover:text-ink',
367
+ )}
368
+ style={{ paddingLeft: 8 + depth * 13 + 10 }}
369
+ title={path}
370
+ >
371
+ <FileText size={11} className={cn('shrink-0', activeFile === path ? 'text-accent' : 'text-faint')} />
372
+ <span className="min-w-0 flex-1 truncate">{node.entry.name}</span>
373
+ </button>
374
+ )
375
+ }
376
+
377
+ return (
378
+ <>
379
+ <button
380
+ type="button"
381
+ aria-label="Close workspace"
382
+ onClick={() => setOpen(false)}
383
+ className="fixed inset-0 z-40 cursor-default bg-ink/20 backdrop-blur-[1px] xl:hidden"
384
+ />
385
+ <aside className={cn(
386
+ 'fixed inset-y-0 right-0 z-50 flex h-dvh w-[min(96vw,440px)] flex-col border-l border-line surface-lift shadow-float transition-[width] duration-200 xl:static xl:z-auto xl:shadow-none',
387
+ tab === 'preview' && info.available && !choosing ? 'sm:w-[min(96vw,620px)] xl:w-[560px]' : 'xl:w-[390px]',
388
+ )}
389
+ >
390
+ <div className="flex h-14 shrink-0 items-center gap-2 border-b border-line px-3">
391
+ <span className="flex h-7 w-7 items-center justify-center rounded-lg bg-accent-soft text-accent">
392
+ <FolderOpen size={14} />
393
+ </span>
394
+ <div className="min-w-0 flex-1">
395
+ <p className="truncate text-[12px] font-semibold tracking-[-0.01em] text-ink">
396
+ {info.available ? info.name : 'Workspace'}
397
+ </p>
398
+ <p className="truncate text-[9px] text-faint" title={root}>
399
+ {info.available ? root : 'No folder open'}
400
+ </p>
401
+ </div>
402
+ {info.available && (
403
+ <>
404
+ <button
405
+ type="button"
406
+ className="icon-button h-7 w-7"
407
+ onClick={() => setChoosing(true)}
408
+ title="Change folder"
409
+ aria-label="Change folder"
410
+ >
411
+ <Folder size={14} />
412
+ </button>
413
+ <button
414
+ type="button"
415
+ className="icon-button h-7 w-7"
416
+ onClick={() => void refresh().catch(() => undefined)}
417
+ title="Refresh tree"
418
+ aria-label="Refresh tree"
419
+ >
420
+ <RefreshCw size={13} className={cn(loadingTree && 'animate-spin')} />
421
+ </button>
422
+ </>
423
+ )}
424
+ <button
425
+ type="button"
426
+ className="icon-button h-7 w-7"
427
+ onClick={() => setOpen(false)}
428
+ title="Close workspace"
429
+ aria-label="Close workspace"
430
+ >
431
+ <X size={15} />
432
+ </button>
433
+ </div>
434
+
435
+ {choosing || !info.available ? (
436
+ <FolderPicker onDone={() => setChoosing(false)} />
437
+ ) : (
438
+ <>
439
+ <div className="flex shrink-0 items-center gap-1 border-b border-line px-2 py-1.5">
440
+ <button
441
+ type="button"
442
+ onClick={() => setTab('files')}
443
+ className={cn(
444
+ 'h-7 rounded-md px-2.5 text-[11px] font-medium',
445
+ tab === 'files' ? 'bg-raised text-ink' : 'text-muted hover:text-ink',
446
+ )}
447
+ >
448
+ Files
449
+ </button>
450
+ <button
451
+ type="button"
452
+ onClick={() => setTab('preview')}
453
+ className={cn(
454
+ 'h-7 rounded-md px-2.5 text-[11px] font-medium',
455
+ tab === 'preview' ? 'bg-raised text-ink' : 'text-muted hover:text-ink',
456
+ )}
457
+ >
458
+ Preview
459
+ </button>
460
+ <button
461
+ type="button"
462
+ onClick={() => setTab('git')}
463
+ className={cn(
464
+ 'h-7 rounded-md px-2.5 text-[11px] font-medium',
465
+ tab === 'git' ? 'bg-raised text-ink' : 'text-muted hover:text-ink',
466
+ )}
467
+ >
468
+ Git {git.entries.length > 0 && <span className="text-warning">· {git.entries.length}</span>}
469
+ </button>
470
+ <div className="ml-auto flex items-center gap-0.5">
471
+ <button
472
+ type="button"
473
+ className="icon-button h-7 w-7"
474
+ onClick={() => setEditor({ mode: 'create-file', value: '' })}
475
+ title="New file"
476
+ aria-label="New file"
477
+ >
478
+ <FilePlus2 size={13} />
479
+ </button>
480
+ <button
481
+ type="button"
482
+ className="icon-button h-7 w-7"
483
+ onClick={() => setEditor({ mode: 'create-dir', value: '' })}
484
+ title="New folder"
485
+ aria-label="New folder"
486
+ >
487
+ <FolderPlus size={13} />
488
+ </button>
489
+ </div>
490
+ </div>
491
+
492
+ {tab === 'files' ? (
493
+ <>
494
+ <div className="shrink-0 border-b border-line px-2.5 py-2">
495
+ <div className="relative">
496
+ <Search size={11} className="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 text-faint" />
497
+ <input
498
+ value={query}
499
+ onChange={(event) => setQuery(event.target.value)}
500
+ placeholder="Find a file"
501
+ className="h-7 w-full rounded-md border border-transparent bg-raised/60 pl-7 pr-2 text-[10.5px] text-ink outline-none transition-colors placeholder:text-faint focus:border-line focus:bg-panel"
502
+ aria-label="Find a file"
503
+ />
504
+ </div>
505
+ {editor && (
506
+ <div className="mt-1.5 flex items-center gap-1">
507
+ <input
508
+ ref={editorRef}
509
+ value={editor.value}
510
+ onChange={(event) => setEditor({ ...editor, value: event.target.value })}
511
+ onKeyDown={(event) => {
512
+ if (event.key === 'Enter') void submitEditor()
513
+ if (event.key === 'Escape') setEditor(null)
514
+ }}
515
+ placeholder={editor.mode === 'rename' ? activeFile ?? 'new/path' : 'folder/file.ts'}
516
+ className="h-7 min-w-0 flex-1 rounded-md border border-accent/40 bg-panel px-2 font-mono text-[10.5px] outline-none"
517
+ />
518
+ <button
519
+ type="button"
520
+ onClick={() => void submitEditor()}
521
+ className="flex h-7 items-center rounded-md bg-ink px-2 text-[10px] font-medium text-panel"
522
+ >
523
+ <Plus size={10} />
524
+ </button>
525
+ <button type="button" onClick={() => setEditor(null)} className="icon-button h-7 w-7">
526
+ <X size={12} />
527
+ </button>
528
+ </div>
529
+ )}
530
+ </div>
531
+
532
+ <div className="thin-scroll max-h-[32dvh] min-h-[92px] shrink-0 overflow-y-auto px-1.5 py-1.5 xl:max-h-[38%]">
533
+ {treeError && <p className="px-2 py-2 text-[10.5px] text-danger">{treeError}</p>}
534
+ {loadingTree && entries.length === 0 && (
535
+ <p className="flex items-center gap-1.5 px-2 py-3 text-[10.5px] text-faint">
536
+ <Spinner size={11} /> Reading folder…
537
+ </p>
538
+ )}
539
+ {filtered
540
+ ? filtered.map((entry) => (
541
+ <button
542
+ key={entry.path}
543
+ type="button"
544
+ onClick={() => entry.type === 'file' && setActiveFile(entry.path)}
545
+ className={cn(
546
+ 'flex h-7 w-full items-center gap-1.5 rounded-md px-2 text-left font-mono text-[10.5px]',
547
+ activeFile === entry.path
548
+ ? 'bg-accent-soft font-medium text-accent'
549
+ : 'text-muted hover:bg-raised hover:text-ink',
550
+ )}
551
+ >
552
+ {entry.type === 'dir' ? <Folder size={11} /> : <FileText size={11} />}
553
+ <span className="truncate">{entry.path}</span>
554
+ </button>
555
+ ))
556
+ : tree.map((node) => renderNode(node, 0))}
557
+ {!loadingTree && !treeError && entries.length === 0 && (
558
+ <p className="px-2 py-4 text-center text-[10.5px] text-faint">This folder is empty.</p>
559
+ )}
560
+ </div>
561
+
562
+ <div className="flex min-h-0 flex-1 flex-col border-t border-line">
563
+ {activeFile && file ? (
564
+ <>
565
+ <div className="flex shrink-0 items-center gap-1.5 border-b border-line px-3 py-2">
566
+ <div className="min-w-0 flex-1">
567
+ <p className="truncate font-mono text-[10.5px] font-medium text-ink">{activeFile}</p>
568
+ <p className="mt-0.5 text-[9px] text-faint">
569
+ {extension(activeFile)} · {lineCount} lines · {formatBytes(file.size)}
570
+ {dirty ? ' · unsaved' : ' · saved'}
571
+ </p>
572
+ </div>
573
+ {openingFile && <Spinner size={11} />}
574
+ <button
575
+ type="button"
576
+ className="icon-button h-7 w-7"
577
+ onClick={() => setEditor({ mode: 'rename', value: activeFile })}
578
+ title="Rename"
579
+ aria-label="Rename"
580
+ >
581
+ <Pencil size={12} />
582
+ </button>
583
+ <button type="button" className="icon-button h-7 w-7" onClick={copyFile} title="Copy" aria-label="Copy">
584
+ {copied ? <Check size={12} className="text-success" /> : <Copy size={12} />}
585
+ </button>
586
+ <button
587
+ type="button"
588
+ className="icon-button h-7 w-7 hover:text-danger"
589
+ onClick={() => void removeEntry(activeFile)}
590
+ title="Delete"
591
+ aria-label="Delete"
592
+ >
593
+ <Trash2 size={12} />
594
+ </button>
595
+ <button
596
+ type="button"
597
+ onClick={() => void saveFile()}
598
+ disabled={!dirty || saving}
599
+ className="inline-flex h-7 items-center gap-1 rounded-md bg-ink px-2 text-[10px] font-medium text-panel disabled:opacity-35"
600
+ >
601
+ {saving ? <Spinner size={10} /> : <Save size={10} />}
602
+ Save
603
+ </button>
604
+ </div>
605
+ {file.binary ? (
606
+ <div className="flex flex-1 items-center justify-center px-6 text-center text-[11px] text-faint">
607
+ This file is binary and cannot be edited here.
608
+ </div>
609
+ ) : (
610
+ <textarea
611
+ value={content}
612
+ onChange={(event) => setContent(event.target.value)}
613
+ spellCheck={false}
614
+ wrap="off"
615
+ aria-label={`Edit ${activeFile}`}
616
+ className="thin-scroll min-h-0 flex-1 resize-none overflow-auto bg-panel p-3.5 font-mono text-[11.5px] leading-[1.75] text-ink outline-none"
617
+ />
618
+ )}
619
+ <div className="flex h-6 shrink-0 items-center justify-between border-t border-line px-3 text-[8.5px] text-faint">
620
+ <span className="flex items-center gap-1">
621
+ <span className={cn('h-1 w-1 rounded-full', dirty ? 'bg-warning' : 'bg-success')} />
622
+ {dirty ? 'Unsaved changes' : 'Saved to disk'}
623
+ </span>
624
+ <span>⌘S to save · UTF-8</span>
625
+ </div>
626
+ </>
627
+ ) : (
628
+ <div className="flex flex-1 flex-col items-center justify-center gap-2 px-6 text-center">
629
+ <span className="flex h-9 w-9 items-center justify-center rounded-xl bg-raised text-faint">
630
+ <FileText size={16} />
631
+ </span>
632
+ <p className="text-[11.5px] font-medium text-ink">No file selected</p>
633
+ <p className="max-w-[230px] text-[10px] leading-relaxed text-faint">
634
+ Pick a file from the tree. The agent reads and writes these same files.
635
+ </p>
636
+ </div>
637
+ )}
638
+ </div>
639
+ </>
640
+ ) : tab === 'preview' ? (
641
+ <PreviewPane />
642
+ ) : (
643
+ <div className="thin-scroll min-h-0 flex-1 overflow-y-auto">
644
+ <div className="flex items-center gap-2 border-b border-line px-3 py-3">
645
+ <GitBranch size={14} className="text-faint" />
646
+ <div className="min-w-0 flex-1">
647
+ <p className="truncate text-[11.5px] font-medium text-ink">
648
+ {git.available ? git.branch || 'detached HEAD' : 'Not a git repository'}
649
+ </p>
650
+ <p className="text-[9.5px] text-faint">
651
+ {git.available ? `${git.entries.length} changed path${git.entries.length === 1 ? '' : 's'}` : git.error}
652
+ </p>
653
+ </div>
654
+ <button
655
+ type="button"
656
+ className="icon-button h-7 w-7"
657
+ onClick={() => void loadGit()}
658
+ aria-label="Refresh git"
659
+ >
660
+ <RefreshCw size={12} className={cn(gitLoading && 'animate-spin')} />
661
+ </button>
662
+ </div>
663
+ {git.entries.length === 0 ? (
664
+ <p className="px-3 py-5 text-[10.5px] text-faint">The working tree is clean.</p>
665
+ ) : (
666
+ git.entries.map((entry) => (
667
+ <button
668
+ key={`${entry.status}-${entry.path}`}
669
+ type="button"
670
+ onClick={() => setActiveFile(entry.path)}
671
+ className="flex w-full items-center gap-2 px-3 py-1.5 text-left font-mono text-[10px] hover:bg-raised"
672
+ title={entry.path}
673
+ >
674
+ <span className="w-6 shrink-0 text-warning">{entry.status}</span>
675
+ <span className="truncate text-muted">{entry.path}</span>
676
+ </button>
677
+ ))
678
+ )}
679
+ <div className="border-t border-line p-3">
680
+ <button
681
+ type="button"
682
+ onClick={async () => {
683
+ const confirmed = await askConfirm({
684
+ title: 'Forget this workspace?',
685
+ body: 'The folder stays on disk. OpenSol stops showing it and closes the current file.',
686
+ confirmLabel: 'Forget workspace',
687
+ })
688
+ if (confirmed) await forgetWorkspace()
689
+ }}
690
+ className="text-[10.5px] font-medium text-faint hover:text-danger"
691
+ >
692
+ Forget this workspace
693
+ </button>
694
+ </div>
695
+ </div>
696
+ )}
697
+ </>
698
+ )}
699
+
700
+ {info.available && !choosing && (
701
+ <div className="flex h-8 shrink-0 items-center gap-1.5 border-t border-line px-3 text-[9px] text-faint">
702
+ <LoaderCircle size={10} className={cn(loadingTree && 'animate-spin')} />
703
+ Real folder · daemon-scoped access
704
+ <span className="ml-auto">{entries.length} entries</span>
705
+ </div>
706
+ )}
707
+ </aside>
708
+ </>
709
+ )
710
+ }