@bibobonking/opensol 3.0.3 → 3.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 (71) hide show
  1. package/.github/workflows/build.yml +60 -8
  2. package/CHANGELOG.md +18 -0
  3. package/desktop/README.md +39 -0
  4. package/desktop/loading/index.html +36 -0
  5. package/desktop/src-tauri/Cargo.lock +4301 -0
  6. package/desktop/src-tauri/Cargo.toml +23 -0
  7. package/desktop/src-tauri/binaries/.gitkeep +0 -0
  8. package/desktop/src-tauri/build.rs +3 -0
  9. package/desktop/src-tauri/capabilities/default.json +7 -0
  10. package/desktop/src-tauri/icons/128x128.png +0 -0
  11. package/desktop/src-tauri/icons/128x128@2x.png +0 -0
  12. package/desktop/src-tauri/icons/32x32.png +0 -0
  13. package/desktop/src-tauri/icons/64x64.png +0 -0
  14. package/desktop/src-tauri/icons/Square107x107Logo.png +0 -0
  15. package/desktop/src-tauri/icons/Square142x142Logo.png +0 -0
  16. package/desktop/src-tauri/icons/Square150x150Logo.png +0 -0
  17. package/desktop/src-tauri/icons/Square284x284Logo.png +0 -0
  18. package/desktop/src-tauri/icons/Square30x30Logo.png +0 -0
  19. package/desktop/src-tauri/icons/Square310x310Logo.png +0 -0
  20. package/desktop/src-tauri/icons/Square44x44Logo.png +0 -0
  21. package/desktop/src-tauri/icons/Square71x71Logo.png +0 -0
  22. package/desktop/src-tauri/icons/Square89x89Logo.png +0 -0
  23. package/desktop/src-tauri/icons/StoreLogo.png +0 -0
  24. package/desktop/src-tauri/icons/icon.icns +0 -0
  25. package/desktop/src-tauri/icons/icon.ico +0 -0
  26. package/desktop/src-tauri/icons/icon.png +0 -0
  27. package/desktop/src-tauri/src/lib.rs +210 -0
  28. package/desktop/src-tauri/src/main.rs +6 -0
  29. package/desktop/src-tauri/tauri.conf.json +48 -0
  30. package/dist/assets/index-DdI1_iPD.css +1 -0
  31. package/dist/assets/index-egG5Bgfo.js +312 -0
  32. package/dist/index.html +2 -2
  33. package/package.json +2 -1
  34. package/server/daemon.mjs +64 -2
  35. package/server/lib/mcp.mjs +532 -0
  36. package/server/lib/mcp.test.mjs +46 -0
  37. package/server/lib/store.mjs +22 -0
  38. package/server/lib/tools.mjs +59 -1
  39. package/src/agent/tools.ts +6 -5
  40. package/src/components/Composer.tsx +22 -38
  41. package/src/components/DiffView.tsx +98 -0
  42. package/src/components/McpTab.tsx +337 -0
  43. package/src/components/MessageItem.tsx +62 -13
  44. package/src/components/ModelDeck.tsx +652 -0
  45. package/src/components/SessionsTab.tsx +206 -0
  46. package/src/components/SettingsModal.tsx +106 -69
  47. package/src/components/Sidebar.tsx +78 -24
  48. package/src/components/ToolCallCard.tsx +29 -7
  49. package/src/components/WorkspacePanel.tsx +16 -1
  50. package/src/core/kernel.ts +2 -0
  51. package/src/core/mcp-tools.ts +63 -0
  52. package/src/core/plugins/tools/fs.ts +24 -3
  53. package/src/core/services/agent-loop.ts +3 -1
  54. package/src/core/services/approval.ts +2 -1
  55. package/src/core/services/titles.ts +135 -0
  56. package/src/core/services/tools.ts +11 -8
  57. package/src/core/types.ts +4 -0
  58. package/src/index.css +97 -1
  59. package/src/lib/daemon.ts +29 -0
  60. package/src/lib/diff.ts +62 -0
  61. package/src/lib/permissions.ts +6 -6
  62. package/src/store/chats.ts +9 -1
  63. package/src/store/mcp.ts +67 -0
  64. package/src/store/settings.ts +9 -6
  65. package/src/store/ui.ts +13 -0
  66. package/src/store/workspace.ts +26 -0
  67. package/src/types.ts +51 -2
  68. package/tsconfig.tsbuildinfo +1 -1
  69. package/dist/assets/index-BmN7pBji.css +0 -1
  70. package/dist/assets/index-DgNDjkOz.js +0 -301
  71. package/src/components/ModelPicker.tsx +0 -487
