pragma-so 0.1.0

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 (65) hide show
  1. package/cli/index.ts +882 -0
  2. package/index.ts +3 -0
  3. package/package.json +53 -0
  4. package/server/connectorBinaries.ts +103 -0
  5. package/server/connectorRegistry.ts +158 -0
  6. package/server/conversation/adapterRegistry.ts +53 -0
  7. package/server/conversation/adapters/claudeAdapter.ts +138 -0
  8. package/server/conversation/adapters/codexAdapter.ts +142 -0
  9. package/server/conversation/adapters.ts +224 -0
  10. package/server/conversation/executeRunner.ts +1191 -0
  11. package/server/conversation/gitWorkflow.ts +1037 -0
  12. package/server/conversation/models.ts +23 -0
  13. package/server/conversation/pragmaCli.ts +34 -0
  14. package/server/conversation/prompts.ts +335 -0
  15. package/server/conversation/store.ts +805 -0
  16. package/server/conversation/titleGenerator.ts +106 -0
  17. package/server/conversation/turnRunner.ts +365 -0
  18. package/server/conversation/types.ts +134 -0
  19. package/server/db.ts +837 -0
  20. package/server/http/middleware.ts +31 -0
  21. package/server/http/schemas.ts +430 -0
  22. package/server/http/validators.ts +38 -0
  23. package/server/index.ts +6560 -0
  24. package/server/process/runCommand.ts +142 -0
  25. package/server/stores/agentStore.ts +167 -0
  26. package/server/stores/connectorStore.ts +299 -0
  27. package/server/stores/humanStore.ts +28 -0
  28. package/server/stores/skillStore.ts +127 -0
  29. package/server/stores/taskStore.ts +371 -0
  30. package/shared/net.ts +24 -0
  31. package/tsconfig.json +14 -0
  32. package/ui/index.html +14 -0
  33. package/ui/public/favicon-32.png +0 -0
  34. package/ui/public/favicon.png +0 -0
  35. package/ui/src/App.jsx +1338 -0
  36. package/ui/src/api.js +954 -0
  37. package/ui/src/components/CodeView.jsx +319 -0
  38. package/ui/src/components/ConnectionsView.jsx +1004 -0
  39. package/ui/src/components/ContextView.jsx +315 -0
  40. package/ui/src/components/ConversationDrawer.jsx +963 -0
  41. package/ui/src/components/EmptyPane.jsx +20 -0
  42. package/ui/src/components/FeedView.jsx +773 -0
  43. package/ui/src/components/FilesView.jsx +257 -0
  44. package/ui/src/components/InlineChatView.jsx +158 -0
  45. package/ui/src/components/InputBar.jsx +476 -0
  46. package/ui/src/components/OnboardingModal.jsx +112 -0
  47. package/ui/src/components/OutputPanel.jsx +658 -0
  48. package/ui/src/components/PlanProposalPanel.jsx +177 -0
  49. package/ui/src/components/RightPanel.jsx +951 -0
  50. package/ui/src/components/SettingsView.jsx +186 -0
  51. package/ui/src/components/Sidebar.jsx +247 -0
  52. package/ui/src/components/TestingPane.jsx +198 -0
  53. package/ui/src/components/testing/ApiTesterPanel.jsx +187 -0
  54. package/ui/src/components/testing/LogViewerPanel.jsx +64 -0
  55. package/ui/src/components/testing/TerminalPanel.jsx +104 -0
  56. package/ui/src/components/testing/WebPreviewPanel.jsx +78 -0
  57. package/ui/src/hooks/useAgents.js +81 -0
  58. package/ui/src/hooks/useConversation.js +252 -0
  59. package/ui/src/hooks/useTasks.js +161 -0
  60. package/ui/src/hooks/useWorkspace.js +259 -0
  61. package/ui/src/lib/agentIcon.js +10 -0
  62. package/ui/src/lib/conversationUtils.js +575 -0
  63. package/ui/src/main.jsx +10 -0
  64. package/ui/src/styles.css +6899 -0
  65. package/ui/vite.config.mjs +6 -0
