@bibobonking/opensol 0.0.0-stage → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/.github/workflows/build.yml +25 -0
  2. package/README.md +74 -2
  3. package/dist/assets/index-BmN7pBji.css +1 -0
  4. package/dist/assets/index-DgNDjkOz.js +301 -0
  5. package/dist/favicon.svg +15 -0
  6. package/dist/index.html +55 -0
  7. package/dist/preview-host.html +33 -0
  8. package/index.html +54 -0
  9. package/package.json +43 -4
  10. package/postcss.config.js +6 -0
  11. package/public/favicon.svg +15 -0
  12. package/public/preview-host.html +33 -0
  13. package/shared/plugin-builder.test.mjs +43 -0
  14. package/shared/plugin-package.d.mts +34 -0
  15. package/shared/plugin-package.mjs +197 -0
  16. package/shared/plugin-package.test.mjs +94 -0
  17. package/src/App.tsx +152 -0
  18. package/src/agent/adapters/anthropic.ts +233 -0
  19. package/src/agent/adapters/gemini.ts +225 -0
  20. package/src/agent/adapters/openai-responses.ts +215 -0
  21. package/src/agent/adapters/openai.ts +200 -0
  22. package/src/agent/adapters/shared.ts +96 -0
  23. package/src/agent/approval.ts +20 -0
  24. package/src/agent/compact.ts +234 -0
  25. package/src/agent/index.ts +36 -0
  26. package/src/agent/model.ts +20 -0
  27. package/src/agent/prompt.ts +107 -0
  28. package/src/agent/queue.test.mjs +111 -0
  29. package/src/agent/queue.ts +84 -0
  30. package/src/agent/subagent.ts +109 -0
  31. package/src/agent/tools.ts +541 -0
  32. package/src/components/ChatView.tsx +403 -0
  33. package/src/components/CommandPalette.tsx +416 -0
  34. package/src/components/Composer.tsx +678 -0
  35. package/src/components/ContextBar.tsx +58 -0
  36. package/src/components/DeployModal.tsx +512 -0
  37. package/src/components/ErrorBoundary.tsx +75 -0
  38. package/src/components/HomeDashboard.tsx +189 -0
  39. package/src/components/Logo.tsx +41 -0
  40. package/src/components/Markdown.tsx +232 -0
  41. package/src/components/MessageItem.tsx +655 -0
  42. package/src/components/ModelPicker.tsx +487 -0
  43. package/src/components/PixleSol.tsx +243 -0
  44. package/src/components/PluginBuilder.tsx +285 -0
  45. package/src/components/PluginManager.tsx +341 -0
  46. package/src/components/PreviewPane.tsx +166 -0
  47. package/src/components/SettingsModal.tsx +2052 -0
  48. package/src/components/Sidebar.tsx +504 -0
  49. package/src/components/Splash.tsx +104 -0
  50. package/src/components/ToolCallCard.tsx +177 -0
  51. package/src/components/TopBar.tsx +182 -0
  52. package/src/components/TrajectoryView.tsx +178 -0
  53. package/src/components/WorkspacePanel.tsx +710 -0
  54. package/src/components/ui.tsx +311 -0
  55. package/src/core/kernel.ts +45 -0
  56. package/src/core/plugins/tools/fs.ts +219 -0
  57. package/src/core/plugins/tools/git.ts +83 -0
  58. package/src/core/plugins/tools/grep.ts +98 -0
  59. package/src/core/plugins/tools/shell.ts +88 -0
  60. package/src/core/plugins/tools/subagent.ts +36 -0
  61. package/src/core/plugins/tools/web.ts +97 -0
  62. package/src/core/services/agent-loop.ts +417 -0
  63. package/src/core/services/approval.ts +66 -0
  64. package/src/core/services/compaction.ts +45 -0
  65. package/src/core/services/llm.ts +32 -0
  66. package/src/core/services/sessions.ts +47 -0
  67. package/src/core/services/subagents.ts +15 -0
  68. package/src/core/services/system-prompt.ts +177 -0
  69. package/src/core/services/tools.ts +135 -0
  70. package/src/core/services/ui.ts +109 -0
  71. package/src/core/types.ts +139 -0
  72. package/src/data/providers.ts +125 -0
  73. package/src/data/skills.ts +68 -0
  74. package/src/index.css +776 -0
  75. package/src/lib/boot.ts +94 -0
  76. package/src/lib/context.ts +75 -0
  77. package/src/lib/conversation.test.mjs +64 -0
  78. package/src/lib/conversation.ts +59 -0
  79. package/src/lib/daemon.ts +475 -0
  80. package/src/lib/hooks.ts +33 -0
  81. package/src/lib/permissions.ts +60 -0
  82. package/src/lib/pixlesol.test.mjs +51 -0
  83. package/src/lib/pixlesol.ts +55 -0
  84. package/src/lib/plugin-folder.test.mjs +145 -0
  85. package/src/lib/plugin-folder.ts +96 -0
  86. package/src/lib/reasoning.ts +87 -0
  87. package/src/lib/sse.ts +75 -0
  88. package/src/lib/themes.test.mjs +72 -0
  89. package/src/lib/themes.ts +52 -0
  90. package/src/lib/usage.test.mjs +72 -0
  91. package/src/lib/usage.ts +155 -0
  92. package/src/lib/utils.ts +73 -0
  93. package/src/main.tsx +16 -0
  94. package/src/plugins/api.test.mjs +77 -0
  95. package/src/plugins/api.ts +113 -0
  96. package/src/plugins/bridge.d.mts +1 -0
  97. package/src/plugins/bridge.mjs +26 -0
  98. package/src/plugins/bridge.test.mjs +17 -0
  99. package/src/plugins/index.ts +52 -0
  100. package/src/plugins/preview.test.mjs +18 -0
  101. package/src/plugins/preview.ts +34 -0
  102. package/src/plugins/runtime.ts +194 -0
  103. package/src/plugins/types.ts +40 -0
  104. package/src/store/chats.ts +278 -0
  105. package/src/store/models.ts +120 -0
  106. package/src/store/settings.ts +445 -0
  107. package/src/store/ui.ts +132 -0
  108. package/src/store/workspace.ts +299 -0
  109. package/src/types.ts +337 -0
  110. package/src/vite-env.d.ts +1 -0
  111. package/tailwind.config.js +83 -0
  112. package/tsconfig.json +22 -0
  113. package/tsconfig.tsbuildinfo +1 -0
  114. package/vite.config.ts +19 -0
