@swarmclawai/swarmclaw 1.5.63 → 1.5.64

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 (32) hide show
  1. package/README.md +17 -0
  2. package/package.json +2 -2
  3. package/src/app/api/chats/[id]/clear/route.ts +7 -3
  4. package/src/app/api/chats/[id]/clear/undo/route.ts +23 -0
  5. package/src/app/api/chats/[id]/compact/route.ts +72 -0
  6. package/src/app/api/chats/[id]/context-status/route.ts +21 -0
  7. package/src/app/api/chats/clear-route.test.ts +121 -0
  8. package/src/app/api/chats/compact-route.test.ts +70 -0
  9. package/src/app/api/chats/context-status-route.test.ts +68 -0
  10. package/src/app/api/mcp-servers/[id]/route.ts +5 -0
  11. package/src/app/api/mcp-servers/[id]/test/route.ts +5 -0
  12. package/src/app/api/mcp-servers/[id]/tools-info/route.ts +75 -0
  13. package/src/cli/index.js +5 -1
  14. package/src/cli/spec.js +4 -1
  15. package/src/components/chat/chat-area.tsx +62 -6
  16. package/src/components/chat/chat-header.tsx +13 -1
  17. package/src/components/chat/context-meter-badge.tsx +227 -0
  18. package/src/components/mcp-servers/mcp-server-list.tsx +56 -0
  19. package/src/components/mcp-servers/mcp-server-sheet.tsx +202 -1
  20. package/src/components/mcp-servers/registry-browser.tsx +224 -0
  21. package/src/lib/chat/chats.ts +37 -1
  22. package/src/lib/server/chats/chat-session-service.ts +75 -0
  23. package/src/lib/server/chats/clear-undo-snapshots.test.ts +107 -0
  24. package/src/lib/server/chats/clear-undo-snapshots.ts +92 -0
  25. package/src/lib/server/mcp-connection-pool.test.ts +98 -0
  26. package/src/lib/server/mcp-connection-pool.ts +134 -0
  27. package/src/lib/server/mcp-gateway-runtime.test.ts +177 -0
  28. package/src/lib/server/mcp-gateway-runtime.ts +138 -0
  29. package/src/lib/server/session-tools/index.ts +83 -15
  30. package/src/lib/server/storage-normalization.ts +11 -0
  31. package/src/types/agent.ts +1 -0
  32. package/src/types/misc.ts +7 -0
@@ -1,14 +1,16 @@
1
1
  'use client'
2
2
 
3
- import { useState } from 'react'
3
+ import { useEffect, useState } from 'react'
4
4
  import { useAppStore } from '@/stores/use-app-store'
5
5
  import { BottomSheet } from '@/components/shared/bottom-sheet'
6
6
  import { ConfirmDialog } from '@/components/shared/confirm-dialog'
7
7
  import { HintTip } from '@/components/shared/hint-tip'
8
+ import { AdvancedSettingsSection } from '@/components/shared/advanced-settings-section'
8
9
  import { api } from '@/lib/app/api-client'
9
10
  import { toast } from 'sonner'
10
11
  import type { McpServerConfig, McpTransport } from '@/types'
11
12
  import { useMountedRef } from '@/hooks/use-mounted-ref'
13
+ import { RegistryBrowser, type RegistryPrefill } from './registry-browser'
12
14
 