@@ -0,0 +1,658 @@
1
+ import { useEffect, useMemo, useRef, useState } from 'react'
2
+ import { ChevronDown, ChevronRight, FileCode2, FileImage, FileSpreadsheet, FileText, FileType2 } from 'lucide-react'
3
+ import Papa from 'papaparse'
4
+ import ReactMarkdown from 'react-markdown'
5
+ import remarkGfm from 'remark-gfm'
6
+ import {
7
+ fetchTaskOutputChanges,
8
+ fetchTaskOutputFiles,
9
+ taskOutputContentUrl,
10
+ taskOutputDownloadUrl,
11
+ } from '../api'
12
+ import { TestingPane } from './TestingPane'
13
+
14
+ const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'])
15
+
16
+ function ext(path) {
17
+ const index = path.lastIndexOf('.')
18
+ if (index === -1) return ''
19
+ return path.slice(index).toLowerCase()
20
+ }
21
+
22
+ function previewKind(path) {
23
+ const extension = ext(path)
24
+ if (extension === '.md') return 'markdown'
25
+ if (extension === '.html' || extension === '.htm') return 'html'
26
+ if (extension === '.csv') return 'csv'
27
+ if (IMAGE_EXTENSIONS.has(extension)) return 'image'
28
+ return 'download'
29
+ }
30
+
31
+ function fileName(path) {
32
+ const segments = String(path || '').split('/').filter(Boolean)
33
+ return segments[segments.length - 1] || path
34
+ }
35
+
36
+ function outputIcon(path) {
37
+ const extension = ext(path)
38
+ if (extension === '.md' || extension === '.txt') return FileText
39
+ if (extension === '.html' || extension === '.htm' || extension === '.js' || extension === '.ts') return FileCode2
40
+ if (extension === '.csv') return FileSpreadsheet
41
+ if (IMAGE_EXTENSIONS.has(extension)) return FileImage
42
+ return FileType2
43
+ }
44
+
45
+ function formatBytes(value) {
46
+ if (!Number.isFinite(value) || value < 0) return ''
47
+ if (value < 1024) return `${value} B`
48
+ if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`
49
+ return `${(value / (1024 * 1024)).toFixed(1)} MB`
50
+ }
51
+
52
+ function parseCsv(text) {
53
+ const parsed = Papa.parse(text, {
54
+ skipEmptyLines: 'greedy',
55
+ })
56
+
57
+ if (parsed.errors.length > 0) {
58
+ throw new Error(parsed.errors[0].message || 'Failed to parse CSV.')
59
+ }
60
+
61
+ if (!Array.isArray(parsed.data)) {
62
+ return []
63
+ }
64
+
65
+ return parsed.data.map((row) => (Array.isArray(row) ? row : [String(row)]))
66
+ }
67
+
68
+ function parseDiffIntoFiles(diff) {
69
+ if (!diff || !diff.trim()) return []
70
+
71
+ const lines = diff.split('\n')
72
+ const files = []
73
+ let current = null
74
+
75
+ for (const line of lines) {
76
+ if (line.startsWith('diff --git ')) {
77
+ const match = line.match(/^diff --git a\/.+ b\/(.+)$/)
78
+ current = {
79
+ path: match ? match[1] : 'unknown file',
80
+ additions: 0,
81
+ deletions: 0,
82
+ lines: [],
83
+ }
84
+ files.push(current)
85
+ current.lines.push(line)
86
+ } else if (current) {
87
+ current.lines.push(line)
88
+ if (line.startsWith('+') && !line.startsWith('+++')) {
89
+ current.additions++
90
+ } else if (line.startsWith('-') && !line.startsWith('---')) {
91
+ current.deletions++
92
+ }
93
+ }
94
+ }
95
+
96
+ // Fallback: if the diff has content but no `diff --git` markers, treat as one file
97
+ if (files.length === 0 && diff.trim()) {
98
+ let additions = 0
99
+ let deletions = 0
100
+ for (const line of lines) {
101
+ if (line.startsWith('+') && !line.startsWith('+++')) additions++
102
+ else if (line.startsWith('-') && !line.startsWith('---')) deletions++
103
+ }
104
+ files.push({ path: 'changes', additions, deletions, lines })
105
+ }
106
+
107
+ return files
108
+ }
109
+
110
+ function FileDiff({ file, defaultOpen }) {
111
+ const [open, setOpen] = useState(defaultOpen)
112
+
113
+ return (
114
+ <div className="diff-file">
115
+ <button className="diff-file-header" onClick={() => setOpen((v) => !v)}>
116
+ <span className="diff-file-chevron">
117
+ {open ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
118
+ </span>
119
+ <span className="diff-file-path">{file.path}</span>
120
+ <span className="diff-file-stats">
121
+ {file.additions > 0 && <span className="diff-stat-add">+{file.additions}</span>}
122
+ {file.deletions > 0 && <span className="diff-stat-remove">-{file.deletions}</span>}
123
+ </span>
124
+ </button>
125
+ {open && (
126
+ <div className="diff-file-body">
127
+ {file.lines.map((line, index) => {
128
+ let className = 'diff-line'
129
+ if (line.startsWith('diff --git')) {
130
+ className += ' meta'
131
+ } else if (line.startsWith('+++') || line.startsWith('---')) {
132
+ className += ' header'
133
+ } else if (line.startsWith('@@')) {
134
+ className += ' hunk'
135
+ } else if (line.startsWith('+')) {
136
+ className += ' add'
137
+ } else if (line.startsWith('-')) {
138
+ className += ' remove'
139
+ }
140
+
141
+ return (
142
+ <div key={`line-${index}`} className={className}>
143
+ {line || ' '}
144
+ </div>
145
+ )
146
+ })}
147
+ </div>
148
+ )}
149
+ </div>
150
+ )
151
+ }
152
+
153
+ function DiffViewer({ diff }) {
154
+ const files = useMemo(() => parseDiffIntoFiles(diff), [diff])
155
+
156
+ if (files.length === 0) {
157
+ return <div className="muted">No changes detected.</div>
158
+ }
159
+
160
+ return (
161
+ <div className="diff-viewer">
162
+ <div className="diff-summary">
163
+ {files.length} {files.length === 1 ? 'file' : 'files'} changed
164
+ </div>
165
+ {files.map((file, index) => (
166
+ <FileDiff key={`${file.path}-${index}`} file={file} defaultOpen={files.length <= 5} />
167
+ ))}
168
+ </div>
169
+ )
170
+ }
171
+
172
+ function formatRuntimeLogEntry(entry) {
173
+ if (!entry || typeof entry !== 'object') {
174
+ return ''
175
+ }
176
+ const stream = typeof entry.stream === 'string' ? entry.stream : 'log'
177
+ const text = typeof entry.text === 'string' ? entry.text : ''
178
+ return `[${stream}] ${text}`
179
+ }
180
+
181
+ export function OutputPanel({
182
+ taskId,
183
+ taskStatus,
184
+ testCommands = [],
185
+ testCommandsLoading = false,
186
+ testCommandsError = '',
187
+ runningTestCommand = '',
188
+ onRunTestCommand,
189
+ onUpdateTestCommand,
190
+ onChangesLoaded,
191
+ onFilesLoaded,
192
+ planData = null,
193
+ planLoading = false,
194
+ planError = '',
195
+ runtimeService = null,
196
+ runtimeServiceLogs = [],
197
+ runtimeServiceError = '',
198
+ onStopRuntimeService,
199
+ testingConfig = null,
200
+ }) {
201
+ const [tab, setTab] = useState('outputs')
202
+ const runtimeLogRef = useRef(null)
203
+ const [commandDrafts, setCommandDrafts] = useState({})
204
+ const [savingCommandIndex, setSavingCommandIndex] = useState(-1)
205
+ const [editingCommandIndex, setEditingCommandIndex] = useState(-1)
206
+
207
+ const [changes, setChanges] = useState('')
208
+ const [changesLoading, setChangesLoading] = useState(false)
209
+ const [changesError, setChangesError] = useState('')
210
+
211
+ const [files, setFiles] = useState([])
212
+ const [filesLoading, setFilesLoading] = useState(false)
213
+ const [filesError, setFilesError] = useState('')
214
+
215
+ const [selectedPath, setSelectedPath] = useState('')
216
+ const [previewText, setPreviewText] = useState('')
217
+ const [previewLoading, setPreviewLoading] = useState(false)
218
+ const [previewError, setPreviewError] = useState('')
219
+
220
+ useEffect(() => {
221
+ if (!taskId) return
222
+ setTab(testingConfig ? 'testing' : 'outputs')
223
+ void loadChanges(taskId)
224
+ void loadFiles(taskId)
225
+ }, [taskId, testingConfig])
226
+
227
+ useEffect(() => {
228
+ if (!files.length) {
229
+ setSelectedPath('')
230
+ return
231
+ }
232
+
233
+ setSelectedPath((current) => {
234
+ if (current && files.some((item) => item.path === current)) {
235
+ return current
236
+ }
237
+ return files[0].path
238
+ })
239
+ }, [files])
240
+
241
+ const selectedFile = useMemo(() => {
242
+ return files.find((file) => file.path === selectedPath) || null
243
+ }, [files, selectedPath])
244
+
245
+ const selectedKind = previewKind(selectedPath || '')
246
+
247
+ useEffect(() => {
248
+ if (!taskId || !selectedPath) {
249
+ setPreviewText('')
250
+ setPreviewError('')
251
+ setPreviewLoading(false)
252
+ return
253
+ }
254
+
255
+ if (selectedKind !== 'markdown' && selectedKind !== 'csv') {
256
+ setPreviewText('')
257
+ setPreviewError('')
258
+ setPreviewLoading(false)
259
+ return
260
+ }
261
+
262
+ const controller = new AbortController()
263
+ setPreviewLoading(true)
264
+ setPreviewError('')
265
+
266
+ void fetch(taskOutputContentUrl(taskId, selectedPath), { signal: controller.signal })
267
+ .then(async (response) => {
268
+ if (!response.ok) {
269
+ throw new Error(`HTTP ${response.status}`)
270
+ }
271
+ return response.text()
272
+ })
273
+ .then((text) => {
274
+ setPreviewText(text)
275
+ })
276
+ .catch((error) => {
277
+ if (controller.signal.aborted) {
278
+ return
279
+ }
280
+ setPreviewError(error instanceof Error ? error.message : String(error))
281
+ })
282
+ .finally(() => {
283
+ if (!controller.signal.aborted) {
284
+ setPreviewLoading(false)
285
+ }
286
+ })
287
+
288
+ return () => {
289
+ controller.abort()
290
+ }
291
+ }, [taskId, selectedPath, selectedKind])
292
+
293
+ async function loadChanges(targetTaskId) {
294
+ setChangesLoading(true)
295
+ setChangesError('')
296
+
297
+ try {
298
+ const data = await fetchTaskOutputChanges(targetTaskId)
299
+ const diff = typeof data.diff === 'string' ? data.diff : ''
300
+ setChanges(diff)
301
+ onChangesLoaded?.(Boolean(diff.trim()))
302
+ } catch (error) {
303
+ setChangesError(error instanceof Error ? error.message : String(error))
304
+ setChanges('')
305
+ onChangesLoaded?.(false)
306
+ } finally {
307
+ setChangesLoading(false)
308
+ }
309
+ }
310
+
311
+ async function loadFiles(targetTaskId) {
312
+ setFilesLoading(true)
313
+ setFilesError('')
314
+
315
+ try {
316
+ const data = await fetchTaskOutputFiles(targetTaskId)
317
+ const nextFiles = Array.isArray(data.files) ? data.files : []
318
+ setFiles(nextFiles)
319
+ onFilesLoaded?.(nextFiles.length > 0)
320
+ } catch (error) {
321
+ setFilesError(error instanceof Error ? error.message : String(error))
322
+ setFiles([])
323
+ onFilesLoaded?.(false)
324
+ } finally {
325
+ setFilesLoading(false)
326
+ }
327
+ }
328
+
329
+ const outputDownloadUrl = selectedPath ? taskOutputDownloadUrl(taskId, selectedPath) : ''
330
+ const outputContentUrl = selectedPath ? taskOutputContentUrl(taskId, selectedPath) : ''
331
+ const runtimeLogText = useMemo(() => {
332
+ if (!Array.isArray(runtimeServiceLogs) || runtimeServiceLogs.length === 0) {
333
+ return ''
334
+ }
335
+ return runtimeServiceLogs.map((entry) => formatRuntimeLogEntry(entry)).join('')
336
+ }, [runtimeServiceLogs])
337
+
338
+ useEffect(() => {
339
+ if (!runtimeLogRef.current) {
340
+ return
341
+ }
342
+ runtimeLogRef.current.scrollTop = runtimeLogRef.current.scrollHeight
343
+ }, [runtimeLogText])
344
+
345
+ useEffect(() => {
346
+ const next = {}
347
+ testCommands.forEach((item, index) => {
348
+ next[index] = typeof item?.command === 'string' ? item.command : ''
349
+ })
350
+ setCommandDrafts(next)
351
+ }, [testCommands])
352
+
353
+ function commandDraft(index, fallback) {
354
+ const value = commandDrafts[index]
355
+ if (typeof value === 'string') {
356
+ return value
357
+ }
358
+ return fallback
359
+ }
360
+
361
+ async function commitCommandEdit(index, fallbackCommand) {
362
+ const current = typeof fallbackCommand === 'string' ? fallbackCommand.trim() : ''
363
+ const nextValue = commandDraft(index, fallbackCommand).trim()
364
+ if (!nextValue || nextValue === current) {
365
+ setCommandDrafts((prev) => ({
366
+ ...prev,
367
+ [index]: current,
368
+ }))
369
+ return
370
+ }
371
+
372
+ setSavingCommandIndex(index)
373
+ try {
374
+ await onUpdateTestCommand?.(index, nextValue)
375
+ setEditingCommandIndex((current) => (current === index ? -1 : current))
376
+ } finally {
377
+ setSavingCommandIndex(-1)
378
+ }
379
+ }
380
+
381
+ return (
382
+ <div className="output-panel">
383
+ <div className="output-tabs">
384
+ <button
385
+ className={`output-tab-btn ${tab === 'outputs' ? 'active' : ''}`}
386
+ onClick={() => setTab('outputs')}
387
+ >
388
+ Outputs
389
+ </button>
390
+ <button
391
+ className={`output-tab-btn ${tab === 'changes' ? 'active' : ''}`}
392
+ onClick={() => setTab('changes')}
393
+ >
394
+ Changes
395
+ </button>
396
+ <button
397
+ className={`output-tab-btn ${tab === 'plan' ? 'active' : ''}`}
398
+ onClick={() => setTab('plan')}
399
+ >
400
+ Plan
401
+ </button>
402
+ {testingConfig && (
403
+ <button
404
+ className={`output-tab-btn ${tab === 'testing' ? 'active' : ''}`}
405
+ onClick={() => setTab('testing')}
406
+ >
407
+ Testing
408
+ </button>
409
+ )}
410
+ </div>
411
+
412
+ {tab === 'changes' && (
413
+ <div className="output-tab-body">
414
+ {changesLoading && <div className="muted">Loading diff...</div>}
415
+ {changesError && <div className="error">Error: {changesError}</div>}
416
+ {!changesLoading && !changesError && <DiffViewer diff={changes} />}
417
+ </div>
418
+ )}
419
+
420
+ {tab === 'outputs' && (
421
+ <div className="output-tab-body output-outputs-layout">
422
+ {testCommandsLoading && <div className="muted">Loading test commands...</div>}
423
+ {!testCommandsLoading && testCommands.length > 0 && (
424
+ <div className="output-run-card">
425
+ <div className="output-run-list">
426
+ {testCommands.map((item, index) => {
427
+ const command = typeof item?.command === 'string' ? item.command : ''
428
+ const cwd = typeof item?.cwd === 'string' ? item.cwd : ''
429
+ const runKey = `${cwd}\n${command}`
430
+ const label = typeof item?.label === 'string' && item.label.trim()
431
+ ? item.label.trim()
432
+ : command || `Test ${index + 1}`
433
+ if (!command || !cwd) {
434
+ return null
435
+ }
436
+ return (
437
+ <div key={`${runKey}-${index}`} className="output-run-item">
438
+ <button
439
+ className="output-run-btn"
440
+ title={`${command}\nCWD: ${cwd}`}
441
+ onClick={() => {
442
+ void onRunTestCommand?.({
443
+ ...item,
444
+ command,
445
+ })
446
+ }}
447
+ disabled={
448
+ Boolean(runningTestCommand) ||
449
+ savingCommandIndex === index ||
450
+ editingCommandIndex === index
451
+ }
452
+ >
453
+ {runningTestCommand === runKey ? `Running: ${label}...` : `Run: ${label}`}
454
+ </button>
455
+ {editingCommandIndex === index ? (
456
+ <>
457
+ <input
458
+ className="output-run-command-input"
459
+ value={commandDraft(index, command)}
460
+ title={`${command}\nCWD: ${cwd}`}
461
+ onChange={(event) => {
462
+ const value = event.target.value
463
+ setCommandDrafts((prev) => ({
464
+ ...prev,
465
+ [index]: value,
466
+ }))
467
+ }}
468
+ onKeyDown={(event) => {
469
+ if (event.key === 'Escape') {
470
+ event.preventDefault()
471
+ setCommandDrafts((prev) => ({
472
+ ...prev,
473
+ [index]: command,
474
+ }))
475
+ setEditingCommandIndex(-1)
476
+ }
477
+ }}
478
+ disabled={Boolean(runningTestCommand) || savingCommandIndex === index}
479
+ />
480
+ <button
481
+ className="output-run-save-btn"
482
+ onClick={() => {
483
+ void commitCommandEdit(index, command)
484
+ }}
485
+ disabled={Boolean(runningTestCommand) || savingCommandIndex === index}
486
+ title="Save command"
487
+ >
488
+
489
+ </button>
490
+ </>
491
+ ) : (
492
+ <>
493
+ <span className="output-run-command" title={`${command}\nCWD: ${cwd}`}>
494
+ {command}
495
+ </span>
496
+ <button
497
+ className="output-run-edit-btn"
498
+ onClick={() => {
499
+ setCommandDrafts((prev) => ({
500
+ ...prev,
501
+ [index]: command,
502
+ }))
503
+ setEditingCommandIndex(index)
504
+ }}
505
+ disabled={Boolean(runningTestCommand) || savingCommandIndex === index}
506
+ title="Edit command"
507
+ >
508
+ Edit
509
+ </button>
510
+ </>
511
+ )}
512
+ </div>
513
+ )
514
+ })}
515
+ </div>
516
+ </div>
517
+ )}
518
+ {testCommandsError && <div className="error">Error: {testCommandsError}</div>}
519
+
520
+ {runtimeService && runtimeService.task_id === taskId && (
521
+ <div className="output-runtime-card">
522
+ <div className="output-runtime-header">
523
+ <div className="output-runtime-title-wrap">
524
+ <div className="output-runtime-title">{runtimeService.label || runtimeService.command}</div>
525
+ <div className="output-runtime-meta">
526
+ {runtimeService.status} · {runtimeService.cwd}
527
+ </div>
528
+ </div>
529
+ {runtimeService.status === 'running' && (
530
+ <button
531
+ className="output-runtime-stop"
532
+ onClick={() => onStopRuntimeService?.(runtimeService.id)}
533
+ >
534
+ Stop
535
+ </button>
536
+ )}
537
+ </div>
538
+ {runtimeServiceError && <div className="error">Error: {runtimeServiceError}</div>}
539
+ <pre className="output-runtime-log" ref={runtimeLogRef}>
540
+ {runtimeLogText || 'No terminal output yet.'}
541
+ </pre>
542
+ </div>
543
+ )}
544
+
545
+ {filesLoading && <div className="muted">Loading files...</div>}
546
+ {filesError && <div className="error">Error: {filesError}</div>}
547
+
548
+ {!filesLoading && !filesError && files.length > 0 && (
549
+ <>
550
+ <div className="output-file-grid">
551
+ {files.map((file) => {
552
+ const Icon = outputIcon(file.path)
553
+ return (
554
+ <button
555
+ key={file.path}
556
+ className={`output-file-tile ${selectedPath === file.path ? 'active' : ''}`}
557
+ onClick={() => setSelectedPath(file.path)}
558
+ title={file.path}
559
+ >
560
+ <span className="output-file-tile-icon">
561
+ <Icon size={24} strokeWidth={1.9} />
562
+ </span>
563
+ <span className="output-file-tile-name">{fileName(file.path)}</span>
564
+ <span className="output-file-tile-size">{formatBytes(file.size)}</span>
565
+ </button>
566
+ )
567
+ })}
568
+ </div>
569
+
570
+ {selectedFile && (
571
+ <div className="output-preview-card">
572
+ <div className="output-preview-header">
573
+ <div className="output-preview-path">{selectedFile.path}</div>
574
+ <div className="output-preview-actions">
575
+ <a className="output-download-btn" href={outputDownloadUrl}>
576
+ Save to Downloads
577
+ </a>
578
+ </div>
579
+ </div>
580
+
581
+ {previewLoading && <div className="muted">Loading preview...</div>}
582
+ {previewError && <div className="error">Error: {previewError}</div>}
583
+
584
+ {!previewLoading && !previewError && selectedKind === 'markdown' && (
585
+ <div className="output-markdown-preview">
586
+ <ReactMarkdown remarkPlugins={[remarkGfm]}>{previewText}</ReactMarkdown>
587
+ </div>
588
+ )}
589
+
590
+ {!previewLoading && !previewError && selectedKind === 'html' && (
591
+ <iframe className="output-html-preview" src={outputContentUrl} title={selectedFile.path} />
592
+ )}
593
+
594
+ {!previewLoading && !previewError && selectedKind === 'image' && (
595
+ <div className="output-image-wrap">
596
+ <img src={outputContentUrl} alt={selectedFile.path} className="output-image-preview" />
597
+ </div>
598
+ )}
599
+
600
+ {!previewLoading && !previewError && selectedKind === 'csv' && (
601
+ <div className="output-csv-wrap">
602
+ <table className="output-csv-table">
603
+ <tbody>
604
+ {parseCsv(previewText).map((row, rowIndex) => (
605
+ <tr key={`row-${rowIndex}`}>
606
+ {row.map((cell, cellIndex) => (
607
+ <td key={`cell-${rowIndex}-${cellIndex}`}>{cell}</td>
608
+ ))}
609
+ </tr>
610
+ ))}
611
+ </tbody>
612
+ </table>
613
+ </div>
614
+ )}
615
+
616
+ {!previewLoading && !previewError && selectedKind === 'download' && (
617
+ <div className="output-fallback-preview">
618
+ <div className="muted">Preview is not supported for this file type.</div>
619
+ <a className="output-download-btn" href={outputDownloadUrl}>
620
+ Save to Downloads
621
+ </a>
622
+ </div>
623
+ )}
624
+ </div>
625
+ )}
626
+ </>
627
+ )}
628
+
629
+ {!testCommandsLoading && testCommands.length === 0 && !filesLoading && files.length === 0 &&
630
+ !(runtimeService && runtimeService.task_id === taskId) && (
631
+ <div className="muted">No outputs yet.</div>
632
+ )}
633
+ </div>
634
+ )}
635
+
636
+ {tab === 'plan' && (
637
+ <div className="output-tab-body output-plan-body">
638
+ {planLoading && <div className="muted">Loading plan...</div>}
639
+ {planError && <div className="error">Error: {planError}</div>}
640
+ {!planLoading && !planError && planData && (
641
+ <div className="output-plan-markdown">
642
+ <ReactMarkdown remarkPlugins={[remarkGfm]}>{planData}</ReactMarkdown>
643
+ </div>
644
+ )}
645
+ {!planLoading && !planError && !planData && (
646
+ <div className="muted">No plan yet.</div>
647
+ )}
648
+ </div>
649
+ )}
650
+
651
+ {tab === 'testing' && testingConfig && (
652
+ <div className="output-tab-body output-testing-body">
653
+ <TestingPane taskId={taskId} config={testingConfig} />
654
+ </div>
655
+ )}
656
+ </div>
657
+ )
658
+ }