@@ -0,0 +1,337 @@
1
+ import { useEffect, useState } from 'react'
2
+ import { ChevronDown, Plug, Plus, RefreshCw, Trash2, Unplug, Wrench } from 'lucide-react'
3
+ import type { McpServer } from '../types'
4
+ import { useMcp } from '../store/mcp'
5
+ import { useUi } from '../store/ui'
6
+ import { cn } from '../lib/utils'
7
+ import { Segmented, Spinner, Switch } from './ui'
8
+ import type { McpServerInput } from '../lib/daemon'
9
+
10
+ const field =
11
+ 'h-9 w-full rounded-xl border border-line bg-panel px-3 text-[12px] text-ink outline-none transition-all placeholder:text-faint focus:border-accent/50 focus:shadow-[0_0_0_3px_rgb(var(--accent)/0.10)]'
12
+
13
+ const PRESETS: Array<{ label: string; draft: Draft }> = [
14
+ { label: 'Filesystem', draft: { name: 'Filesystem', transport: 'stdio', command: 'npx', args: '-y @modelcontextprotocol/server-filesystem .', env: '', url: '', headers: '' } },
15
+ { label: 'Memory', draft: { name: 'Memory', transport: 'stdio', command: 'npx', args: '-y @modelcontextprotocol/server-memory', env: '', url: '', headers: '' } },
16
+ { label: 'Fetch', draft: { name: 'Fetch', transport: 'stdio', command: 'uvx', args: 'mcp-server-fetch', env: '', url: '', headers: '' } },
17
+ ]
18
+
19
+ interface Draft {
20
+ name: string
21
+ transport: 'stdio' | 'http'
22
+ command: string
23
+ args: string
24
+ env: string
25
+ url: string
26
+ headers: string
27
+ }
28
+
29
+ const EMPTY: Draft = { name: '', transport: 'stdio', command: '', args: '', env: '', url: '', headers: '' }
30
+
31
+ /** Shell-like split that keeps quoted segments together. */
32
+ function splitArgs(value: string): string[] {
33
+ const out: string[] = []
34
+ const pattern = /"([^"]*)"|'([^']*)'|(\S+)/g
35
+ let match: RegExpExecArray | null
36
+ while ((match = pattern.exec(value))) out.push(match[1] ?? match[2] ?? match[3])
37
+ return out
38
+ }
39
+
40
+ function parsePairs(value: string, separator: string): Record<string, string> {
41
+ const out: Record<string, string> = {}
42
+ for (const line of value.split('\n')) {
43
+ const index = line.indexOf(separator)
44
+ if (index < 1) continue
45
+ out[line.slice(0, index).trim()] = line.slice(index + 1).trim()
46
+ }
47
+ return out
48
+ }
49
+
50
+ function toInput(server: McpServer): McpServerInput {
51
+ return {
52
+ id: server.id,
53
+ name: server.name,
54
+ transport: server.transport,
55
+ enabled: server.enabled,
56
+ approval: server.approval,
57
+ disabledTools: server.disabledTools,
58
+ command: server.command,
59
+ args: server.args,
60
+ env: server.env,
61
+ url: server.url,
62
+ headers: server.headers,
63
+ }
64
+ }
65
+
66
+ function StatusDot({ status }: { status: McpServer['status'] }) {
67
+ return (
68
+ <span
69
+ className={cn(
70
+ 'h-2 w-2 shrink-0 rounded-full',
71
+ status === 'connected' && 'bg-success shadow-[0_0_0_3px_rgb(var(--success)/0.18)]',
72
+ status === 'connecting' && 'animate-breathe bg-warning',
73
+ status === 'error' && 'bg-danger',
74
+ (status === 'disconnected' || status === 'disabled') && 'bg-faint/60',
75
+ )}
76
+ />
77
+ )
78
+ }
79
+
80
+ export function McpTab() {
81
+ const servers = useMcp((state) => state.servers)
82
+ const loaded = useMcp((state) => state.loaded)
83
+ const loading = useMcp((state) => state.loading)
84
+ const error = useMcp((state) => state.error)
85
+ const load = useMcp((state) => state.load)
86
+ const save = useMcp((state) => state.save)
87
+ const act = useMcp((state) => state.act)
88
+ const notify = useUi((state) => state.notify)
89
+ const askConfirm = useUi((state) => state.askConfirm)
90
+ const [draft, setDraft] = useState<Draft | null>(null)
91
+ const [open, setOpen] = useState<string | null>(null)
92
+ const [busy, setBusy] = useState(false)
93
+
94
+ useEffect(() => {
95
+ void load()
96
+ }, [load])
97
+
98
+ const persist = async (next: McpServerInput[], message?: string) => {
99
+ setBusy(true)
100
+ try {
101
+ await save(next)
102
+ if (message) notify(message, 'success')
103
+ } catch (failure) {
104
+ notify(failure instanceof Error ? failure.message : 'Could not save MCP servers.', 'error')
105
+ } finally {
106
+ setBusy(false)
107
+ }
108
+ }
109
+
110
+ const addServer = async () => {
111
+ if (!draft || !draft.name.trim()) return
112
+ const entry: McpServerInput =
113
+ draft.transport === 'stdio'
114
+ ? { name: draft.name.trim(), transport: 'stdio', command: draft.command.trim(), args: splitArgs(draft.args), env: parsePairs(draft.env, '=') }
115
+ : { name: draft.name.trim(), transport: 'http', url: draft.url.trim(), headers: parsePairs(draft.headers, ':') }
116
+ await persist([...servers.map(toInput), { ...entry, enabled: true, approval: 'ask' }], `Added ${entry.name}. Connecting…`)
117
+ setDraft(null)
118
+ }
119
+
120
+ const patch = (id: string, change: Partial<McpServerInput>) =>
121
+ persist(servers.map((server) => (server.id === id ? { ...toInput(server), ...change } : toInput(server))))
122
+
123
+ const remove = async (server: McpServer) => {
124
+ const ok = await askConfirm({
125
+ title: `Remove ${server.name}?`,
126
+ body: 'The server is disconnected and its tools disappear from the agent. Nothing on disk is deleted.',
127
+ confirmLabel: 'Remove server',
128
+ tone: 'danger',
129
+ })
130
+ if (ok) await persist(servers.filter((item) => item.id !== server.id).map(toInput), `Removed ${server.name}.`)
131
+ }
132
+
133
+ return (
134
+ <div className="space-y-5 px-5 py-6 sm:px-8">
135
+ <div className="settings-card">
136
+ <div className="flex items-start gap-3">
137
+ <span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-accent-soft text-accent">
138
+ <Plug size={16} />
139
+ </span>
140
+ <div className="min-w-0 flex-1">
141
+ <p className="settings-label">Model Context Protocol</p>
142
+ <p className="settings-hint">
143
+ Connect MCP servers and their tools show up for the agent as <span className="font-mono">mcp__server__tool</span>. Local
144
+ (stdio) servers run as a process on this computer with your permissions, so only add servers you trust.
145
+ </p>
146
+ </div>
147
+ {!draft && (
148
+ <button
149
+ type="button"
150
+ onClick={() => setDraft({ ...EMPTY })}
151
+ className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-full bg-ink px-3.5 text-[11.5px] font-medium text-panel transition-transform active:scale-95"
152
+ >
153
+ <Plus size={13} /> Add server
154
+ </button>
155
+ )}
156
+ </div>
157
+ </div>
158
+
159
+ {draft && (
160
+ <div className="settings-card animate-scalein space-y-3">
161
+ <div className="flex flex-wrap items-center gap-2">
162
+ <p className="settings-label mr-auto">New MCP server</p>
163
+ {PRESETS.map((preset) => (
164
+ <button key={preset.label} type="button" className="chip h-7 rounded-full px-3 text-[11px]" onClick={() => setDraft({ ...preset.draft })}>
165
+ {preset.label}
166
+ </button>
167
+ ))}
168
+ </div>
169
+ <div className="grid gap-3 sm:grid-cols-[1fr_auto]">
170
+ <input className={field} placeholder="Name (e.g. GitHub)" value={draft.name} onChange={(e) => setDraft({ ...draft, name: e.target.value })} />
171
+ <Segmented
172
+ options={[
173
+ { value: 'stdio', label: 'Local · stdio' },
174
+ { value: 'http', label: 'Remote · HTTP' },
175
+ ]}
176
+ value={draft.transport}
177
+ onChange={(value) => setDraft({ ...draft, transport: value })}
178
+ size="md"
179
+ />
180
+ </div>
181
+ {draft.transport === 'stdio' ? (
182
+ <>
183
+ <div className="grid gap-3 sm:grid-cols-[160px_1fr]">
184
+ <input className={cn(field, 'font-mono')} placeholder="command" value={draft.command} onChange={(e) => setDraft({ ...draft, command: e.target.value })} />
185
+ <input className={cn(field, 'font-mono')} placeholder="arguments" value={draft.args} onChange={(e) => setDraft({ ...draft, args: e.target.value })} />
186
+ </div>
187
+ <textarea
188
+ className={cn(field, 'h-20 resize-none py-2 font-mono')}
189
+ placeholder={'Environment, one per line\nAPI_KEY=…'}
190
+ value={draft.env}
191
+ onChange={(e) => setDraft({ ...draft, env: e.target.value })}
192
+ />
193
+ </>
194
+ ) : (
195
+ <>
196
+ <input className={cn(field, 'font-mono')} placeholder="https://example.com/mcp" value={draft.url} onChange={(e) => setDraft({ ...draft, url: e.target.value })} />
197
+ <textarea
198
+ className={cn(field, 'h-20 resize-none py-2 font-mono')}
199
+ placeholder={'Headers, one per line\nAuthorization: Bearer …'}
200
+ value={draft.headers}
201
+ onChange={(e) => setDraft({ ...draft, headers: e.target.value })}
202
+ />
203
+ </>
204
+ )}
205
+ <div className="flex justify-end gap-2">
206
+ <button type="button" className="chip h-8 rounded-full px-4" onClick={() => setDraft(null)}>
207
+ Cancel
208
+ </button>
209
+ <button
210
+ type="button"
211
+ disabled={busy || !draft.name.trim() || (draft.transport === 'stdio' ? !draft.command.trim() : !draft.url.trim())}
212
+ onClick={() => void addServer()}
213
+ className="inline-flex h-8 items-center gap-1.5 rounded-full bg-accent px-4 text-[11.5px] font-medium text-on-accent transition-transform active:scale-95 disabled:opacity-40"
214
+ >
215
+ {busy && <Spinner size={11} />} Save and connect
216
+ </button>
217
+ </div>
218
+ </div>
219
+ )}
220
+
221
+ {error && <p className="rounded-xl border border-danger/25 bg-danger/[0.05] px-3 py-2 text-[11.5px] text-danger">{error}</p>}
222
+ {!loaded && loading && (
223
+ <p className="flex items-center gap-2 text-[12px] text-faint">
224
+ <Spinner size={12} /> Loading servers…
225
+ </p>
226
+ )}
227
+ {loaded && servers.length === 0 && !draft && (
228
+ <p className="rounded-2xl border border-dashed border-line px-5 py-10 text-center text-[12px] text-muted">
229
+ No MCP servers yet. Add one to give the agent new tools.
230
+ </p>
231
+ )}
232
+
233
+ {servers.map((server, index) => {
234
+ const expanded = open === server.id
235
+ return (
236
+ <div key={server.id} style={{ animationDelay: `${index * 50}ms` }} className="settings-card animate-rise !p-0">
237
+ <div className="flex items-center gap-3 px-4 py-3.5">
238
+ <StatusDot status={server.status} />
239
+ <button type="button" className="min-w-0 flex-1 text-left" onClick={() => setOpen(expanded ? null : server.id)}>
240
+ <p className="flex items-center gap-2 text-[13px] font-medium text-ink">
241
+ <span className="truncate">{server.name}</span>
242
+ <span className="rounded-full bg-raised px-2 py-px font-mono text-[9.5px] text-faint">{server.transport}</span>
243
+ </p>
244
+ <p className="mt-0.5 truncate text-[11px] text-muted">
245
+ {server.status === 'connected'
246
+ ? `${server.tools.length} tool${server.tools.length === 1 ? '' : 's'}${server.serverInfo?.name ? ` · ${server.serverInfo.name} ${server.serverInfo.version ?? ''}` : ''}`
247
+ : server.status === 'connecting'
248
+ ? 'Connecting…'
249
+ : server.status === 'error'
250
+ ? server.error || 'Connection failed'
251
+ : server.status === 'disabled'
252
+ ? 'Turned off'
253
+ : 'Not connected'}
254
+ </p>
255
+ </button>
256
+ {server.status === 'connected' || server.status === 'connecting' ? (
257
+ <>
258
+ <button type="button" className="icon-button h-8 w-8" title="Reload tools" aria-label="Reload tools" onClick={() => void act(server.id, 'refresh')}>
259
+ <RefreshCw size={13} className={cn(server.status === 'connecting' && 'animate-spin')} />
260
+ </button>
261
+ <button type="button" className="icon-button h-8 w-8" title="Disconnect" aria-label="Disconnect" onClick={() => void act(server.id, 'disconnect')}>
262
+ <Unplug size={13} />
263
+ </button>
264
+ </>
265
+ ) : (
266
+ <button
267
+ type="button"
268
+ className="chip h-8 rounded-full px-3.5 text-[11.5px]"
269
+ disabled={!server.enabled}
270
+ onClick={() => void act(server.id, 'connect')}
271
+ >
272
+ Connect
273
+ </button>
274
+ )}
275
+ <Switch checked={server.enabled} onChange={(enabled) => void patch(server.id, { enabled })} label={`Enable ${server.name}`} />
276
+ <button type="button" className="icon-button h-8 w-8" aria-label="Expand" onClick={() => setOpen(expanded ? null : server.id)}>
277
+ <ChevronDown size={14} className={cn('transition-transform duration-300', expanded && 'rotate-180')} />
278
+ </button>
279
+ </div>
280
+ {expanded && (
281
+ <div className="animate-fade space-y-3 border-t border-line px-4 py-3.5">
282
+ <div className="flex flex-wrap items-center justify-between gap-2">
283
+ <p className="font-mono text-[10.5px] text-faint">
284
+ {server.transport === 'stdio' ? `${server.command} ${(server.args ?? []).join(' ')}` : server.url}
285
+ </p>
286
+ <div className="flex items-center gap-2">
287
+ <span className="text-[11px] text-muted">Tool calls</span>
288
+ <Segmented
289
+ options={[
290
+ { value: 'ask', label: 'Ask first' },
291
+ { value: 'auto', label: 'Auto-run' },
292
+ ]}
293
+ value={server.approval}
294
+ onChange={(approval) => void patch(server.id, { approval })}
295
+ />
296
+ </div>
297
+ </div>
298
+ {server.status === 'error' && server.error && <p className="text-[11.5px] text-danger">{server.error}</p>}
299
+ {server.tools.length > 0 && (
300
+ <ul className="divide-y divide-line rounded-xl border border-line">
301
+ {server.tools.map((tool) => {
302
+ const off = server.disabledTools.includes(tool.name)
303
+ return (
304
+ <li key={tool.name} className="flex items-center gap-3 px-3 py-2">
305
+ <Wrench size={12} className={cn('shrink-0', off ? 'text-faint' : 'text-accent')} />
306
+ <div className="min-w-0 flex-1">
307
+ <p className={cn('truncate font-mono text-[11.5px]', off ? 'text-faint line-through' : 'text-ink')}>{tool.name}</p>
308
+ {tool.description && <p className="truncate text-[10.5px] text-muted">{tool.description}</p>}
309
+ </div>
310
+ {tool.readOnly && <span className="rounded-full bg-success/10 px-1.5 py-px text-[9px] text-success">read-only</span>}
311
+ <Switch
312
+ checked={!off}
313
+ label={`Enable ${tool.name}`}
314
+ onChange={(on) =>
315
+ void patch(server.id, {
316
+ disabledTools: on ? server.disabledTools.filter((n) => n !== tool.name) : [...server.disabledTools, tool.name],
317
+ })
318
+ }
319
+ />
320
+ </li>
321
+ )
322
+ })}
323
+ </ul>
324
+ )}
325
+ <div className="flex justify-end">
326
+ <button type="button" onClick={() => void remove(server)} className="inline-flex items-center gap-1.5 text-[11.5px] font-medium text-faint transition-colors hover:text-danger">
327
+ <Trash2 size={12} /> Remove server
328
+ </button>
329
+ </div>
330
+ </div>
331
+ )}
332
+ </div>
333
+ )
334
+ })}
335
+ </div>
336
+ )
337
+ }
@@ -21,7 +21,8 @@ import { createId, useChats } from '../store/chats'
21
21
  import { toolLabel } from '../agent/tools'
