@bibobonking/opensol 3.1.4 → 3.1.5

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 (72) hide show
  1. package/CHANGELOG.md +10 -12
  2. package/desktop/src-tauri/icons/128x128.png +0 -0
  3. package/desktop/src-tauri/icons/128x128@2x.png +0 -0
  4. package/desktop/src-tauri/icons/32x32.png +0 -0
  5. package/desktop/src-tauri/icons/64x64.png +0 -0
  6. package/desktop/src-tauri/icons/Square107x107Logo.png +0 -0
  7. package/desktop/src-tauri/icons/Square142x142Logo.png +0 -0
  8. package/desktop/src-tauri/icons/Square150x150Logo.png +0 -0
  9. package/desktop/src-tauri/icons/Square284x284Logo.png +0 -0
  10. package/desktop/src-tauri/icons/Square30x30Logo.png +0 -0
  11. package/desktop/src-tauri/icons/Square310x310Logo.png +0 -0
  12. package/desktop/src-tauri/icons/Square44x44Logo.png +0 -0
  13. package/desktop/src-tauri/icons/Square71x71Logo.png +0 -0
  14. package/desktop/src-tauri/icons/Square89x89Logo.png +0 -0
  15. package/desktop/src-tauri/icons/StoreLogo.png +0 -0
  16. package/desktop/src-tauri/icons/icon.icns +0 -0
  17. package/desktop/src-tauri/icons/icon.ico +0 -0
  18. package/desktop/src-tauri/icons/icon.png +0 -0
  19. package/dist/assets/index-C0yvs6Vl.js +281 -0
  20. package/dist/assets/index-CxNPLBg7.css +1 -0
  21. package/dist/favicon.svg +18 -15
  22. package/dist/index.html +2 -2
  23. package/package.json +1 -1
  24. package/public/favicon.svg +18 -15
  25. package/server/daemon.mjs +66 -49
  26. package/server/lib/deploy.mjs +222 -121
  27. package/server/lib/providers.mjs +23 -1
  28. package/server/lib/skills.mjs +385 -100
  29. package/server/lib/tools.mjs +0 -40
  30. package/src/agent/adapters/openai-responses.ts +1 -5
  31. package/src/agent/adapters/openai.ts +4 -11
  32. package/src/agent/prompt.ts +7 -14
  33. package/src/agent/subagent.ts +3 -14
  34. package/src/agent/tools.ts +2 -0
  35. package/src/components/ChatView.tsx +2 -1
  36. package/src/components/Composer.tsx +2 -1
  37. package/src/components/ContextBar.tsx +10 -0
  38. package/src/components/DeployModal.tsx +266 -252
  39. package/src/components/FileIcon.tsx +391 -99
  40. package/src/components/HomeDashboard.tsx +5 -10
  41. package/src/components/Logo.tsx +9 -11
  42. package/src/components/ModelDeck.tsx +8 -11
  43. package/src/components/SettingsModal.tsx +160 -122
  44. package/src/components/Sidebar.tsx +4 -3
  45. package/src/components/SystemPromptButton.tsx +198 -0
  46. package/src/components/ToolCallCard.tsx +2 -0
  47. package/src/components/TopBar.tsx +2 -19
  48. package/src/components/WorkspacePanel.tsx +76 -93
  49. package/src/core/plugins/tools/skill.ts +19 -76
  50. package/src/core/services/agent-loop.ts +16 -31
  51. package/src/core/services/subagents.ts +2 -2
  52. package/src/core/services/system-prompt.ts +16 -23
  53. package/src/core/services/tools.ts +0 -1
  54. package/src/lib/boot.ts +2 -0
  55. package/src/lib/credits.ts +33 -0
  56. package/src/lib/daemon.ts +40 -19
  57. package/src/lib/permissions.ts +1 -1
  58. package/src/lib/retry.test.mjs +40 -12
  59. package/src/lib/retry.ts +41 -8
  60. package/src/lib/system-prompt.ts +23 -0
  61. package/src/lib/usage.ts +8 -13
  62. package/src/store/chats.ts +0 -1
  63. package/src/store/settings.ts +12 -44
  64. package/src/store/skills.ts +79 -0
  65. package/src/store/ui.ts +3 -1
  66. package/src/types.ts +30 -15
  67. package/tsconfig.tsbuildinfo +1 -1
  68. package/dist/assets/index-B1pQC9Fe.js +0 -288
  69. package/dist/assets/index-qLr4aZVC.css +0 -1
  70. package/src/components/SystemPromptModal.tsx +0 -168
  71. package/src/lib/skills.test.mjs +0 -22
  72. package/src/lib/skills.ts +0 -93
@@ -1,68 +1,78 @@
1
1
  import { useEffect, useRef, useState } from 'react'
2
2
  import {
3
+ AlertTriangle,
3
4
  Check,
4
5
  Cloud,
5
6
  Copy,
6
7
  ExternalLink,
7
8
  FolderOpen,
8
9
  Globe2,
10
+ Hammer,
11
+ PackageOpen,
9
12
  Play,
10
13
  Rocket,
14
+ ScanSearch,
11
15
  ShieldCheck,
12
- Sparkles,
13
16
  Square,
14
- Triangle,
15
17
  Terminal,
18
+ Triangle,
19
+ UploadCloud,
16
20
  X,
17
21
  } from 'lucide-react'