13
15
  interface McpPreset {
14
16
  id: string
@@ -39,6 +41,18 @@ const MCP_PRESETS: McpPreset[] = [
39
41
  cwdHint: 'Absolute path to a SwarmVault workspace (the directory containing swarmvault.config.json). Run `npx @swarmvaultai/cli init` there first if you haven\'t.',
40
42
  defaultName: 'SwarmVault',
41
43
  },
44
+ {
45
+ id: 'mcp-gateway',
46
+ label: 'MCP Gateway (local)',
47
+ description: 'Consolidate many MCP servers behind one entry. The gateway fans out to your downstream servers, namespaces their tools, and only exposes the ones you pre-load — big token savings when you run more than a handful of MCP servers.',
48
+ helpUrl: 'https://github.com/swarmclawai/mcp-gateway',
49
+ transport: 'stdio',
50
+ command: 'npx',
51
+ args: ['-y', '@swarmclawai/mcp-gateway@latest', 'start'],
52
+ needsCwd: true,
53
+ cwdHint: 'Absolute path to a directory containing mcp-gateway.config.json. Run `npx @swarmclawai/mcp-gateway init --write` there first to generate a starter config.',
54
+ defaultName: 'MCP Gateway',
55
+ },
42
56
  {
43
57
  id: 'swarmdock',
44
58
  label: 'SwarmDock',
@@ -65,6 +79,7 @@ function McpServerForm({ editing, onClose, loadMcpServers }: {
65
79
  const [args, setArgs] = useState(editing?.args?.join(', ') || '')
66
80
  const [cwd, setCwd] = useState(editing?.cwd || '')
67
81
  const [activePresetId, setActivePresetId] = useState<string | null>(null)
82
+ const [registryBrowserOpen, setRegistryBrowserOpen] = useState(false)
68
83
  const [url, setUrl] = useState(editing?.url || '')
69
84
  const [envText, setEnvText] = useState(
70
85
  editing?.env ? Object.entries(editing.env).map(([k, v]) => `${k}=${v}`).join('\n') : '',
@@ -72,11 +87,58 @@ function McpServerForm({ editing, onClose, loadMcpServers }: {
72
87
  const [headersText, setHeadersText] = useState(
73
88
  editing?.headers ? Object.entries(editing.headers).map(([k, v]) => `${k}: ${v}`).join('\n') : '',
74
89
  )
90
+ const initialExposureMode: 'all' | 'lazy' | 'selected' =
91
+ editing === null || editing?.alwaysExpose === undefined || editing.alwaysExpose === true
92
+ ? 'all'
93
+ : editing.alwaysExpose === false
94
+ ? 'lazy'
95
+ : 'selected'
96
+ const [exposureMode, setExposureMode] = useState<'all' | 'lazy' | 'selected'>(initialExposureMode)
97
+ const [exposureAllowlistText, setExposureAllowlistText] = useState(
98
+ Array.isArray(editing?.alwaysExpose) ? editing.alwaysExpose.join(', ') : '',
99
+ )
100
+ const [advancedOpen, setAdvancedOpen] = useState(initialExposureMode !== 'all')
101
+ const [discoveredTools, setDiscoveredTools] = useState<Array<{ name: string; description?: string; tokens: number }> | null>(null)
102
+ const [discoveredLoading, setDiscoveredLoading] = useState(false)
103
+ const [discoveredError, setDiscoveredError] = useState<string | null>(null)
75
104
  const [testing, setTesting] = useState(false)
76
105
  const [testResult, setTestResult] = useState<{ ok: boolean; tools?: string[]; error?: string } | null>(null)
77
106
  const [confirmDelete, setConfirmDelete] = useState(false)
78
107
  const [deleting, setDeleting] = useState(false)
79
108
 
109
+ // Lazily load discovered tools when the user picks the allow-list mode
110
+ // for an existing (edited) server. Only hits the server once per sheet open.
111
+ useEffect(() => {
112
+ if (!editing || exposureMode !== 'selected' || discoveredTools || discoveredLoading) return
113
+ let cancelled = false
114
+ setDiscoveredLoading(true)
115
+ setDiscoveredError(null)
116
+ void (async () => {
117
+ try {
118
+ const res = await api<{ tools: Array<{ name: string; description?: string; tokens: number }> }>(
119
+ 'GET',
120
+ `/mcp-servers/${editing.id}/tools-info`,
121
+ )
122
+ if (cancelled) return
123
+ setDiscoveredTools(res.tools)
124
+ } catch (err: unknown) {
125
+ if (cancelled) return
126
+ setDiscoveredError(err instanceof Error ? err.message : 'Failed to load tools')
127
+ } finally {
128
+ if (!cancelled) setDiscoveredLoading(false)
129
+ }
130
+ })()
131
+ return () => { cancelled = true }
132
+ }, [editing, exposureMode, discoveredTools, discoveredLoading])
133
+
134
+ const toggleAllowlistTool = (toolName: string) => {
135
+ const current = exposureAllowlistText.split(/[\s,]+/).map((s) => s.trim()).filter(Boolean)
136
+ const next = current.includes(toolName)
137
+ ? current.filter((t) => t !== toolName)
138
+ : [...current, toolName]
139
+ setExposureAllowlistText(next.join(', '))
140
+ }
141
+
80
142
  const parseEnv = (text: string): Record<string, string> | undefined => {
81
143
  if (!text.trim()) return undefined
82
144
  const env: Record<string, string> = {}
@@ -103,6 +165,12 @@ function McpServerForm({ editing, onClose, loadMcpServers }: {
103
165
  transport,
104
166
  env: parseEnv(envText),
105
167
  headers: parseHeaders(headersText),
168
+ alwaysExpose:
169
+ exposureMode === 'all'
170
+ ? true
171
+ : exposureMode === 'lazy'
172
+ ? false
173
+ : exposureAllowlistText.split(/[\s,]+/).map((s) => s.trim()).filter(Boolean),
106
174
  }
107
175
  if (transport === 'stdio') {
108
176
  data.command = command.trim()
@@ -186,6 +254,16 @@ function McpServerForm({ editing, onClose, loadMcpServers }: {
186
254
 
187
255
  const activePreset = activePresetId ? MCP_PRESETS.find((p) => p.id === activePresetId) ?? null : null
188
256
 
257
+ const applyRegistryPrefill = (prefill: RegistryPrefill) => {
258
+ setActivePresetId(null)
259
+ setTransport(prefill.transport)
260
+ if (prefill.command !== undefined) setCommand(prefill.command)
261
+ if (prefill.args !== undefined) setArgs(prefill.args.join(', '))
262
+ if (prefill.url !== undefined) setUrl(prefill.url)
263
+ if (!name.trim()) setName(prefill.name)
264
+ toast.success(`Prefilled from SwarmDock MCP Registry: ${prefill.sourceSlug}`)
265
+ }
266
+
189
267
  const inputClass = "w-full px-4 py-3.5 rounded-[14px] border border-white/[0.08] bg-surface text-text text-[15px] outline-none transition-all duration-200 placeholder:text-text-3/50 focus-glow"
190
268
  const labelClass = "block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-3"
191
269
 
@@ -221,6 +299,15 @@ function McpServerForm({ editing, onClose, loadMcpServers }: {
221
299
  </button>
222
300
  )
223
301
  })}
302
+ <button
303
+ type="button"
304
+ onClick={() => setRegistryBrowserOpen(true)}
305
+ className="py-2 px-4 rounded-[12px] border border-dashed border-accent-bright/30 bg-transparent text-[13px] font-600 text-accent-bright cursor-pointer transition-all hover:bg-accent-bright/10"
306
+ style={{ fontFamily: 'inherit' }}
307
+ title="Browse the public SwarmDock MCP Registry"
308
+ >
309
+ Browse Registry...
310
+ </button>
224
311
  </div>
225
312
  {activePreset && (
226
313
  <p className="mt-3 text-[12px] text-text-3">
@@ -321,6 +408,115 @@ function McpServerForm({ editing, onClose, loadMcpServers }: {
321
408
  </div>
322
409
  )}
323
410
 
411
+ <AdvancedSettingsSection
412
+ open={advancedOpen}
413
+ onToggle={() => setAdvancedOpen((v) => !v)}
414
+ summary={exposureMode === 'all' ? 'All tools eager' : exposureMode === 'lazy' ? 'Lazy (on demand)' : 'Allow-list'}
415
+ badges={exposureMode === 'selected' && exposureAllowlistText.trim()
416
+ ? exposureAllowlistText.split(/[\s,]+/).map((s) => s.trim()).filter(Boolean).slice(0, 5)
417
+ : []}
418
+ >
419
+ <div className="space-y-4">
420
+ <div>
421
+ <label className={labelClass}>
422
+ Tool exposure
423
+ <HintTip text="Controls how this server's tools get bound into agent context. Lazy servers stay hidden until an agent calls `mcp_tool_search` to discover them — that's how you cut token usage from chatty MCP servers." />
424
+ </label>
425
+ <div className="flex flex-col gap-2">
426
+ {([
427
+ ['all', 'Expose all tools', 'Every tool from this server is bound on every turn. Default — preserves legacy behavior.'],
428
+ ['lazy', 'Lazy — expose none', 'No tools bound until the agent calls mcp_tool_search to discover them. Biggest token savings.'],
429
+ ['selected', 'Allow-list', 'Only pre-bind the tools you list below. Agent can still discover others via mcp_tool_search.'],
430
+ ] as const).map(([value, label, hint]) => (
431
+ <label key={value} className={`flex items-start gap-3 p-3 rounded-[12px] border cursor-pointer transition-all ${exposureMode === value ? 'border-accent-bright bg-accent-bright/5' : 'border-white/[0.08] hover:bg-surface-2'}`}>
432
+ <input
433
+ type="radio"
434
+ name="exposureMode"
435
+ value={value}
436
+ checked={exposureMode === value}
437
+ onChange={() => setExposureMode(value)}
438
+ className="mt-1"
439
+ />
440
+ <div className="flex-1 min-w-0">
441
+ <div className="text-[14px] font-600 text-text">{label}</div>
442
+ <div className="text-[12px] text-text-3 leading-[1.5] mt-0.5">{hint}</div>
443
+ </div>
444
+ </label>
445
+ ))}
446
+ </div>
447
+ </div>
448
+ {exposureMode === 'selected' && (
449
+ <div>
450
+ <label className={labelClass}>
451
+ Allow-list tools
452
+ <HintTip text="Pick the tools to bind eagerly. Every unchecked tool stays discoverable via `mcp_tool_search`." />
453
+ </label>
454
+ {discoveredLoading && (
455
+ <div className="text-[12px] text-text-3">Loading tools...</div>
456
+ )}
457
+ {discoveredError && (
458
+ <div className="text-[12px] text-amber-400">
459
+ Could not load tools: {discoveredError}. Type names manually below.
460
+ </div>
461
+ )}
462
+ {discoveredTools && discoveredTools.length > 0 && (
463
+ (() => {
464
+ const selected = new Set(
465
+ exposureAllowlistText.split(/[\s,]+/).map((s) => s.trim()).filter(Boolean),
466
+ )
467
+ const totalTokens = discoveredTools.reduce((n, t) => n + t.tokens, 0)
468
+ const selectedTokens = discoveredTools
469
+ .filter((t) => selected.has(t.name))
470
+ .reduce((n, t) => n + t.tokens, 0)
471
+ return (
472
+ <div className="space-y-1 rounded-[12px] border border-white/[0.08] bg-surface/50 p-2 max-h-[320px] overflow-auto">
473
+ <div className="px-2 py-1 text-[11px] font-mono text-text-3">
474
+ {selectedTokens.toLocaleString()} / {totalTokens.toLocaleString()} tokens selected
475
+ </div>
476
+ {discoveredTools.map((t) => {
477
+ const checked = selected.has(t.name)
478
+ return (
479
+ <label
480
+ key={t.name}
481
+ className={`flex items-start gap-3 p-2 rounded-[10px] cursor-pointer transition-colors ${checked ? 'bg-accent-bright/5' : 'hover:bg-white/[0.03]'}`}
482
+ >
483
+ <input
484
+ type="checkbox"
485
+ checked={checked}
486
+ onChange={() => toggleAllowlistTool(t.name)}
487
+ className="mt-1 shrink-0"
488
+ />
489
+ <div className="flex-1 min-w-0">
490
+ <div className="flex items-center justify-between gap-2">
491
+ <span className="text-[13px] font-mono text-text truncate">{t.name}</span>
492
+ <span className="text-[10px] font-mono text-text-3 shrink-0">{t.tokens.toLocaleString()} tok</span>
493
+ </div>
494
+ {t.description && (
495
+ <p className="text-[12px] text-text-3/80 leading-[1.4] mt-0.5 line-clamp-2">{t.description}</p>
496
+ )}
497
+ </div>
498
+ </label>
499
+ )
500
+ })}
501
+ </div>
502
+ )
503
+ })()
504
+ )}
505
+ {(!discoveredTools || discoveredTools.length === 0) && !discoveredLoading && (
506
+ <textarea
507
+ value={exposureAllowlistText}
508
+ onChange={(e) => setExposureAllowlistText(e.target.value)}
509
+ placeholder={"read_file\nwrite_file"}
510
+ rows={3}
511
+ className={`${inputClass} resize-y min-h-[80px] font-mono text-[13px]`}
512
+ style={{ fontFamily: 'inherit' }}
513
+ />
514
+ )}
515
+ </div>
516
+ )}
517
+ </div>
518
+ </AdvancedSettingsSection>
519
+
324
520
  {editing && (
325
521
  <div className="mb-8">
326
522
  <button
@@ -372,6 +568,11 @@ function McpServerForm({ editing, onClose, loadMcpServers }: {
372
568
  onConfirm={() => { void handleDelete() }}
373
569
  onCancel={() => { if (!deleting) setConfirmDelete(false) }}
374
570
  />
571
+ <RegistryBrowser
572
+ open={registryBrowserOpen}
573
+ onClose={() => setRegistryBrowserOpen(false)}
574
+ onSelect={applyRegistryPrefill}
575
+ />
375
576
  </>
376
577
  )
377
578
  }
@@ -0,0 +1,224 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * Browse the public SwarmDock MCP Registry (https://mcp.swarmdock.ai) from
5
+ * the New MCP Server sheet. Selecting a server populates the form with its
6
+ * recommended install method so users get one-click discovery without
7
+ * leaving SwarmClaw.
8
+ *
9
+ * Read-only — SwarmClaw only consumes the registry. Attestations and
10
+ * submissions happen through SwarmDock directly.
11
+ */
12
+
13
+ import { useEffect, useState } from 'react'
14
+
15
+ const REGISTRY_API = 'https://swarmdock-api.onrender.com/api/v1/mcp/servers'
16
+
17
+ export interface RegistryPrefill {
18
+ name: string
19
+ transport: 'stdio' | 'sse' | 'streamable-http'
20
+ command?: string
21
+ args?: string[]
22
+ url?: string
23
+ sourceSlug: string
24
+ }
25
+
26
+ interface RegistryServer {
27
+ slug: string
28
+ name: string
29
+ description: string
30
+ transport: string
31
+ authMode: string
32
+ language: string | null
33
+ tags: string[]
34
+ qualityScore: number
35
+ verifiedUsageCount: number
36
+ paidTier: boolean
37
+ }
38
+
39
+ interface RegistryDetail extends RegistryServer {
40
+ installations: Array<{ method: string; spec: Record<string, unknown> }>
41
+ }
42
+
43
+ function mapTransport(transport: string): 'stdio' | 'sse' | 'streamable-http' {
44
+ if (transport === 'sse') return 'sse'
45
+ if (transport === 'streamable_http') return 'streamable-http'
46
+ return 'stdio'
47
+ }
48
+
49
+ function installToPrefill(server: RegistryDetail): RegistryPrefill | null {
50
+ const preferred = server.installations.find((i) => i.method === 'npx')
51
+ ?? server.installations.find((i) => i.method === 'npm')
52
+ ?? server.installations.find((i) => i.method === 'uvx')
53
+ ?? server.installations.find((i) => i.method === 'pipx')
54
+ ?? server.installations.find((i) => i.method === 'docker')
55
+ ?? server.installations.find((i) => i.method === 'remote')
56
+ ?? server.installations[0]
57
+
58
+ if (!preferred) return null
59
+
60
+ const spec = preferred.spec
61
+ const transport = mapTransport(server.transport)
62
+
63
+ if (preferred.method === 'remote') {
64
+ const url = typeof spec.url === 'string' ? spec.url : undefined
65
+ return url
66
+ ? { name: server.name, transport: transport === 'stdio' ? 'streamable-http' : transport, url, sourceSlug: server.slug }
67
+ : null
68
+ }
69
+
70
+ const command = typeof spec.command === 'string' ? spec.command : preferred.method === 'docker' ? 'docker' : 'npx'
71
+ const args = Array.isArray(spec.args)
72
+ ? spec.args.filter((a): a is string => typeof a === 'string')
73
+ : preferred.method === 'npm' && typeof spec.package === 'string'
74
+ ? ['-y', spec.package]
75
+ : []
76
+
77
+ return { name: server.name, transport, command, args, sourceSlug: server.slug }
78
+ }
79
+
80
+ export function RegistryBrowser({
81
+ open,
82
+ onClose,
83
+ onSelect,
84
+ }: {
85
+ open: boolean
86
+ onClose: () => void
87
+ onSelect: (prefill: RegistryPrefill) => void
88
+ }) {
89
+ const [query, setQuery] = useState('')
90
+ const [servers, setServers] = useState<RegistryServer[]>([])
91
+ const [loading, setLoading] = useState(false)
92
+ const [error, setError] = useState<string | null>(null)
93
+ const [selecting, setSelecting] = useState<string | null>(null)
94
+
95
+ useEffect(() => {
96
+ if (!open) return
97
+ let cancelled = false
98
+ const fetchServers = async () => {
99
+ setLoading(true)
100
+ setError(null)
101
+ try {
102
+ const qs = query ? `?q=${encodeURIComponent(query)}&limit=20` : '?limit=20'
103
+ const res = await fetch(`${REGISTRY_API}${qs}`)
104
+ if (!res.ok) throw new Error(`Registry returned ${res.status}`)
105
+ const data = await res.json() as { servers: RegistryServer[] }
106
+ if (!cancelled) setServers(data.servers)
107
+ } catch (err) {
108
+ if (!cancelled) setError(err instanceof Error ? err.message : 'Failed to load registry')
109
+ } finally {
110
+ if (!cancelled) setLoading(false)
111
+ }
112
+ }
113
+ const timer = setTimeout(fetchServers, query ? 250 : 0)
114
+ return () => {
115
+ cancelled = true
116
+ clearTimeout(timer)
117
+ }
118
+ }, [open, query])
119
+
120
+ const handleSelect = async (slug: string) => {
121
+ setSelecting(slug)
122
+ try {
123
+ const res = await fetch(`${REGISTRY_API}/${encodeURIComponent(slug)}`)
124
+ if (!res.ok) throw new Error(`Server detail returned ${res.status}`)
125
+ const detail = await res.json() as RegistryDetail
126
+ const prefill = installToPrefill(detail)
127
+ if (!prefill) {
128
+ setError('This server has no installation method SwarmClaw can consume yet.')
129
+ return
130
+ }
131
+ onSelect(prefill)
132
+ onClose()
133
+ } catch (err) {
134
+ setError(err instanceof Error ? err.message : 'Failed to fetch server')
135
+ } finally {
136
+ setSelecting(null)
137
+ }
138
+ }
139
+
140
+ if (!open) return null
141
+
142
+ return (
143
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={onClose}>
144
+ <div
145
+ className="flex max-h-[80vh] w-full max-w-2xl flex-col overflow-hidden rounded-[20px] border border-white/[0.08] bg-surface"
146
+ onClick={(e) => e.stopPropagation()}
147
+ >
148
+ <div className="flex items-center justify-between border-b border-white/[0.06] px-6 py-4">
149
+ <div>
150
+ <h3 className="font-display text-[18px] font-700 tracking-[-0.02em]">Browse SwarmDock MCP Registry</h3>
151
+ <p className="mt-0.5 text-[12px] text-text-3">
152
+ Public directory with verified usage signal · <a href="https://mcp.swarmdock.ai" target="_blank" rel="noopener noreferrer" className="text-accent-bright hover:underline">mcp.swarmdock.ai</a>
153
+ </p>
154
+ </div>
155
+ <button
156
+ type="button"
157
+ onClick={onClose}
158
+ className="text-text-3 hover:text-text"
159
+ aria-label="Close"
160
+ >
161
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
162
+ <line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
163
+ </svg>
164
+ </button>
165
+ </div>
166
+
167
+ <div className="px-6 py-4">
168
+ <input
169
+ type="text"
170
+ placeholder="Search — e.g. postgres, pdf, github"
171
+ value={query}
172
+ onChange={(e) => setQuery(e.target.value)}
173
+ className="w-full rounded-[12px] border border-white/[0.08] bg-surface-2 px-4 py-2.5 text-[14px] outline-none focus-glow"
174
+ style={{ fontFamily: 'inherit' }}
175
+ autoFocus
176
+ />
177
+ </div>
178
+
179
+ <div className="flex-1 overflow-y-auto px-4 pb-4">
180
+ {loading ? (
181
+ <p className="px-2 py-4 text-center text-[13px] text-text-3">Loading...</p>
182
+ ) : error ? (
183
+ <p className="px-2 py-4 text-center text-[13px] text-red-400">{error}</p>
184
+ ) : servers.length === 0 ? (
185
+ <p className="px-2 py-4 text-center text-[13px] text-text-3">No servers found.</p>
186
+ ) : (
187
+ <ul className="space-y-1.5">
188
+ {servers.map((server) => (
189
+ <li key={server.slug}>
190
+ <button
191
+ type="button"
192
+ onClick={() => handleSelect(server.slug)}
193
+ disabled={selecting !== null}
194
+ className="group flex w-full flex-col gap-1 rounded-[12px] border border-transparent px-3 py-2.5 text-left transition-all hover:border-white/[0.08] hover:bg-surface-2 disabled:opacity-60"
195
+ >
196
+ <div className="flex items-center gap-2">
197
+ <span className="text-[14px] font-600 group-hover:text-accent-bright">{server.name}</span>
198
+ <span className="rounded-full bg-white/[0.05] px-2 py-0.5 text-[10px] font-mono uppercase text-text-3">
199
+ {server.transport}
200
+ </span>
201
+ {server.paidTier ? (
202
+ <span className="rounded-full bg-emerald-500/15 px-2 py-0.5 text-[10px] font-600 text-emerald-400">
203
+ Paid
204
+ </span>
205
+ ) : null}
206
+ {selecting === server.slug ? (
207
+ <span className="ml-auto text-[11px] text-accent-bright">Loading...</span>
208
+ ) : (
209
+ <span className="ml-auto text-[11px] text-text-3">
210
+ Q {server.qualityScore.toFixed(2)} · {server.verifiedUsageCount.toLocaleString()} uses
211
+ </span>
212
+ )}
213
+ </div>
214
+ <p className="line-clamp-2 text-[12px] text-text-3">{server.description}</p>
215
+ </button>
216
+ </li>
217
+ ))}
218
+ </ul>
219
+ )}
220
+ </div>
221
+ </div>
222
+ </div>
223
+ )
224
+ }
@@ -76,8 +76,44 @@ export const removeQueuedSessionMessage = (id: string, runId: string) =>
76
76
  export const clearSessionQueue = (id: string) =>
77
77
  api<{ cancelled: number; snapshot: SessionQueueSnapshot }>('DELETE', `/chats/${id}/queue`, {})
78
78
 
79
+ export interface ClearChatResult {
80
+ cleared: number
81
+ undoToken: string
82
+ expiresAt: number
83
+ }
84
+
79
85
  export const clearMessages = (id: string) =>
80
- api<string>('POST', `/chats/${id}/clear`)
86
+ api<ClearChatResult>('POST', `/chats/${id}/clear`)
87
+
88
+ export const undoClearMessages = (id: string, undoToken: string) =>
89
+ api<{ restored: number }>('POST', `/chats/${id}/clear/undo`, { undoToken })
90
+
91
+ export interface ContextStatusResponse {
92
+ estimatedTokens: number
93
+ effectiveTokens: number
94
+ contextWindow: number
95
+ percentUsed: number
96
+ messageCount: number
97
+ extraTokens: number
98
+ reserveTokens: number
99
+ remainingTokens: number
100
+ strategy: 'ok' | 'warning' | 'critical'
101
+ }
102
+
103
+ export const fetchContextStatus = (id: string) =>
104
+ api<ContextStatusResponse>('GET', `/chats/${id}/context-status`)
105
+
106
+ export interface CompactChatResult {
107
+ status: 'compacted' | 'no_action'
108
+ prunedCount?: number
109
+ memoriesStored?: number
110
+ summaryAdded?: boolean
111
+ messageCount: number
112
+ keepLastN?: number
113
+ }
114
+
115
+ export const compactChat = (id: string, keepLastN?: number) =>
116
+ api<CompactChatResult>('POST', `/chats/${id}/compact`, keepLastN ? { keepLastN } : {})
81
117
 
82
118
  export const stopChat = (id: string) =>
83
119
  api<string>('POST', `/chats/${id}/stop`)
@@ -22,8 +22,14 @@ import {
22
22
  clearMessages,
23
23
  deleteSessionMessages,
24
24
  getMessages,
25
+ replaceAllMessages,
25
26
  truncateAfter,
26
27
  } from '@/lib/server/messages/message-repository'
28
+ import {
29
+ consumeClearUndoSnapshot,
30
+ recordClearUndoSnapshot,
31
+ type ClearUndoCliIds,
32
+ } from '@/lib/server/chats/clear-undo-snapshots'
27
33
  import { deleteSessionWorkingState } from '@/lib/server/working-state/service'
28
34
  import { normalizeProviderEndpoint } from '@/lib/openclaw/openclaw-endpoint'
29
35
  import { serviceFail, serviceOk } from '@/lib/server/service-result'
@@ -387,6 +393,7 @@ export function clearChatMessages(sessionId: string): boolean {
387
393
  session.claudeSessionId = null
388
394
  session.codexThreadId = null
389
395
  session.opencodeSessionId = null
396
+ session.opencodeWebSessionId = null
390
397
  session.geminiSessionId = null
391
398
  session.copilotSessionId = null
392
399
  session.droidSessionId = null
@@ -399,6 +406,74 @@ export function clearChatMessages(sessionId: string): boolean {
399
406
  return true
400
407
  }
401
408
 
409
+ function snapshotSessionCliIds(session: Session): ClearUndoCliIds {
410
+ return {
411
+ claudeSessionId: session.claudeSessionId ?? null,
412
+ codexThreadId: session.codexThreadId ?? null,
413
+ opencodeSessionId: session.opencodeSessionId ?? null,
414
+ opencodeWebSessionId: session.opencodeWebSessionId ?? null,
415
+ geminiSessionId: session.geminiSessionId ?? null,
416
+ copilotSessionId: session.copilotSessionId ?? null,
417
+ droidSessionId: session.droidSessionId ?? null,
418
+ cursorSessionId: session.cursorSessionId ?? null,
419
+ qwenSessionId: session.qwenSessionId ?? null,
420
+ acpSessionId: session.acpSessionId ?? null,
421
+ delegateResumeIds: session.delegateResumeIds
422
+ ? { ...session.delegateResumeIds }
423
+ : null,
424
+ }
425
+ }
426
+
427
+ export function clearChatMessagesWithUndo(sessionId: string): ServiceResult<{
428
+ cleared: number
429
+ undoToken: string
430
+ expiresAt: number
431
+ }> {
432
+ const session = getSession(sessionId)
433
+ if (!session) return serviceFail(404, 'Session not found')
434
+ const priorMessages = getMessages(sessionId)
435
+ const cli = snapshotSessionCliIds(session)
436
+ const { token, expiresAt } = recordClearUndoSnapshot({
437
+ sessionId,
438
+ messages: priorMessages,
439
+ cli,
440
+ })
441
+ clearChatMessages(sessionId)
442
+ return serviceOk({
443
+ cleared: priorMessages.length,
444
+ undoToken: token,
445
+ expiresAt,
446
+ })
447
+ }
448
+
449
+ export function restoreChatFromUndoToken(
450
+ sessionId: string,
451
+ undoToken: string,
452
+ ): ServiceResult<{ restored: number }> {
453
+ const session = getSession(sessionId)
454
+ if (!session) return serviceFail(404, 'Session not found')
455
+ const snapshot = consumeClearUndoSnapshot({ token: undoToken, sessionId })
456
+ if (!snapshot) return serviceFail(404, 'Undo window expired')
457
+ replaceAllMessages(sessionId, snapshot.messages)
458
+ const cli = snapshot.cli
459
+ session.claudeSessionId = cli.claudeSessionId
460
+ session.codexThreadId = cli.codexThreadId
461
+ session.opencodeSessionId = cli.opencodeSessionId
462
+ session.opencodeWebSessionId = cli.opencodeWebSessionId
463
+ session.geminiSessionId = cli.geminiSessionId
464
+ session.copilotSessionId = cli.copilotSessionId
465
+ session.droidSessionId = cli.droidSessionId
466
+ session.cursorSessionId = cli.cursorSessionId
467
+ session.qwenSessionId = cli.qwenSessionId
468
+ session.acpSessionId = cli.acpSessionId
469
+ session.delegateResumeIds = cli.delegateResumeIds
470
+ ? { ...cli.delegateResumeIds }
471
+ : emptyDelegateResumeIds()
472
+ saveSession(sessionId, session)
473
+ notify('sessions')
474
+ return serviceOk({ restored: snapshot.messages.length })
475
+ }
476
+
402
477
  export function retryChatTurn(sessionId: string): ServiceResult<{ message: string; imagePath: string | null }> {
403
478
  const session = getSession(sessionId)
404
479
  if (!session) return serviceFail(404, 'Session not found')