22
22
  import { formatBytes, cn } from '../lib/utils'
23
23
  import { Markdown } from './Markdown'
24
- import { ToolCallCard } from './ToolCallCard'
24
+ import { ToolCallCard, diffForTool } from './ToolCallCard'
25
+ import { DiffBadge } from './DiffView'
25
26
  import { ArrowRight } from 'lucide-react'
26
27
  import { LogoMark } from './Logo'
27
28
 
@@ -168,7 +169,15 @@ function changedFiles(tools: ToolMessage[]): string[] {
168
169
  }
169
170
 
170
171
  /** 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
+ function UndoBar({
173
+ turnId,
174
+ files,
175
+ diff,
176
+ }: {
177
+ turnId: string
178
+ files: string[]
179
+ diff: { added: number; removed: number } | null
180
+ }) {
172
181
  const askConfirm = useUi((state) => state.askConfirm)
173
182
  const notify = useUi((state) => state.notify)
174
183
  const [state, setState] = useState<'idle' | 'working' | 'undone'>('idle')
@@ -204,6 +213,7 @@ function UndoBar({ turnId, files }: { turnId: string; files: string[] }) {
204
213
  <span className="min-w-0 truncate">
205
214
  {state === 'undone' ? 'Changes undone' : `Changed ${shown}${rest > 0 ? ` and ${rest} more` : ''}`}
206
215
  </span>
216
+ {state !== 'undone' && diff && <DiffBadge added={diff.added} removed={diff.removed} className="shrink-0" />}
207
217
  {state !== 'undone' && (
208
218
  <button
209
219
  type="button"
@@ -218,6 +228,48 @@ function UndoBar({ turnId, files }: { turnId: string; files: string[] }) {
218
228
  )
219
229
  }
220
230
 
231
+ function ThoughtToggle({ text }: { text: string }) {
232
+ const [open, setOpen] = useState(false)
233
+ return (
234
+ <div>
235
+ <button
236
+ type="button"
237
+ onClick={() => setOpen((value) => !value)}
238
+ className="group/think flex max-w-full items-baseline gap-1.5 py-0.5 text-left text-[12px]"
239
+ aria-expanded={open}
240
+ >
241
+ <ArrowRight
242
+ size={11}
243
+ strokeWidth={2}
244
+ className={cn('relative top-[1px] shrink-0 self-center text-faint transition-transform duration-200 ease-out', open && 'rotate-90')}
245
+ />
246
+ <span className="shrink-0 font-medium text-muted group-hover/think:text-ink">Thought</span>
247
+ {!open && <span className="min-w-0 truncate text-[11px] text-faint">{text.replace(/\s+/g, ' ').trim().slice(0, 100)}</span>}
248
+ </button>
249
+ {open && (
250
+ <div className="thin-scroll animate-fade max-h-40 overflow-y-auto whitespace-pre-wrap pl-[17px] font-mono text-[11px] leading-relaxed text-faint">
251
+ {text}
252
+ </div>
253
+ )}
254
+ </div>
255
+ )
256
+ }
257
+
258
+ function totalDiff(tools: ToolMessage[]) {
259
+ let added = 0
260
+ let removed = 0
261
+ let any = false
262
+ for (const tool of tools) {
263
+ if (!FILE_EDIT_TOOLS.has(tool.call.name)) continue
264
+ const diff = diffForTool(tool)
265
+ if (!diff) continue
266
+ any = true
267
+ added += diff.added
268
+ removed += diff.removed
269
+ }
270
+ return any ? { added, removed } : null
271
+ }
272
+
221
273
  export function TaskCompletedBox({
222
274
  tools,
223
275
  intermediateMessages = [],
@@ -291,6 +343,10 @@ export function TaskCompletedBox({
291
343
  {summary ? ` (${summary})` : ''}
292
344
  </span>
293
345
  {failed > 0 && <span className="shrink-0 text-[12px] text-danger">· {failed} failed</span>}
346
+ {(() => {
347
+ const total = totalDiff(tools)
348
+ return total ? <DiffBadge added={total.added} removed={total.removed} className="shrink-0 text-[11.5px]" /> : null
349
+ })()}
294
350
  </button>
295
351
 
296
352
  {open && (
@@ -300,11 +356,7 @@ export function TaskCompletedBox({
300
356
  <ToolCallCard key={item.tool.id} message={item.tool} compact={!detailed} />
301
357
  ) : (
302
358
  <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
- )}
359
+ {item.message.reasoning.trim() && <ThoughtToggle text={item.message.reasoning} />}
308
360
  {item.message.content.trim() && (
309
361
  <div className="mt-1 text-[12.5px] leading-relaxed text-muted">
310
362
  <Markdown content={item.message.content} />
@@ -347,7 +399,8 @@ export function AssistantBlock({
347
399
  message.provider ? state.byProvider[message.provider] : undefined,
348
400
  )
349
401
  const [copied, setCopied] = useState(false)
350
- const [reasoningOpen, setReasoningOpen] = useState(streaming)
402
+ // Thoughts stay closed until the person opens them, even while the model is still thinking.
403
+ const [reasoningOpen, setReasoningOpen] = useState(false)
351
404
  const hasBody = Boolean(message.content.trim() || message.reasoning.trim() || message.error)
352
405
  const modelName =
353
406
  providerModels?.find((model) => model.id === message.model)?.name ?? message.model ?? ''
@@ -368,10 +421,6 @@ export function AssistantBlock({
368
421
  .filter(Boolean)
369
422
  .join(' · ')
370
423
 
371
- useEffect(() => {
372
- if (streaming) setReasoningOpen(true)
373
- }, [streaming])
374
-
375
424
  const copy = async () => {
376
425
  try {
377
426
  await navigator.clipboard.writeText(message.content)
@@ -648,7 +697,7 @@ export function TurnBlock({
648
697
  )}
649
698
 
650
699
  {turn.user && changedFiles(allTools).length > 0 && (
651
- <UndoBar turnId={turn.user.id} files={changedFiles(allTools)} />
700
+ <UndoBar turnId={turn.user.id} files={changedFiles(allTools)} diff={totalDiff(allTools)} />
652
701
  )}
653
702
  </div>
654
703
  )