18
- import type { DeployProviderId, DeployResult } from '../types'
19
- import { inspectDeploy, streamDeploy, type DeployInspection } from '../lib/daemon'
22
+ import type { DeployDetection, DeployProviderId, DeployResult } from '../types'
23
+ import { detectDeployProject, streamDeploy } from '../lib/daemon'
20
24
  import { cn } from '../lib/utils'
21
25
  import { useSettings } from '../store/settings'
22
26
  import { useUi } from '../store/ui'
23
27
  import { useWorkspace } from '../store/workspace'
28
+ import { FolderIcon } from './FileIcon'
24
29
  import { Modal, Spinner } from './ui'
25
30
 
26
31
  const PROVIDERS: Array<{
27
32
  id: DeployProviderId
28
33
  label: string
29
- short: string
30
34
  icon: typeof Cloud
35
+ tint: string
31
36
  blurb: string
32
37
  needs: string
33
38
  }> = [
34
- {
35
- id: 'cloudflare',
36
- label: 'Cloudflare Pages',
37
- short: 'Pages',
38
- icon: Cloud,
39
- blurb: 'Fast global hosting with a generous free tier.',
40
- needs: 'API token + account id',
41
- },
42
- {
43
- id: 'netlify',
44
- label: 'Netlify',
45
- short: 'Netlify',
46
- icon: Globe2,
47
- blurb: 'Create a site and publish a folder in one step.',
48
- needs: 'Personal access token',
49
- },
50
- {
51
- id: 'vercel',
52
- label: 'Vercel',
53
- short: 'Vercel',
54
- icon: Triangle,
55
- blurb: 'Production deploys through the Vercel CLI.',
56
- needs: 'Access token',
57
- },
39
+ { id: 'cloudflare', label: 'Cloudflare Pages', icon: Cloud, tint: '#f6821f', blurb: 'Global edge, generous free tier', needs: 'API token + account id' },
40
+ { id: 'netlify', label: 'Netlify', icon: Globe2, tint: '#00c7b7', blurb: 'Creates the site once, then reuses it', needs: 'Personal access token' },
41
+ { id: 'vercel', label: 'Vercel', icon: Triangle, tint: '#8b95a5', blurb: 'Production deploy through the CLI', needs: 'Access token' },
58
42
  ]
59
43
 
44
+ const STEPS = [
45
+ { id: 'prepare', label: 'Inspect', icon: ScanSearch },
46
+ { id: 'build', label: 'Build', icon: Hammer },
47
+ { id: 'publish', label: 'Publish', icon: UploadCloud },
48
+ ] as const
49
+
50
+ type StepId = (typeof STEPS)[number]['id']
51
+ type StepState = 'idle' | 'active' | 'done' | 'error'
52
+
60
53
  interface LogLine {
61
54
  id: number
62
55
  stream: 'stdout' | 'stderr' | 'meta'
63
56
  text: string
64
57
  }
65
58
 