@@ -0,0 +1,655 @@
1
+ import { useEffect, useRef, useState } from 'react'
2
+ import {
3
+ Pencil,
4
+ AlertCircle,
5
+ Check,
6
+ Copy,
7
+ FileText,
8
+ RefreshCw,
9
+ Settings2,
10
+ Square,
11
+ } from 'lucide-react'
12
+ import type { AssistantMessage, ToolMessage, UserMessage } from '../types'
13
+ import type { ConversationTurn } from '../lib/conversation'
14
+ import { useUi } from '../store/ui'
15
+ import { useSettings } from '../store/settings'
16
+ import { useModelCatalog } from '../store/models'
17
+ import { regenerateLastTurn, runAgent } from '../agent'
18
+ import { restoreCheckpoint } from '../lib/daemon'
19
+ import { useWorkspace } from '../store/workspace'
20
+ import { createId, useChats } from '../store/chats'
21
+ import { toolLabel } from '../agent/tools'
22
+ import { formatBytes, cn } from '../lib/utils'
23
+ import { Markdown } from './Markdown'
24
+ import { ToolCallCard } from './ToolCallCard'
25
+ import { ArrowRight } from 'lucide-react'
26
+ import { LogoMark } from './Logo'
27
+
28
+ function formatTokens(value?: number) {
29
+ if (!value) return ''
30
+ return value >= 1000 ? `${(value / 1000).toFixed(1)}k` : String(value)
31
+ }
32
+
33
+ function UserBubble({
34
+ message,
35
+ sessionId,
36
+ canEdit,
37
+ }: {
38
+ message: UserMessage
39
+ sessionId: string
40
+ canEdit: boolean
41
+ }) {
42
+ const [editing, setEditing] = useState(false)
43
+ const [draft, setDraft] = useState(message.content)
44
+
45
+ const resend = () => {
46
+ const text = draft.trim()
47
+ if (!text) return
48
+ const session = useChats.getState().sessions.find((item) => item.id === sessionId)
49
+ const index = session?.messages.findIndex((item) => item.id === message.id) ?? -1
50
+ if (!session || index < 0) return
51
+ const id = createId('msg')
52
+ // Everything after the edited message belonged to the old answer, so it is dropped.
53
+ useChats.getState().updateSession(sessionId, { messages: session.messages.slice(0, index) })
54
+ useChats.getState().addMessage(sessionId, { ...message, id, content: text, createdAt: Date.now() })
55
+ setEditing(false)
56
+ runAgent(sessionId, id)
57
+ }
58
+
59
+ const images = (message.attachments ?? []).filter((attachment) => attachment.kind === 'image')
60
+ const files = (message.attachments ?? []).filter((attachment) => attachment.kind === 'text')
61
+ if (editing) {
62
+ return (
63
+ <div className="flex justify-end">
64
+ <div className="w-full max-w-[88%] rounded-2xl border border-accent/40 bg-canvas p-2.5 sm:max-w-[82%]">
65
+ <textarea
66
+ value={draft}
67
+ onChange={(event) => setDraft(event.target.value)}
68
+ onKeyDown={(event) => {
69
+ if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) {
70
+ event.preventDefault()
71
+ resend()
72
+ }
73
+ if (event.key === 'Escape') setEditing(false)
74
+ }}
75
+ autoFocus
76
+ rows={Math.min(10, Math.max(2, draft.split('\n').length))}
77
+ className="chat-text block w-full resize-none bg-transparent px-2 py-1 leading-[1.65] text-ink outline-none"
78
+ aria-label="Edit message"
79
+ />
80
+ <div className="mt-1 flex items-center justify-between gap-2 px-1">
81
+ <p className="text-[11.5px] text-faint">Sending replaces the reply below.</p>
82
+ <div className="flex items-center gap-1.5">
83
+ <button
84
+ type="button"
85
+ onClick={() => setEditing(false)}
86
+ className="h-8 rounded-full px-3 text-[12.5px] text-muted transition-colors hover:text-ink"
87
+ >
88
+ Cancel
89
+ </button>
90
+ <button
91
+ type="button"
92
+ onClick={resend}
93
+ disabled={!draft.trim()}
94
+ className="h-8 rounded-full bg-accent px-3.5 text-[12.5px] font-medium text-on-accent transition hover:brightness-110 disabled:opacity-40"
95
+ >
96
+ Send
97
+ </button>
98
+ </div>
99
+ </div>
100
+ </div>
101
+ </div>
102
+ )
103
+ }
104
+
105
+ return (
106
+ <div className="group/user flex items-start justify-end gap-1.5">
107
+ {canEdit && message.content && (
108
+ <button
109
+ type="button"
110
+ onClick={() => {
111
+ setDraft(message.content)
112
+ setEditing(true)
113
+ }}
114
+ className="mt-2 inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-lg text-faint opacity-0 transition hover:bg-raised hover:text-ink focus-visible:opacity-100 group-hover/user:opacity-100"
115
+ title="Edit and resend"
116
+ aria-label="Edit and resend message"
117
+ >
118
+ <Pencil size={14} strokeWidth={1.7} />
119
+ </button>
120
+ )}
121
+ <div className="max-w-[88%] sm:max-w-[82%]">
122
+ {images.length > 0 && (
123
+ <div className={cn('mb-2 grid gap-2', images.length > 1 ? 'grid-cols-2' : 'grid-cols-1')}>
124
+ {images.map((image) => (
125
+ <img
126
+ key={image.id}
127
+ src={image.dataUrl}
128
+ alt={image.name}
129
+ className="max-h-52 w-full rounded-xl border border-line object-cover"
130
+ />
131
+ ))}
132
+ </div>
133
+ )}
134
+ <div className="rounded-2xl rounded-br-md border border-line/70 bg-canvas px-4 py-2.5">
135
+ {files.length > 0 && (
136
+ <div className="mb-2 flex flex-wrap gap-1.5">
137
+ {files.map((file) => (
138
+ <span
139
+ key={file.id}
140
+ className="inline-flex items-center gap-1.5 rounded-md border border-line bg-panel px-2 py-1 text-[10.5px] text-muted"
141
+ >
142
+ <FileText size={11} />
143
+ <span className="max-w-[180px] truncate">{file.name}</span>
144
+ <span className="text-faint">{formatBytes(file.size)}</span>
145
+ </span>
146
+ ))}
147
+ </div>
148
+ )}
149
+ {message.content && (
150
+ <p className="chat-text whitespace-pre-wrap break-words leading-[1.65] text-ink">{message.content}</p>
151
+ )}
152
+ </div>
153
+ </div>
154
+ </div>
155
+ )
156
+ }
157
+
158
+ const FILE_EDIT_TOOLS = new Set(['write_file', 'rewrite_file', 'edit_file'])
159
+
160
+ function changedFiles(tools: ToolMessage[]): string[] {
161
+ const seen = new Set<string>()
162
+ for (const tool of tools) {
163
+ if (tool.status !== 'done' || !FILE_EDIT_TOOLS.has(tool.call.name)) continue
164
+ const path = tool.call.args.path
165
+ if (typeof path === 'string' && path) seen.add(path)
166
+ }
167
+ return [...seen]
168
+ }
169
+
170
+ /** Lets the person roll back every file the agent wrote or edited during one turn. */
171
+ function UndoBar({ turnId, files }: { turnId: string; files: string[] }) {
172
+ const askConfirm = useUi((state) => state.askConfirm)
173
+ const notify = useUi((state) => state.notify)
174
+ const [state, setState] = useState<'idle' | 'working' | 'undone'>('idle')
175
+ const shown = files.slice(0, 2).join(', ')
176
+ const rest = files.length - 2
177
+
178
+ const undo = async () => {
179
+ const confirmed = await askConfirm({
180
+ title: 'Undo these file changes?',
181
+ body: `${files.length === 1 ? 'This file goes' : `These ${files.length} files go`} back to how ${files.length === 1 ? 'it was' : 'they were'} before this answer. Changes made through shell commands are not affected.`,
182
+ confirmLabel: 'Undo changes',
183
+ })
184
+ if (!confirmed) return
185
+ setState('working')
186
+ try {
187
+ const result = await restoreCheckpoint(turnId)
188
+ const parts = [
189
+ result.restored.length ? `restored ${result.restored.length}` : '',
190
+ result.removed.length ? `removed ${result.removed.length}` : '',
191
+ result.skipped.length ? `${result.skipped.length} too large to restore` : '',
192
+ ].filter(Boolean)
193
+ notify(`Undone: ${parts.join(', ') || 'nothing to change'}.`, 'success')
194
+ setState('undone')
195
+ await useWorkspace.getState().refresh().catch(() => undefined)
196
+ } catch (error) {
197
+ setState('idle')
198
+ notify(error instanceof Error ? error.message : 'Could not undo those changes.', 'error')
199
+ }
200
+ }
201
+
202
+ return (
203
+ <p className="flex items-baseline gap-2 text-[12.5px] text-muted">
204
+ <span className="min-w-0 truncate">
205
+ {state === 'undone' ? 'Changes undone' : `Changed ${shown}${rest > 0 ? ` and ${rest} more` : ''}`}
206
+ </span>
207
+ {state !== 'undone' && (
208
+ <button
209
+ type="button"
210
+ onClick={() => void undo()}
211
+ disabled={state === 'working'}
212
+ className="shrink-0 font-medium text-ink underline decoration-ink/30 underline-offset-[3px] transition-colors hover:decoration-ink disabled:opacity-50"
213
+ >
214
+ {state === 'working' ? 'Undoing…' : 'Undo'}
215
+ </button>
216
+ )}
217
+ </p>
218
+ )
219
+ }
220
+
221
+ export function TaskCompletedBox({
222
+ tools,
223
+ intermediateMessages = [],
224
+ streaming = false,
225
+ }: {
226
+ tools: ToolMessage[]
227
+ intermediateMessages?: AssistantMessage[]
228
+ streaming?: boolean
229
+ }) {
230
+ const [open, setOpen] = useState(false)
231
+ const autoOpened = useRef(false)
232
+ const autoCollapse = useSettings((state) => state.autoCollapseWork)
233
+ const detailed = useSettings((state) => state.workDetail === 'detailed')
234
+
235
+ const awaiting = tools.some((tool) => tool.status === 'awaiting')
236
+ const active = streaming || tools.some((tool) => tool.status === 'running')
237
+
238
+ // Auto-open when awaiting approval or actively running
239
+ useEffect(() => {
240
+ if (awaiting) {
241
+ setOpen(true)
242
+ } else if (active) {
243
+ autoOpened.current = true
244
+ setOpen(true)
245
+ } else if (autoOpened.current && autoCollapse) {
246
+ autoOpened.current = false
247
+ setOpen(false)
248
+ }
249
+ }, [active, awaiting, autoCollapse])
250
+
251
+ if (tools.length === 0 && intermediateMessages.length === 0) return null
252
+
253
+ const labels = [...new Set(tools.map((tool) => toolLabel(tool.call.name)))]
254
+ const failed = tools.filter((tool) => tool.status === 'error' || tool.status === 'denied').length
255
+ const summary = labels.length <= 3 ? labels.join(' · ') : `${labels.slice(0, 3).join(' · ')} +${labels.length - 3}`
256
+
257
+ const taskItems = [
258
+ ...tools.map((t) => ({ kind: 'tool' as const, tool: t, time: t.createdAt })),
259
+ ...intermediateMessages.map((m) => ({ kind: 'assistant' as const, message: m, time: m.createdAt })),
260
+ ].sort((a, b) => a.time - b.time)
261
+
262
+ return (
263
+ <div className="my-1">
264
+ <button
265
+ type="button"
266
+ onClick={() => {
267
+ autoOpened.current = false
268
+ setOpen((value) => !value)
269
+ }}
270
+ className="group/task flex max-w-full items-baseline gap-1.5 py-0.5 text-left text-[13px]"
271
+ aria-expanded={open}
272
+ >
273
+ <ArrowRight
274
+ size={13}
275
+ strokeWidth={2}
276
+ className={cn(
277
+ 'relative top-[1px] shrink-0 self-center text-faint transition-transform duration-200 ease-out group-hover/task:text-ink',
278
+ open && 'rotate-90',
279
+ )}
280
+ />
281
+ <span
282
+ className={cn(
283
+ 'shrink-0 font-medium transition-colors',
284
+ awaiting ? 'text-warning' : active ? 'text-shimmer' : 'text-ink/80 group-hover/task:text-ink',
285
+ )}
286
+ >
287
+ {awaiting ? 'Action required' : active ? 'Running task…' : 'Task completed'}
288
+ </span>
289
+ <span className="min-w-0 truncate text-[12px] text-faint">
290
+ {tools.length > 0 && `Used ${tools.length} tool${tools.length === 1 ? '' : 's'}`}
291
+ {summary ? ` (${summary})` : ''}
292
+ </span>
293
+ {failed > 0 && <span className="shrink-0 text-[12px] text-danger">· {failed} failed</span>}
294
+ </button>
295
+
296
+ {open && (
297
+ <div className="animate-fade space-y-0.5 pb-1 pl-[19px] pt-1">
298
+ {taskItems.map((item) =>
299
+ item.kind === 'tool' ? (
300
+ <ToolCallCard key={item.tool.id} message={item.tool} compact={!detailed} />
301
+ ) : (
302
+ <div key={item.message.id} className="py-1">
303
+ {item.message.reasoning.trim() && (
304
+ <div className="thin-scroll max-h-40 overflow-y-auto whitespace-pre-wrap font-mono text-[11px] leading-relaxed text-faint">
305
+ {item.message.reasoning}
306
+ </div>
307
+ )}
308
+ {item.message.content.trim() && (
309
+ <div className="mt-1 text-[12.5px] leading-relaxed text-muted">
310
+ <Markdown content={item.message.content} />
311
+ </div>
312
+ )}
313
+ </div>
314
+ ),
315
+ )}
316
+ </div>
317
+ )}
318
+ </div>
319
+ )
320
+ }
321
+
322
+ export function UsedGroup({ tools, streaming = false }: { tools: ToolMessage[]; streaming?: boolean }) {
323
+ return <TaskCompletedBox tools={tools} streaming={streaming} />
324
+ }
325
+
326
+ export function AssistantBlock({
327
+ message,
328
+ sessionId,
329
+ streaming,
330
+ isLastAssistant,
331
+ showHeader,
332
+ turnUsage,
333
+ }: {
334
+ message: AssistantMessage
335
+ sessionId: string
336
+ streaming: boolean
337
+ isLastAssistant: boolean
338
+ /** Summed token usage of every round in the turn (shown on the final answer). */
339
+ turnUsage?: { input: number; output: number }
340
+ /** Only the first answer of a turn repeats the assistant header. */
341
+ showHeader: boolean
342
+ }) {
343
+ const notify = useUi((state) => state.notify)
344
+ const openSettings = useUi((state) => state.openSettings)
345
+ const showTokenEstimate = useSettings((state) => state.showTokenEstimate)
346
+ const providerModels = useModelCatalog((state) =>
347
+ message.provider ? state.byProvider[message.provider] : undefined,
348
+ )
349
+ const [copied, setCopied] = useState(false)
350
+ const [reasoningOpen, setReasoningOpen] = useState(streaming)
351
+ const hasBody = Boolean(message.content.trim() || message.reasoning.trim() || message.error)
352
+ const modelName =
353
+ providerModels?.find((model) => model.id === message.model)?.name ?? message.model ?? ''
354
+ const needsConfig = Boolean(message.error && /api key|base url|settings|credential|403|401/i.test(message.error))
355
+ const tokenBits: string[] = []
356
+ const usedInput = turnUsage?.input || message.usage?.input
357
+ const usedOutput = turnUsage?.output || message.usage?.output
358
+ if (usedInput) tokenBits.push(`${formatTokens(usedInput)} in`)
359
+ if (usedOutput) tokenBits.push(`${formatTokens(usedOutput)} out`)
360
+ else if (showTokenEstimate && message.content.trim()) {
361
+ tokenBits.push(`~${formatTokens(Math.ceil(message.content.length / 4))} out`)
362
+ }
363
+ const metaLine = [
364
+ modelName,
365
+ ...tokenBits,
366
+ message.durationMs ? `${(message.durationMs / 1000).toFixed(1)}s` : '',
367
+ ]
368
+ .filter(Boolean)
369
+ .join(' · ')
370
+
371
+ useEffect(() => {
372
+ if (streaming) setReasoningOpen(true)
373
+ }, [streaming])
374
+
375
+ const copy = async () => {
376
+ try {
377
+ await navigator.clipboard.writeText(message.content)
378
+ setCopied(true)
379
+ window.setTimeout(() => setCopied(false), 1600)
380
+ } catch {
381
+ notify('Clipboard access was blocked by the browser.', 'error')
382
+ }
383
+ }
384
+
385
+ if (!hasBody && !streaming) {
386
+ return (
387
+ <div className="group">
388
+ <div className="min-w-0">
389
+ {showHeader && (
390
+ <div className="flex items-center gap-2 text-[12px] font-semibold text-ink">
391
+ OpenSol
392
+ {modelName && <span className="font-normal text-faint">{modelName}</span>}
393
+ </div>
394
+ )}
395
+ <p className="mt-2 text-[13px] text-muted">The model returned an empty response.</p>
396
+ {isLastAssistant && (
397
+ <button
398
+ type="button"
399
+ onClick={() => regenerateLastTurn(sessionId)}
400
+ className="mt-2 inline-flex items-center gap-1.5 text-[11.5px] font-medium text-accent hover:underline"
401
+ >
402
+ <RefreshCw size={12} /> Try again
403
+ </button>
404
+ )}
405
+ </div>
406
+ </div>
407
+ )
408
+ }
409
+
410
+ return (
411
+ <div className="group">
412
+ <div className="min-w-0">
413
+ {(showHeader || message.stopped) && (
414
+ <div className="flex min-h-[22px] items-center gap-1.5">
415
+ {showHeader && (
416
+ <>
417
+ <LogoMark size={15} />
418
+ <span className="text-[12px] font-medium text-muted">OpenSol</span>
419
+ {modelName && <span className="truncate text-[11.5px] text-faint">{modelName}</span>}
420
+ </>
421
+ )}
422
+ {message.stopped && (
423
+ <span className="inline-flex items-center gap-1 rounded-full border border-line px-1.5 py-0.5 text-[9.5px] font-medium text-muted">
424
+ <Square size={7} fill="currentColor" />
425
+ Stopped
426
+ </span>
427
+ )}
428
+ </div>
429
+ )}
430
+
431
+ {message.reasoning.trim() && (
432
+ <div className="mt-1">
433
+ <button
434
+ type="button"
435
+ onClick={() => setReasoningOpen((value) => !value)}
436
+ className="group/think flex max-w-full items-baseline gap-1.5 py-0.5 text-left text-[12.5px]"
437
+ aria-expanded={reasoningOpen}
438
+ >
439
+ <ArrowRight
440
+ size={12}
441
+ strokeWidth={2}
442
+ className={cn(
443
+ 'relative top-[1px] shrink-0 self-center text-faint transition-transform duration-200 ease-out group-hover/think:text-ink',
444
+ reasoningOpen && 'rotate-90',
445
+ )}
446
+ />
447
+ <span className={cn('shrink-0 font-medium', streaming ? 'text-shimmer' : 'text-muted group-hover/think:text-ink')}>
448
+ {streaming ? 'Thinking…' : 'Thought'}
449
+ </span>
450
+ {!reasoningOpen && (
451
+ <span className="min-w-0 truncate text-[11.5px] text-faint">
452
+ {message.reasoning.replace(/\s+/g, ' ').trim().slice(0, 120)}
453
+ </span>
454
+ )}
455
+ </button>
456
+ {reasoningOpen && (
457
+ <div className="thin-scroll animate-fade max-h-72 overflow-y-auto whitespace-pre-wrap break-words py-1 pl-[18px] font-mono text-[11px] leading-[1.65] text-faint">
458
+ {message.reasoning}
459
+ </div>
460
+ )}
461
+ </div>
462
+ )}
463
+
464
+ {message.content && (
465
+ <div className="mt-1.5">
466
+ <Markdown content={message.content} streaming={streaming} />
467
+ </div>
468
+ )}
469
+
470
+ {streaming && !message.content && !message.reasoning && (
471
+ <div className="mt-2 flex items-center gap-2 text-[13px] text-muted">
472
+ <span className="flex items-center gap-1">
473
+ <span className="h-1.5 w-1.5 animate-breathe rounded-full bg-accent" />
474
+ <span className="h-1.5 w-1.5 animate-breathe rounded-full bg-accent [animation-delay:160ms]" />
475
+ <span className="h-1.5 w-1.5 animate-breathe rounded-full bg-accent [animation-delay:320ms]" />
476
+ </span>
477
+ Working
478
+ </div>
479
+ )}
480
+
481
+ {message.error && (
482
+ <div className="mt-3 rounded-xl border border-danger/25 bg-danger/[0.045] px-3.5 py-3">
483
+ <div className="flex items-start gap-2">
484
+ <AlertCircle size={15} className="mt-0.5 shrink-0 text-danger" />
485
+ <div className="min-w-0 flex-1">
486
+ <p className="text-[12px] font-medium text-ink">The request did not finish</p>
487
+ <p className="mt-1 whitespace-pre-wrap text-[11.5px] leading-relaxed text-muted">{message.error}</p>
488
+ <div className="mt-2.5 flex flex-wrap items-center gap-2">
489
+ {needsConfig && (
490
+ <button
491
+ type="button"
492
+ onClick={() => openSettings('models')}
493
+ className="inline-flex h-7 items-center gap-1.5 rounded-lg border border-line bg-panel px-2.5 text-[11.5px] font-medium text-ink hover:bg-raised"
494
+ >
495
+ <Settings2 size={12} />
496
+ Open model settings
497
+ </button>
498
+ )}
499
+ {isLastAssistant && (
500
+ <button
501
+ type="button"
502
+ onClick={() => regenerateLastTurn(sessionId)}
503
+ className="inline-flex h-7 items-center gap-1.5 rounded-lg border border-line bg-panel px-2.5 text-[11.5px] font-medium text-ink hover:bg-raised"
504
+ >
505
+ <RefreshCw size={12} />
506
+ Try again
507
+ </button>
508
+ )}
509
+ </div>
510
+ </div>
511
+ </div>
512
+ </div>
513
+ )}
514
+
515
+ {hasBody && !streaming && (
516
+ <div className="mt-2 flex min-h-[28px] flex-wrap items-center gap-0.5 text-[11.5px] text-faint">
517
+ {message.content && (
518
+ <button
519
+ type="button"
520
+ onClick={copy}
521
+ className="inline-flex h-7 w-7 items-center justify-center rounded-lg transition-colors hover:bg-raised hover:text-ink"
522
+ aria-label="Copy response"
523
+ title={copied ? 'Copied' : 'Copy'}
524
+ >
525
+ {copied ? <Check size={14} className="text-success" /> : <Copy size={14} strokeWidth={1.7} />}
526
+ </button>
527
+ )}
528
+ {isLastAssistant && (
529
+ <button
530
+ type="button"
531
+ onClick={() => regenerateLastTurn(sessionId)}
532
+ className="inline-flex h-7 w-7 items-center justify-center rounded-lg transition-colors hover:bg-raised hover:text-ink"
533
+ aria-label="Regenerate response"
534
+ title="Regenerate"
535
+ >
536
+ <RefreshCw size={14} strokeWidth={1.7} />
537
+ </button>
538
+ )}
539
+ <span className="ml-2 tabular-nums text-muted">{metaLine}</span>
540
+ </div>
541
+ )}
542
+ </div>
543
+ </div>
544
+ )
545
+ }
546
+
547
+ export function TurnBlock({
548
+ turn,
549
+ sessionId,
550
+ streaming,
551
+ lastAssistantId,
552
+ activeTurn = false,
553
+ }: {
554
+ turn: ConversationTurn
555
+ sessionId: string
556
+ streaming: boolean
557
+ lastAssistantId?: string
558
+ /** True while this is the turn the agent is currently working on. */
559
+ activeTurn?: boolean
560
+ }) {
561
+ const allTools: ToolMessage[] = []
562
+ const assistantMessages: AssistantMessage[] = []
563
+
564
+ for (const segment of turn.segments) {
565
+ if (segment.kind === 'tools') {
566
+ allTools.push(...segment.tools)
567
+ } else if (segment.kind === 'assistant') {
568
+ assistantMessages.push(segment.message)
569
+ }
570
+ }
571
+
572
+ const hasTools = allTools.length > 0
573
+ const firstAssistantId = assistantMessages[0]?.id
574
+ const finalAssistant = assistantMessages[assistantMessages.length - 1] ?? null
575
+
576
+ // While the agent is still working, every step is shown inline. Nothing is
577
+ // folded into "Task completed" until the whole turn actually finishes.
578
+ if (activeTurn && hasTools) {
579
+ return (
580
+ <div className="flex flex-col gap-2.5">
581
+ {turn.user && <UserBubble message={turn.user} sessionId={sessionId} canEdit={!streaming} />}
582
+ {turn.segments.map((segment, index) =>
583
+ segment.kind === 'tools' ? (
584
+ <div key={`tools-${index}`} className="space-y-0.5">
585
+ {segment.tools.map((tool) => (
586
+ <ToolCallCard key={tool.id} message={tool} />
587
+ ))}
588
+ </div>
589
+ ) : (
590
+ <AssistantBlock
591
+ key={segment.message.id}
592
+ message={segment.message}
593
+ sessionId={sessionId}
594
+ streaming={streaming && segment.message.id === lastAssistantId}
595
+ isLastAssistant={segment.message.id === lastAssistantId}
596
+ showHeader={segment.message.id === firstAssistantId}
597
+ />
598
+ ),
599
+ )}
600
+ </div>
601
+ )
602
+ }
603
+
604
+ // Finished turn with no tools: just the conversation.
605
+ if (!hasTools) {
606
+ let seen = 0
607
+ return (
608
+ <div className="flex flex-col gap-2.5">
609
+ {turn.user && <UserBubble message={turn.user} sessionId={sessionId} canEdit={!streaming} />}
610
+ {assistantMessages.map((message) => (
611
+ <AssistantBlock
612
+ key={message.id}
613
+ message={message}
614
+ sessionId={sessionId}
615
+ streaming={streaming && message.id === lastAssistantId}
616
+ isLastAssistant={message.id === lastAssistantId}
617
+ showHeader={seen++ === 0}
618
+ />
619
+ ))}
620
+ </div>
621
+ )
622
+ }
623
+
624
+ // Finished turn: fold the tool calls and intermediate steps into one
625
+ // collapsible "Task completed" block; the final answer stays outside.
626
+ const intermediateAssistants = assistantMessages.slice(0, -1)
627
+ const turnUsage = assistantMessages.reduce(
628
+ (sum, item) => ({ input: sum.input + (item.usage?.input ?? 0), output: sum.output + (item.usage?.output ?? 0) }),
629
+ { input: 0, output: 0 },
630
+ )
631
+
632
+ return (
633
+ <div className="flex flex-col gap-2.5">
634
+ {turn.user && <UserBubble message={turn.user} sessionId={sessionId} canEdit={!streaming} />}
635
+
636
+ <TaskCompletedBox tools={allTools} intermediateMessages={intermediateAssistants} />
637
+
638
+ {finalAssistant && (
639
+ <AssistantBlock
640
+ key={finalAssistant.id}
641
+ message={finalAssistant}
642
+ sessionId={sessionId}
643
+ streaming={false}
644
+ isLastAssistant={finalAssistant.id === lastAssistantId}
645
+ showHeader={true}
646
+ turnUsage={turnUsage}
647
+ />
648
+ )}
649
+
650
+ {turn.user && changedFiles(allTools).length > 0 && (
651
+ <UndoBar turnId={turn.user.id} files={changedFiles(allTools)} />
652
+ )}
653
+ </div>
654
+ )
655
+ }