59
+ const inputClass =
60
+ 'h-9 w-full rounded-lg border border-line bg-panel px-2.5 font-mono text-[11px] text-ink outline-none transition-colors placeholder:text-faint focus:border-accent/50 disabled:opacity-60'
61
+
62
+ function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
63
+ return (
64
+ <div>
65
+ <p className="mb-1.5 flex items-baseline gap-1.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-faint">
66
+ {label}
67
+ {hint && <span className="font-normal normal-case tracking-normal">{hint}</span>}
68
+ </p>
69
+ {children}
70
+ </div>
71
+ )
72
+ }
73
+
74
+ const IDLE: Record<StepId, StepState> = { prepare: 'idle', build: 'idle', publish: 'idle' }
75
+
66
76
  export function DeployModal() {
67
77
  const open = useUi((state) => state.deployOpen)
68
78
  const setOpen = useUi((state) => state.setDeployOpen)
@@ -79,9 +89,10 @@ export function DeployModal() {
79
89
  const [outputDir, setOutputDir] = useState('')
80
90
  const [buildCommand, setBuildCommand] = useState('')
81
91
  const [projectName, setProjectName] = useState('')
82
- const [inspection, setInspection] = useState<DeployInspection | null>(null)
83
- const [inspecting, setInspecting] = useState(false)
92
+ const [detected, setDetected] = useState<DeployDetection | null>(null)
93
+ const [detectError, setDetectError] = useState('')
84
94
  const [running, setRunning] = useState(false)
95
+ const [steps, setSteps] = useState<Record<StepId, StepState>>(IDLE)
85
96
  const [logs, setLogs] = useState<LogLine[]>([])
86
97
  const [result, setResult] = useState<DeployResult | null>(null)
87
98
  const [error, setError] = useState('')
@@ -89,66 +100,77 @@ export function DeployModal() {
89
100
  const abortRef = useRef<AbortController | null>(null)
90
101
  const logRef = useRef<HTMLDivElement>(null)
91
102
  const logId = useRef(0)
103
+ const currentStep = useRef<StepId | null>(null)
92
104
 
93
105
  const meta = PROVIDERS.find((entry) => entry.id === provider) ?? PROVIDERS[0]
94
106
  const tool = deployTools?.[provider]
95
107
  const configured = Boolean(deploySecrets?.[provider]?.configured)
96
- const lastUrl = deploySecrets?.[provider]?.fields?.lastUrl ?? ''
97
108
 
98
109
  useEffect(() => {
99
110
  if (!open) return
100
111
  setPath(workspaceRoot)
101
- setOutputDir('')
102
- setBuildCommand('')
103
- setProjectName('')
104
- setInspection(null)
105
112
  setResult(null)
106
113
  setError('')
107
114
  setLogs([])
108
115
  setRunning(false)
109
116
  setCopied(false)
117
+ setSteps(IDLE)
110
118
  }, [open, workspaceRoot])
111
119
 
112
- const detect = async (target = path) => {
113
- if (!target.trim()) return
114
- setInspecting(true)
115
- try {
116
- const info = await inspectDeploy(target.trim())
117
- setInspection(info)
118
- if (info.buildCommand) setBuildCommand((value) => value || info.buildCommand)
119
- if (info.outputDir) setOutputDir((value) => value || info.outputDir)
120
- if (info.name) setProjectName((value) => value || info.name)
121
- } catch (cause) {
122
- setInspection(null)
123
- setError(cause instanceof Error ? cause.message : 'Could not inspect that folder.')
124
- } finally {
125
- setInspecting(false)
126
- }
127
- }
128
-
129
- // Fill the form from the real project once the path settles.
120
+ // Look at the folder so the build command and output folder are suggested instead of guessed.
130
121
  useEffect(() => {
131
- if (!open || !path.trim()) return
132
- const timer = window.setTimeout(() => void detect(path), 450)
133
- return () => window.clearTimeout(timer)
122
+ if (!open || !path.trim()) {
123
+ setDetected(null)
124
+ setDetectError('')
125
+ return
126
+ }
127
+ let cancelled = false
128
+ const timer = window.setTimeout(() => {
129
+ detectDeployProject(path.trim())
130
+ .then((project) => {
131
+ if (cancelled) return
132
+ setDetected(project)
133
+ setDetectError('')
134
+ })
135
+ .catch((cause) => {
136
+ if (cancelled) return
137
+ setDetected(null)
138
+ setDetectError(cause instanceof Error ? cause.message : 'Could not read that folder.')
139
+ })
140
+ }, 350)
141
+ return () => {
142
+ cancelled = true
143
+ window.clearTimeout(timer)
144
+ }
134
145
  }, [open, path])
135
146
 
136
147
  useEffect(() => {
137
- if (!open) return
138
148
  const node = logRef.current
139
149
  if (node) node.scrollTop = node.scrollHeight
140
- }, [logs, open])
150
+ }, [logs])
141
151
 
142
- useEffect(() => {
143
- return () => abortRef.current?.abort()
144
- }, [])
152
+ useEffect(() => () => abortRef.current?.abort(), [])
145
153
 
146
154
  const append = (stream: LogLine['stream'], text: string) => {
147
155
  const clean = text.trimEnd()
148
156
  if (!clean) return
149
157
  logId.current += 1
150
158
  const id = logId.current
151
- setLogs((current) => [...current.slice(-500), { id, stream, text: clean }])
159
+ setLogs((current) => [...current.slice(-600), { id, stream, text: clean }])
160
+ }
161
+
162
+ const startStep = (id: string) => {
163
+ const mapped: StepId = id === 'prepare' || id === 'publish' ? id : 'build'
164
+ setSteps((current) => {
165
+ const next = { ...current }
166
+ for (const step of STEPS) {
167
+ if (step.id === mapped) next[step.id] = 'active'
168
+ else if (next[step.id] === 'active') next[step.id] = 'done'
169
+ }
170
+ if (mapped === 'publish' && next.build === 'idle') next.build = 'done'
171
+ return next
172
+ })
173
+ currentStep.current = mapped
152
174
  }
153
175
 
154
176
  const close = () => {
@@ -166,9 +188,12 @@ export function DeployModal() {
166
188
  setResult(null)
167
189
  setError('')
168
190
  setLogs([])
191
+ setSteps({ ...IDLE, prepare: 'active' })
192
+ currentStep.current = 'prepare'
169
193
  const controller = new AbortController()
170
194
  abortRef.current = controller
171
- append('meta', `Preparing ${meta.label} deploy from ${path.trim()}`)
195
+ append('meta', `Deploying ${path.trim()} to ${meta.label}`)
196
+ let failed = false
172
197
  try {
173
198
  for await (const event of streamDeploy(
174
199
  {
@@ -180,13 +205,15 @@ export function DeployModal() {
180
205
  },
181
206
  controller.signal,
182
207
  )) {
183
- if (event.type === 'log') append(event.stream, event.text)
184
- else if (event.type === 'start') append('meta', `Deploying ${event.provider}${event.path ? ` · ${event.path}` : ''}`)
208
+ if (event.type === 'step') startStep(event.id)
209
+ else if (event.type === 'log') append(event.stream, event.text)
185
210
  else if (event.type === 'done') {
186
211
  setResult(event.result)
187
- append('meta', `Published ${event.result.url}`)
212
+ setSteps({ prepare: 'done', build: 'done', publish: 'done' })
213
+ append('meta', `Live at ${event.result.url}`)
188
214
  break
189
215
  } else if (event.type === 'error') {
216
+ failed = true
190
217
  setError(event.message)
191
218
  append('stderr', event.message)
192
219
  break
@@ -195,12 +222,18 @@ export function DeployModal() {
195
222
  } catch (cause) {
196
223
  if (cause instanceof DOMException && cause.name === 'AbortError') {
197
224
  append('meta', 'Deployment stopped.')
225
+ setSteps(IDLE)
198
226
  } else {
227
+ failed = true
199
228
  const message = cause instanceof Error ? cause.message : 'The deployment failed.'
200
229
  setError(message)
201
230
  append('stderr', message)
202
231
  }
203
232
  } finally {
233
+ if (failed && currentStep.current) {
234
+ const failedStep = currentStep.current
235
+ setSteps((current) => ({ ...current, [failedStep]: 'error' }))
236
+ }
204
237
  abortRef.current = null
205
238
  setRunning(false)
206
239
  }
@@ -217,94 +250,68 @@ export function DeployModal() {
217
250
  }
218
251
  }
219
252
 
253
+ const folderName = path.trim().split(/[\\/]/).filter(Boolean).pop() ?? ''
254
+
220
255
  return (
221
- <Modal open={open} onClose={close} labelledBy="deploy-title" wide className="h-[92dvh] p-0 sm:h-[min(90dvh,760px)]">
222
- <div className="flex shrink-0 items-start justify-between gap-4 border-b border-line px-5 py-4 sm:px-6">
223
- <div className="flex min-w-0 items-start gap-3">
224
- <span className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-accent-soft text-accent">
256
+ <Modal open={open} onClose={close} labelledBy="deploy-title" wide className="h-[92dvh] p-0 sm:h-[min(90dvh,780px)]">
257
+ <div className="flex shrink-0 items-center justify-between gap-4 border-b border-line px-5 py-3.5 sm:px-6">
258
+ <div className="flex min-w-0 items-center gap-3">
259
+ <span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-accent text-on-accent shadow-pill">
225
260
  <Rocket size={17} />
226
261
  </span>
227
262
  <div className="min-w-0">
228
- <h2 id="deploy-title" className="font-display text-[20px] font-semibold tracking-[-0.02em] text-ink">
229
- Deploy a site
263
+ <h2 id="deploy-title" className="font-display text-[19px] font-semibold leading-tight tracking-[-0.02em] text-ink">
264
+ Deploy
230
265
  </h2>
231
- <p className="mt-0.5 text-[11.5px] leading-relaxed text-muted">
232
- Publish the open workspace or another project folder. Tokens stay encrypted on this machine.
233
- </p>
266
+ <p className="truncate text-[11.5px] text-muted">Build and publish a folder to a live URL. Tokens stay encrypted on this machine.</p>
234
267
  </div>
235
268
  </div>
236
- <button type="button" className="icon-button -mr-1 shrink-0" onClick={close} aria-label="Close deploy panel">
269
+ <button type="button" className="icon-button shrink-0" onClick={close} aria-label="Close deploy panel">
237
270
  <X size={16} />
238
271
  </button>
239
272
  </div>
240
273
 
241
- <div className="grid min-h-0 flex-1 lg:grid-cols-[minmax(0,380px)_minmax(0,1fr)]">
274
+ <div className="grid min-h-0 flex-1 lg:grid-cols-[minmax(0,390px)_minmax(0,1fr)]">
242
275
  <div className="thin-scroll min-h-0 overflow-y-auto border-b border-line lg:border-b-0 lg:border-r">
243
276
  <div className="space-y-5 p-5">
244
- <div>
245
- <p className="mb-2 text-[10px] font-semibold uppercase tracking-[0.14em] text-faint">Host</p>
246
- <div className="grid gap-1.5">
277
+ <Field label="Host">
278
+ <div className="grid grid-cols-3 gap-1.5">
247
279
  {PROVIDERS.map((entry) => {
248
280
  const Icon = entry.icon
249
281
  const active = provider === entry.id
282
+ const ready = deploySecrets?.[entry.id]?.configured
250
283
  return (
251
284
  <button
252
285
  key={entry.id}
253
286
  type="button"
254
287
  onClick={() => setProvider(entry.id)}
255
288
  disabled={running}
289
+ aria-pressed={active}
256
290
  className={cn(
257
- 'group/host flex items-start gap-2.5 rounded-xl border px-3 py-2.5 text-left transition-all duration-200 disabled:opacity-60',
258
- active
259
- ? 'border-accent/40 bg-gradient-to-br from-accent-soft/80 to-panel shadow-pill'
260
- : 'border-line bg-panel hover:border-line hover:bg-raised/50',
291
+ 'relative flex flex-col items-start gap-2 rounded-xl border p-2.5 text-left transition-all disabled:opacity-60',
292
+ active ? 'border-accent/50 bg-accent-soft/50 shadow-pill' : 'border-line bg-panel hover:bg-raised/60',
261
293
  )}
262
294
  >
263
- <span
264
- className={cn(
265
- 'mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-lg transition-colors',
266
- active ? 'bg-accent text-on-accent shadow-pill' : 'bg-raised text-muted group-hover/host:text-ink',
267
- )}
268
- >
269
- <Icon size={13} />
270
- </span>
271
- <span className="min-w-0 flex-1">
272
- <span className="flex items-center gap-2">
273
- <span className={cn('text-[12px] font-medium', active ? 'text-accent' : 'text-ink')}>
274
- {entry.label}
275
- </span>
276
- {deploySecrets?.[entry.id]?.configured ? (
277
- <Check size={11} className="text-success" />
278
- ) : (
279
- <span className="text-[9px] text-warning">needs token</span>
280
- )}
281
- </span>
282
- <span className="mt-0.5 block text-[10px] leading-relaxed text-faint">{entry.blurb}</span>
295
+ <span className="flex h-7 w-7 items-center justify-center rounded-lg text-white" style={{ backgroundColor: entry.tint }}>
296
+ <Icon size={14} />
283
297
  </span>
298
+ <span className="text-[11.5px] font-medium leading-tight text-ink">{entry.label}</span>
299
+ <span
300
+ className={cn('absolute right-2 top-2 h-1.5 w-1.5 rounded-full', ready ? 'bg-success' : 'bg-warning')}
301
+ title={ready ? 'Credentials saved' : 'Needs credentials'}
302
+ />
284
303
  </button>
285
304
  )
286
305
  })}
287
306
  </div>
288
- </div>
289
-
290
- {lastUrl && (
291
- <a
292
- href={lastUrl}
293
- target="_blank"
294
- rel="noreferrer"
295
- className="flex items-center gap-1.5 rounded-lg border border-line bg-raised/30 px-2.5 py-1.5 text-[9.5px] text-muted hover:text-ink"
296
- >
297
- <ExternalLink size={10} className="shrink-0 text-faint" />
298
- <span className="truncate">Last deploy: {lastUrl}</span>
299
- </a>
300
- )}
307
+ <p className="mt-1.5 text-[10px] text-faint">{meta.blurb}</p>
308
+ </Field>
301
309
 
302
310
  {!configured && (
303
- <div className="rounded-xl border border-warning/25 bg-warning/[0.045] p-3">
311
+ <div className="rounded-xl border border-warning/25 bg-warning/[0.05] p-3">
304
312
  <p className="flex items-start gap-1.5 text-[10.5px] leading-relaxed text-muted">
305
313
  <ShieldCheck size={12} className="mt-0.5 shrink-0 text-warning" />
306
- {meta.label} needs {meta.needs}. Add it once in Settings → Deploy; it is encrypted with AES-256-GCM
307
- and never appears in the page again.
314
+ {meta.label} needs {meta.needs}. Add it once in Settings → Deploy; it is encrypted and never shown again.
308
315
  </p>
309
316
  <button
310
317
  type="button"
@@ -319,112 +326,104 @@ export function DeployModal() {
319
326
  </div>
320
327
  )}
321
328
 
322
- <div>
323
- <div className="mb-1.5 flex items-center justify-between gap-2">
324
- <label htmlFor="deploy-path" className="text-[10px] font-semibold uppercase tracking-[0.14em] text-faint">
325
- Project folder
326
- </label>
327
- {workspaceAvailable && (
328
- <button
329
- type="button"
330
- onClick={() => setPath(workspaceRoot)}
331
- disabled={running}
332
- className="inline-flex items-center gap-1 text-[9.5px] font-medium text-accent disabled:opacity-40"
333
- >
334
- <FolderOpen size={10} />
335
- Use open workspace
336
- </button>
337
- )}
338
- </div>
329
+ <Field label="Project folder">
339
330
  <input
340
- id="deploy-path"
341
331
  value={path}
342
332
  onChange={(event) => setPath(event.target.value)}
343
333
  disabled={running}
344
334
  placeholder="/absolute/path/to/project or ~/Projects/site"
345
335
  spellCheck={false}
346
- className="h-9 w-full rounded-lg border border-line bg-panel px-2.5 font-mono text-[10.5px] text-ink outline-none transition-colors placeholder:text-faint focus:border-accent/50 disabled:opacity-60"
336
+ className={inputClass}
337
+ aria-label="Project folder"
347
338
  />
348
- <div className="mt-1.5 flex items-center gap-2">
349
- <p className="min-w-0 flex-1 truncate text-[9px] leading-relaxed text-faint">
350
- {workspaceAvailable ? `Open workspace: ${workspaceName}` : 'No workspace open.'} Any folder that already exists on this machine.
339
+ <div className="mt-1.5 flex items-center justify-between gap-2">
340
+ <p className="min-w-0 truncate text-[10px] text-faint">
341
+ {workspaceAvailable ? `Open workspace: ${workspaceName}` : 'No workspace open.'}
351
342
  </p>
352
- <button
353
- type="button"
354
- onClick={() => void detect()}
355
- disabled={running || inspecting || !path.trim()}
356
- className="inline-flex h-6 shrink-0 items-center gap-1 rounded-md border border-line bg-panel px-1.5 text-[9px] font-medium text-muted hover:text-ink disabled:opacity-40"
357
- >
358
- {inspecting ? <Spinner size={9} /> : <Sparkles size={9} />}
359
- Detect
360
- </button>
343
+ {workspaceAvailable && path !== workspaceRoot && (
344
+ <button
345
+ type="button"
346
+ onClick={() => setPath(workspaceRoot)}
347
+ disabled={running}
348
+ className="inline-flex shrink-0 items-center gap-1 text-[10px] font-medium text-accent disabled:opacity-40"
349
+ >
350
+ <FolderOpen size={10} /> Use workspace
351
+ </button>
352
+ )}
361
353
  </div>
362
- {inspection && (
363
- <div className="mt-2 flex flex-wrap items-center gap-1.5 rounded-lg border border-line bg-raised/40 px-2 py-1.5 text-[9px] text-muted">
364
- <span className="rounded bg-panel px-1.5 py-0.5 font-medium text-ink">{inspection.framework}</span>
365
- {inspection.existingOutputs.length > 0 && <span>build output: {inspection.existingOutputs.join(', ')}</span>}
366
- {!inspection.hasPackageJson && <span>static folder, no package.json</span>}
354
+
355
+ {detected && (
356
+ <div className="mt-2 flex items-start gap-2.5 rounded-xl border border-line bg-raised/40 p-2.5">
357
+ <FolderIcon name={detected.outputDir || 'src'} open size={22} />
358
+ <div className="min-w-0 flex-1 text-[10.5px] leading-relaxed">
359
+ <p className="font-medium text-ink">
360
+ {detected.framework || 'Folder'}
361
+ {detected.packageManager && <span className="ml-1.5 font-normal text-faint">{detected.packageManager}</span>}
362
+ </p>
363
+ <p className="text-muted">
364
+ {detected.buildCommand ? (
365
+ <>
366
+ build <code className="font-mono text-ink">{detected.buildCommand}</code>
367
+ </>
368
+ ) : (
369
+ 'no build step'
370
+ )}
371
+ {' → '}
372
+ {detected.outputDir ? (
373
+ <code className="font-mono text-ink">{detected.outputDir}</code>
374
+ ) : detected.expectedOutputDir ? (
375
+ <>
376
+ <code className="font-mono text-ink">{detected.expectedOutputDir}</code> <span className="text-faint">(after build)</span>
377
+ </>
378
+ ) : (
379
+ <span className="text-warning">no output folder found</span>
380
+ )}
381
+ </p>
382
+ </div>
367
383
  </div>
368
384
  )}
369
- </div>
385
+ {detectError && (
386
+ <p className="mt-2 flex items-start gap-1.5 text-[10.5px] leading-relaxed text-danger">
387
+ <AlertTriangle size={11} className="mt-0.5 shrink-0" />
388
+ {detectError}
389
+ </p>
390
+ )}
391
+ </Field>
370
392
 
371
393
  <div className="grid grid-cols-2 gap-2.5">
372
- <div>
373
- <label htmlFor="deploy-output" className="mb-1.5 block text-[10px] font-semibold uppercase tracking-[0.14em] text-faint">
374
- Output folder
375
- </label>
394
+ <Field label="Output folder" hint="auto">
376
395
  <input
377
- id="deploy-output"
378
396
  value={outputDir}
379
397
  onChange={(event) => setOutputDir(event.target.value)}
380
398
  disabled={running}
381
- placeholder="auto"
399
+ placeholder={detected?.outputDir || detected?.expectedOutputDir || 'auto'}
382
400
  spellCheck={false}
383
- className="h-8 w-full rounded-lg border border-line bg-panel px-2 font-mono text-[10.5px] text-ink outline-none focus:border-accent/50 disabled:opacity-60"
401
+ className={inputClass}
402
+ aria-label="Output folder"
384
403
  />
385
- </div>
386
- <div>
387
- <label htmlFor="deploy-name" className="mb-1.5 block text-[10px] font-semibold uppercase tracking-[0.14em] text-faint">
388
- Project name
389
- </label>
404
+ </Field>
405
+ <Field label="Project name" hint="auto">
390
406
  <input
391
- id="deploy-name"
392
407
  value={projectName}
393
408
  onChange={(event) => setProjectName(event.target.value)}
394
409
  disabled={running}
395
- placeholder="my-site"
410
+ placeholder={detected?.name || 'my-site'}
396
411
  spellCheck={false}
397
- className="h-8 w-full rounded-lg border border-line bg-panel px-2 font-mono text-[10.5px] text-ink outline-none focus:border-accent/50 disabled:opacity-60"
412
+ className={inputClass}
413
+ aria-label="Project name"
398
414
  />
399
- </div>
400
- </div>
401
-
402
- <div className="flex flex-wrap gap-1">
403
- {['auto', 'dist', 'build', 'out', '.'].map((value) => (
404
- <button
405
- key={value}
406
- type="button"
407
- onClick={() => setOutputDir(value)}
408
- disabled={running}
409
- className={cn('chip h-7 px-2 text-[9.5px]', (outputDir || 'auto') === value && 'chip-active')}
410
- >
411
- {value}
412
- </button>
413
- ))}
415
+ </Field>
414
416
  </div>
415
417
 
416
- <div>
417
- <label htmlFor="deploy-build" className="mb-1.5 block text-[10px] font-semibold uppercase tracking-[0.14em] text-faint">
418
- Build command <span className="font-normal normal-case tracking-normal text-faint">optional</span>
419
- </label>
418
+ <Field label="Build command" hint="auto when the output folder is missing">
420
419
  <input
421
- id="deploy-build"
422
420
  value={buildCommand}
423
421
  onChange={(event) => setBuildCommand(event.target.value)}
424
422
  disabled={running}
425
- placeholder="npm run build"
423
+ placeholder={detected?.buildCommand || 'npm run build'}
426
424
  spellCheck={false}
427
- className="h-8 w-full rounded-lg border border-line bg-panel px-2 font-mono text-[10.5px] text-ink outline-none focus:border-accent/50 disabled:opacity-60"
425
+ className={inputClass}
426
+ aria-label="Build command"
428
427
  />
429
428
  <div className="mt-1.5 flex flex-wrap gap-1">
430
429
  {['npm run build', 'pnpm build', 'yarn build'].map((value) => (
@@ -433,121 +432,136 @@ export function DeployModal() {
433
432
  type="button"
434
433
  onClick={() => setBuildCommand(value)}
435
434
  disabled={running}
436
- className="rounded-md border border-line px-1.5 py-0.5 text-[9px] text-faint transition-colors hover:text-ink"
435
+ className="rounded-md border border-line px-1.5 py-0.5 text-[9.5px] text-faint transition-colors hover:text-ink"
437
436
  >
438
437
  {value}
439
438
  </button>
440
439
  ))}
441
- {buildCommand && (
440
+ {(buildCommand || outputDir) && (
442
441
  <button
443
442
  type="button"
444
- onClick={() => setBuildCommand('')}
443
+ onClick={() => {
444
+ setBuildCommand('')
445
+ setOutputDir('')
446
+ }}
445
447
  disabled={running}
446
- className="px-1.5 py-0.5 text-[9px] text-faint hover:text-danger"
448
+ className="px-1.5 py-0.5 text-[9.5px] text-faint hover:text-danger"
447
449
  >
448
- clear
450
+ reset to auto
449
451
  </button>
450
452
  )}
451
453
  </div>
452
- </div>
454
+ </Field>
453
455
 
454
- <div className="flex items-start gap-1.5 rounded-lg border border-line bg-raised/40 px-2.5 py-2 text-[9.5px] leading-relaxed text-faint">
456
+ <div className="flex items-start gap-1.5 rounded-lg border border-line bg-raised/40 px-2.5 py-2 text-[10px] leading-relaxed text-faint">
455
457
  <Terminal size={11} className="mt-0.5 shrink-0" />
456
458
  {tool?.installed
457
- ? `${tool.label} ${tool.version ? `(${tool.version})` : ''} is installed locally.`
458
- : `${tool?.label ?? meta.short} CLI is not installed; OpenSol will use npx for this deploy.`}
459
+ ? `${tool.label}${tool.version ? ` ${tool.version}` : ''} found on this machine.`
460
+ : `${tool?.label ?? 'The CLI'} is not installed; OpenSol will fetch it with npx for this deploy.`}
459
461
  </div>
460
462
  </div>
461
463
  </div>
462
464
 
463
465
  <div className="flex min-h-0 flex-col">
464
- <div className="flex shrink-0 items-center gap-2 border-b border-line px-4 py-2.5">
465
- <span className={cn('h-1.5 w-1.5 rounded-full', running ? 'animate-breathe bg-accent' : result ? 'bg-success' : 'bg-faint')} />
466
- <p className="min-w-0 flex-1 truncate text-[11px] font-medium text-muted">
467
- {running ? `Deploying with ${meta.label}…` : result ? 'Deployment complete' : 'Deploy output'}
468
- </p>
469
- {running && (
466
+ <div className="flex shrink-0 items-center gap-3 border-b border-line px-4 py-3">
467
+ <ol className="flex min-w-0 flex-1 items-center gap-1.5">
468
+ {STEPS.map((step, index) => {
469
+ const state = steps[step.id]
470
+ const Icon = step.icon
471
+ return (
472
+ <li key={step.id} className="flex min-w-0 items-center gap-1.5">
473
+ <span
474
+ className={cn(
475
+ 'flex h-7 items-center gap-1.5 rounded-full border px-2.5 text-[10.5px] font-medium transition-colors',
476
+ state === 'active' && 'border-accent/40 bg-accent-soft text-accent',
477
+ state === 'done' && 'border-success/30 bg-success/10 text-success',
478
+ state === 'error' && 'border-danger/30 bg-danger/10 text-danger',
479
+ state === 'idle' && 'border-line text-faint',
480
+ )}
481
+ >
482
+ {state === 'active' ? (
483
+ <Spinner size={11} />
484
+ ) : state === 'done' ? (
485
+ <Check size={11} />
486
+ ) : state === 'error' ? (
487
+ <AlertTriangle size={11} />
488
+ ) : (
489
+ <Icon size={11} />
490
+ )}
491
+ <span className="hidden sm:inline">{step.label}</span>
492
+ </span>
493
+ {index < STEPS.length - 1 && <span className={cn('h-px w-4 shrink-0', state === 'done' ? 'bg-success/40' : 'bg-line')} />}
494
+ </li>
495
+ )
496
+ })}
497
+ </ol>
498
+ {running ? (
470
499
  <button
471
500
  type="button"
472
501
  onClick={() => abortRef.current?.abort()}
473
- className="inline-flex h-7 items-center gap-1 rounded-lg border border-line px-2 text-[10px] font-medium text-muted hover:text-ink"
502
+ className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-line px-3 text-[11px] font-medium text-muted hover:text-ink"
474
503
  >
475
504
  <Square size={9} fill="currentColor" /> Stop
476
505
  </button>
477
- )}
478
- {!running && (
506
+ ) : (
479
507
  <button
480
508
  type="button"
481
509
  onClick={() => void deploy()}
482
510
  disabled={!configured || !path.trim()}
483
- className="inline-flex h-7 items-center gap-1.5 rounded-lg bg-accent px-2.5 text-[10.5px] font-medium text-on-accent transition-colors hover:bg-accent/90 disabled:cursor-not-allowed disabled:opacity-40"
511
+ className="inline-flex h-8 items-center gap-1.5 rounded-lg bg-accent px-3.5 text-[11.5px] font-medium text-on-accent transition-colors hover:bg-accent/90 disabled:cursor-not-allowed disabled:opacity-40"
484
512
  >
485
513
  <Play size={10} fill="currentColor" />
486
- Deploy
514
+ {result || error ? 'Deploy again' : 'Deploy'}
487
515
  </button>
488
516
  )}
489
517
  </div>
490
518
 
491
519
  {result && (
492
- <div className="shrink-0 border-b border-line bg-success/[0.045] px-4 py-3">
493
- <div className="flex items-start gap-2">
494
- <Check size={14} className="mt-0.5 shrink-0 text-success" />
520
+ <div className="shrink-0 border-b border-line bg-success/[0.05] px-4 py-3.5">
521
+ <div className="flex items-center gap-3">
522
+ <span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-success/15 text-success">
523
+ <Check size={17} />
524
+ </span>
495
525
  <div className="min-w-0 flex-1">
496
- <p className="text-[11.5px] font-medium text-ink">Published successfully</p>
497
- <a
498
- href={result.url}
499
- target="_blank"
500
- rel="noreferrer"
501
- className="mt-0.5 block truncate text-[11px] text-accent hover:underline"
502
- >
526
+ <p className="text-[11px] font-medium text-muted">Live on {meta.label}</p>
527
+ <a href={result.url} target="_blank" rel="noreferrer" className="block truncate text-[14px] font-semibold text-accent hover:underline">
503
528
  {result.url}
504
529
  </a>
505
530
  </div>
506
- <button type="button" onClick={copyUrl} className="icon-button h-7 w-7" title="Copy URL" aria-label="Copy URL">
507
- {copied ? <Check size={12} className="text-success" /> : <Copy size={12} />}
531
+ <button type="button" onClick={copyUrl} className="icon-button h-8 w-8" title="Copy URL" aria-label="Copy URL">
532
+ {copied ? <Check size={13} className="text-success" /> : <Copy size={13} />}
508
533
  </button>
509
- <a
510
- href={result.url}
511
- target="_blank"
512
- rel="noreferrer"
513
- className="icon-button h-7 w-7"
514
- title="Open site"
515
- aria-label="Open site"
516
- >
517
- <ExternalLink size={12} />
534
+ <a href={result.url} target="_blank" rel="noreferrer" className="icon-button h-8 w-8" title="Open site" aria-label="Open site">
535
+ <ExternalLink size={13} />
518
536
  </a>
519
537
  </div>
520
538
  </div>
521
539
  )}
522
540
 
523
541
  {error && (
524
- <div className="shrink-0 border-b border-danger/20 bg-danger/[0.045] px-4 py-2.5 text-[10.5px] leading-relaxed text-danger">
542
+ <div className="flex shrink-0 items-start gap-2 border-b border-danger/20 bg-danger/[0.05] px-4 py-2.5 text-[11px] leading-relaxed text-danger">
543
+ <AlertTriangle size={13} className="mt-0.5 shrink-0" />
525
544
  {error}
526
545
  </div>
527
546
  )}
528
547
 
529
548
  <div ref={logRef} className="thin-scroll min-h-0 flex-1 overflow-y-auto bg-canvas/40 px-4 py-3">
530
549
  {logs.length === 0 && !running ? (
531
- <div className="flex h-full min-h-[180px] flex-col items-center justify-center gap-2 px-6 text-center">
532
- <span className="flex h-10 w-10 items-center justify-center rounded-xl bg-raised text-faint">
533
- <Rocket size={17} />
550
+ <div className="flex h-full min-h-[200px] flex-col items-center justify-center gap-2 px-6 text-center">
551
+ <span className="flex h-11 w-11 items-center justify-center rounded-2xl bg-raised text-faint">
552
+ <PackageOpen size={18} />
534
553
  </span>
535
- <p className="text-[11.5px] font-medium text-muted">Ready when you are</p>
536
- <p className="max-w-[260px] text-[10px] leading-relaxed text-faint">
537
- The build and deploy output appears here. Credentials are passed to the CLI through the environment,
538
- never printed in the log.
554
+ <p className="text-[12px] font-medium text-muted">Ready when you are</p>
555
+ <p className="max-w-[280px] text-[10.5px] leading-relaxed text-faint">
556
+ Leave the fields on auto: OpenSol installs dependencies, builds if there is no output folder yet, then publishes it. Tokens never appear in the log.
539
557
  </p>
540
558
  </div>
541
559
  ) : (
542
- <pre className="whitespace-pre-wrap break-words font-mono text-[10px] leading-[1.7] text-muted">
560
+ <pre className="whitespace-pre-wrap break-words font-mono text-[10.5px] leading-[1.7] text-muted">
543
561
  {logs.map((line) => (
544
562
  <span
545
563
  key={line.id}
546
- className={cn(
547
- 'block',
548
- line.stream === 'stderr' && 'text-danger',
549
- line.stream === 'meta' && 'text-accent',
550
- )}
564
+ className={cn('block', line.stream === 'stderr' && 'text-danger', line.stream === 'meta' && 'font-medium text-accent')}
551
565
  >
552
566
  {line.stream === 'meta' ? '› ' : ''}
553
567
  {line.text}
@@ -562,10 +576,10 @@ export function DeployModal() {
562
576
  )}
563
577
  </div>
564
578
 
565
- <div className="flex h-9 shrink-0 items-center gap-2 border-t border-line px-4 text-[9px] text-faint">
579
+ <div className="flex h-9 shrink-0 items-center gap-2 border-t border-line px-4 text-[10px] text-faint">
566
580
  <ShieldCheck size={10} />
567
581
  Tokens encrypted at rest
568
- <span className="ml-auto">{path.trim() ? path.trim().split(/[\\/]/).filter(Boolean).pop() : 'No folder'}</span>
582
+ <span className="ml-auto truncate">{folderName || 'No folder'}</span>
569
583
  </div>
570
584
  </div>
571
585
  </div>