@swarmclawai/swarmclaw 1.5.62 → 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 (39) hide show
  1. package/README.md +25 -10
  2. package/package.json +2 -2
  3. package/src/app/api/chatrooms/[id]/chat/route.test.ts +111 -0
  4. package/src/app/api/chatrooms/[id]/chat/route.ts +47 -28
  5. package/src/app/api/chatrooms/[id]/members/route.ts +0 -8
  6. package/src/app/api/chatrooms/[id]/route.ts +0 -11
  7. package/src/app/api/chatrooms/route.ts +0 -10
  8. package/src/app/api/chats/[id]/clear/route.ts +7 -3
  9. package/src/app/api/chats/[id]/clear/undo/route.ts +23 -0
  10. package/src/app/api/chats/[id]/compact/route.ts +72 -0
  11. package/src/app/api/chats/[id]/context-status/route.ts +21 -0
  12. package/src/app/api/chats/clear-route.test.ts +121 -0
  13. package/src/app/api/chats/compact-route.test.ts +70 -0
  14. package/src/app/api/chats/context-status-route.test.ts +68 -0
  15. package/src/app/api/mcp-servers/[id]/route.ts +5 -0
  16. package/src/app/api/mcp-servers/[id]/test/route.ts +5 -0
  17. package/src/app/api/mcp-servers/[id]/tools-info/route.ts +75 -0
  18. package/src/cli/index.js +5 -1
  19. package/src/cli/spec.js +4 -1
  20. package/src/components/chat/chat-area.tsx +62 -6
  21. package/src/components/chat/chat-header.tsx +13 -1
  22. package/src/components/chat/context-meter-badge.tsx +227 -0
  23. package/src/components/chatrooms/chatroom-sheet.tsx +1 -4
  24. package/src/components/mcp-servers/mcp-server-list.tsx +56 -0
  25. package/src/components/mcp-servers/mcp-server-sheet.tsx +202 -1
  26. package/src/components/mcp-servers/registry-browser.tsx +224 -0
  27. package/src/lib/chat/chats.ts +37 -1
  28. package/src/lib/server/chats/chat-session-service.ts +75 -0
  29. package/src/lib/server/chats/clear-undo-snapshots.test.ts +107 -0
  30. package/src/lib/server/chats/clear-undo-snapshots.ts +92 -0
  31. package/src/lib/server/mcp-connection-pool.test.ts +98 -0
  32. package/src/lib/server/mcp-connection-pool.ts +134 -0
  33. package/src/lib/server/mcp-gateway-runtime.test.ts +177 -0
  34. package/src/lib/server/mcp-gateway-runtime.ts +138 -0
  35. package/src/lib/server/session-tools/chatroom.ts +0 -11
  36. package/src/lib/server/session-tools/index.ts +83 -15
  37. package/src/lib/server/storage-normalization.ts +11 -0
  38. package/src/types/agent.ts +1 -0
  39. package/src/types/misc.ts +7 -0
@@ -0,0 +1,70 @@
1
+ import assert from 'node:assert/strict'
2
+ import test from 'node:test'
3
+
4
+ import { runWithTempDataDir } from '@/lib/server/test-utils/run-with-temp-data-dir'
5
+
6
+ test('POST /api/chats/[id]/compact returns no_action when transcript is smaller than keepLastN', () => {
7
+ const output = runWithTempDataDir<{
8
+ status: number
9
+ payload: { status?: string; messageCount?: number; keepLastN?: number }
10
+ missingStatus: number
11
+ }>(`
12
+ const storageMod = await import('./src/lib/server/storage')
13
+ const repoMod = await import('@/lib/server/messages/message-repository')
14
+ const routeMod = await import('./src/app/api/chats/[id]/compact/route')
15
+ const storage = storageMod.default || storageMod
16
+ const repo = repoMod.default || repoMod
17
+ const route = routeMod.default || routeMod
18
+
19
+ const now = Date.now()
20
+ storage.saveSessions({
21
+ sess_compact_1: {
22
+ id: 'sess_compact_1',
23
+ name: 'Compact test',
24
+ cwd: process.env.WORKSPACE_DIR,
25
+ user: 'tester',
26
+ provider: 'openai',
27
+ model: 'gpt-4o-mini',
28
+ claudeSessionId: null,
29
+ messages: [],
30
+ createdAt: now,
31
+ lastActiveAt: now,
32
+ },
33
+ })
34
+
35
+ // Only 2 messages — smaller than default keepLastN (10), so compact should no-op.
36
+ repo.appendMessage('sess_compact_1', { role: 'user', text: 'hi', time: now })
37
+ repo.appendMessage('sess_compact_1', { role: 'assistant', text: 'hello', time: now + 1 })
38
+
39
+ const response = await route.POST(
40
+ new Request('http://local/api/chats/sess_compact_1/compact', {
41
+ method: 'POST',
42
+ headers: { 'content-type': 'application/json' },
43
+ body: JSON.stringify({}),
44
+ }),
45
+ { params: Promise.resolve({ id: 'sess_compact_1' }) },
46
+ )
47
+ const payload = await response.json()
48
+
49
+ const missingResponse = await route.POST(
50
+ new Request('http://local/api/chats/missing/compact', {
51
+ method: 'POST',
52
+ headers: { 'content-type': 'application/json' },
53
+ body: JSON.stringify({}),
54
+ }),
55
+ { params: Promise.resolve({ id: 'missing' }) },
56
+ )
57
+
58
+ console.log(JSON.stringify({
59
+ status: response.status,
60
+ payload,
61
+ missingStatus: missingResponse.status,
62
+ }))
63
+ `, { prefix: 'swarmclaw-compact-route-' })
64
+
65
+ assert.equal(output.status, 200)
66
+ assert.equal(output.payload.status, 'no_action')
67
+ assert.equal(output.payload.messageCount, 2)
68
+ assert.equal(output.payload.keepLastN, 10)
69
+ assert.equal(output.missingStatus, 404)
70
+ })
@@ -0,0 +1,68 @@
1
+ import assert from 'node:assert/strict'
2
+ import test from 'node:test'
3
+
4
+ import { runWithTempDataDir } from '@/lib/server/test-utils/run-with-temp-data-dir'
5
+
6
+ test('GET /api/chats/[id]/context-status returns token usage summary', () => {
7
+ const output = runWithTempDataDir<{
8
+ status: number
9
+ hasContextWindow: boolean
10
+ percentUsed: number
11
+ messageCount: number
12
+ strategy: string
13
+ missingStatus: number
14
+ }>(`
15
+ const storageMod = await import('./src/lib/server/storage')
16
+ const repoMod = await import('@/lib/server/messages/message-repository')
17
+ const routeMod = await import('./src/app/api/chats/[id]/context-status/route')
18
+ const storage = storageMod.default || storageMod
19
+ const repo = repoMod.default || repoMod
20
+ const route = routeMod.default || routeMod
21
+
22
+ const now = Date.now()
23
+ storage.saveSessions({
24
+ sess_ctx_1: {
25
+ id: 'sess_ctx_1',
26
+ name: 'Context status test',
27
+ cwd: process.env.WORKSPACE_DIR,
28
+ user: 'tester',
29
+ provider: 'openai',
30
+ model: 'gpt-4o-mini',
31
+ claudeSessionId: null,
32
+ messages: [],
33
+ createdAt: now,
34
+ lastActiveAt: now,
35
+ },
36
+ })
37
+
38
+ repo.appendMessage('sess_ctx_1', { role: 'user', text: 'hello world', time: now })
39
+ repo.appendMessage('sess_ctx_1', { role: 'assistant', text: 'hi there, how can I help?', time: now + 1 })
40
+
41
+ const response = await route.GET(
42
+ new Request('http://local/api/chats/sess_ctx_1/context-status'),
43
+ { params: Promise.resolve({ id: 'sess_ctx_1' }) },
44
+ )
45
+ const payload = await response.json()
46
+
47
+ const missingResponse = await route.GET(
48
+ new Request('http://local/api/chats/missing/context-status'),
49
+ { params: Promise.resolve({ id: 'missing' }) },
50
+ )
51
+
52
+ console.log(JSON.stringify({
53
+ status: response.status,
54
+ hasContextWindow: typeof payload.contextWindow === 'number' && payload.contextWindow > 0,
55
+ percentUsed: payload.percentUsed,
56
+ messageCount: payload.messageCount,
57
+ strategy: payload.strategy,
58
+ missingStatus: missingResponse.status,
59
+ }))
60
+ `, { prefix: 'swarmclaw-context-status-route-' })
61
+
62
+ assert.equal(output.status, 200)
63
+ assert.equal(output.hasContextWindow, true)
64
+ assert.ok(output.percentUsed >= 0 && output.percentUsed <= 100)
65
+ assert.equal(output.messageCount, 2)
66
+ assert.ok(['ok', 'warning', 'critical'].includes(output.strategy))
67
+ assert.equal(output.missingStatus, 404)
68
+ })
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
2
2
  import { safeParseBody } from '@/lib/server/safe-parse-body'
3
3
  import { loadMcpServers, saveMcpServers, deleteMcpServer } from '@/lib/server/storage'
4
4
  import { mutateItem, deleteItem, notFound, type CollectionOps } from '@/lib/server/collection-helpers'
5
+ import { evictMcpClient } from '@/lib/server/mcp-connection-pool'
5
6
 
6
7
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
7
8
  const ops: CollectionOps<any> = { load: loadMcpServers, save: saveMcpServers, deleteFn: deleteMcpServer }
@@ -21,11 +22,15 @@ export async function PUT(req: Request, { params }: { params: Promise<{ id: stri
21
22
  ...server, ...body, id, updatedAt: Date.now(),
22
23
  }))
23
24
  if (!result) return notFound()
25
+ // Connection pool caches by config fingerprint; evicting here is defense in
26
+ // depth — getOrConnectMcpClient also detects fingerprint mismatches.
27
+ await evictMcpClient(id)
24
28
  return NextResponse.json(result)
25
29
  }
26
30
 
27
31
  export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) {
28
32
  const { id } = await params
29
33
  if (!deleteItem(ops, id)) return notFound()
34
+ await evictMcpClient(id)
30
35
  return NextResponse.json({ deleted: id })
31
36
  }
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
2
2
  import { loadMcpServers } from '@/lib/server/storage'
3
3
  import { notFound } from '@/lib/server/collection-helpers'
4
4
  import { connectMcpServer, mcpToolsToLangChain, disconnectMcpServer } from '@/lib/server/mcp-client'
5
+ import { evictMcpClient } from '@/lib/server/mcp-connection-pool'
5
6
  import { errorMessage } from '@/lib/shared-utils'
6
7
 
7
8
  export async function POST(_req: Request, { params }: { params: Promise<{ id: string }> }) {
@@ -10,6 +11,10 @@ export async function POST(_req: Request, { params }: { params: Promise<{ id: st
10
11
  const server = servers[id]
11
12
  if (!server) return notFound()
12
13
 
14
+ // Force a fresh connection for the test — if a pooled client is in a weird
15
+ // state, the test button is the user's signal to rebuild it.
16
+ await evictMcpClient(id)
17
+
13
18
  try {
14
19
  const { client, transport } = await connectMcpServer(server)
15
20
  const tools = await mcpToolsToLangChain(client, server.name)
@@ -0,0 +1,75 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { loadMcpServers } from '@/lib/server/storage'
3
+ import { notFound } from '@/lib/server/collection-helpers'
4
+ import { connectMcpServer, disconnectMcpServer } from '@/lib/server/mcp-client'
5
+
6
+ // Tokenizer-free estimate — same formula as @swarmclawai/mcp-gateway's
7
+ // tokens.ts so the two numbers line up when users compare side-by-side.
8
+ const CHARS_PER_TOKEN = 3.5
9
+
10
+ function estimateToolTokens(tool: {
11
+ name: string
12
+ description?: string
13
+ inputSchema?: unknown
14
+ }): number {
15
+ const json = JSON.stringify({
16
+ name: tool.name,
17
+ description: tool.description ?? '',
18
+ inputSchema: tool.inputSchema ?? {},
19
+ })
20
+ return Math.ceil(json.length / CHARS_PER_TOKEN)
21
+ }
22
+
23
+ /**
24
+ * Discovery + token-cost endpoint for an MCP server. Connects, lists tools,
25
+ * estimates per-tool schema tokens, and returns aggregate totals — including
26
+ * how many tokens would actually be bound given the server's current
27
+ * alwaysExpose policy. The MCP servers UI uses this to render the token-cost
28
+ * badge on each card and the per-tool checklist in the allow-list editor.
29
+ */
30
+ export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
31
+ const { id } = await params
32
+ const servers = loadMcpServers()
33
+ const config = servers[id]
34
+ if (!config) return notFound()
35
+
36
+ let client: Awaited<ReturnType<typeof connectMcpServer>>['client'] | null = null
37
+ let transport: Awaited<ReturnType<typeof connectMcpServer>>['transport'] | null = null
38
+ try {
39
+ const conn = await connectMcpServer(config)
40
+ client = conn.client
41
+ transport = conn.transport
42
+ const { tools } = await client.listTools()
43
+ const detailed = tools.map((t: { name: string; description?: string; inputSchema?: unknown }) => ({
44
+ name: t.name,
45
+ description: t.description ?? '',
46
+ inputSchema: t.inputSchema ?? {},
47
+ tokens: estimateToolTokens(t),
48
+ }))
49
+ const totalTokens = detailed.reduce((n: number, t: { tokens: number }) => n + t.tokens, 0)
50
+ const mode = config.alwaysExpose === undefined ? true : config.alwaysExpose
51
+ const exposedTokens =
52
+ mode === true
53
+ ? totalTokens
54
+ : mode === false
55
+ ? 0
56
+ : detailed
57
+ .filter((t: { name: string }) => (mode as string[]).includes(t.name))
58
+ .reduce((n: number, t: { tokens: number }) => n + t.tokens, 0)
59
+ return NextResponse.json({
60
+ tools: detailed,
61
+ totalTokens,
62
+ exposedTokens,
63
+ alwaysExpose: mode,
64
+ })
65
+ } catch (err: unknown) {
66
+ return NextResponse.json(
67
+ { error: err instanceof Error ? err.message : 'MCP connection failed' },
68
+ { status: 502 },
69
+ )
70
+ } finally {
71
+ if (client && transport) {
72
+ await disconnectMcpServer(client, transport)
73
+ }
74
+ }
75
+ }
package/src/cli/index.js CHANGED
@@ -367,6 +367,7 @@ const COMMAND_GROUPS = [
367
367
  cmd('delete', 'DELETE', '/mcp-servers/:id', 'Delete MCP server'),
368
368
  cmd('test', 'POST', '/mcp-servers/:id/test', 'Test MCP server connection'),
369
369
  cmd('tools', 'GET', '/mcp-servers/:id/tools', 'List tools available on an MCP server'),
370
+ cmd('tools-info', 'GET', '/mcp-servers/:id/tools-info', 'List tools with token-cost estimates and exposure status'),
370
371
  cmd('conformance', 'POST', '/mcp-servers/:id/conformance', 'Run MCP conformance checks for a server', { expectsJsonBody: true }),
371
372
  cmd('invoke', 'POST', '/mcp-servers/:id/invoke', 'Invoke an MCP tool on a server', { expectsJsonBody: true }),
372
373
  ],
@@ -585,7 +586,10 @@ const COMMAND_GROUPS = [
585
586
  responseType: 'sse',
586
587
  }),
587
588
  cmd('stop', 'POST', '/chats/:id/stop', 'Stop chat run(s)'),
588
- cmd('clear', 'POST', '/chats/:id/clear', 'Clear chat messages'),
589
+ cmd('clear', 'POST', '/chats/:id/clear', 'Clear chat messages (returns undoToken with a 30s TTL)'),
590
+ cmd('clear-undo', 'POST', '/chats/:id/clear/undo', 'Restore a cleared chat via its undoToken', { expectsJsonBody: true }),
591
+ cmd('compact', 'POST', '/chats/:id/compact', 'Summarize and compact chat history (accepts optional keepLastN)', { expectsJsonBody: true }),
592
+ cmd('context-status', 'GET', '/chats/:id/context-status', 'Report token usage and context-window status for a chat'),
589
593
  cmd('browser-status', 'GET', '/chats/:id/browser', 'Check browser status'),
590
594
  cmd('browser-close', 'DELETE', '/chats/:id/browser', 'Close browser'),
591
595
  cmd('mailbox', 'GET', '/chats/:id/mailbox', 'List chat mailbox envelopes'),
package/src/cli/spec.js CHANGED
@@ -435,7 +435,10 @@ const COMMAND_GROUPS = {
435
435
  'edit-resend': { description: 'Edit and resend from a specific message index', method: 'POST', path: '/chats/:id/edit-resend', params: ['id'] },
436
436
  chat: { description: 'Send chat message (SSE stream)', method: 'POST', path: '/chats/:id/chat', params: ['id'], stream: true, waitable: true },
437
437
  stop: { description: 'Cancel active/running chat work', method: 'POST', path: '/chats/:id/stop', params: ['id'] },
438
- clear: { description: 'Clear chat history', method: 'POST', path: '/chats/:id/clear', params: ['id'] },
438
+ clear: { description: 'Clear chat history (returns undoToken with 30s TTL)', method: 'POST', path: '/chats/:id/clear', params: ['id'] },
439
+ 'clear-undo': { description: 'Restore a cleared chat via its undoToken', method: 'POST', path: '/chats/:id/clear/undo', params: ['id'] },
440
+ compact: { description: 'Summarize and compact chat history', method: 'POST', path: '/chats/:id/compact', params: ['id'] },
441
+ 'context-status': { description: 'Report token usage and context-window status', method: 'GET', path: '/chats/:id/context-status', params: ['id'] },
439
442
  mailbox: { description: 'List mailbox envelopes for a chat', method: 'GET', path: '/chats/:id/mailbox', params: ['id'] },
440
443
  'mailbox-action': { description: 'Send/ack/clear mailbox envelopes', method: 'POST', path: '/chats/:id/mailbox', params: ['id'] },
441
444
  queue: { description: 'List queued follow-up turns for a chat', method: 'GET', path: '/chats/:id/queue', params: ['id'] },
@@ -6,7 +6,9 @@ import { useAppStore } from '@/stores/use-app-store'
6
6
  import { selectActiveSessionId } from '@/stores/slices/session-slice'
7
7
  import { useWs } from '@/hooks/use-ws'
8
8
  import { useChatStore } from '@/stores/use-chat-store'
9
- import { fetchMessages, fetchMessagesPaginated, clearMessages, deleteChat, devServer, checkBrowser, stopBrowser } from '@/lib/chat/chats'
9
+ import { fetchMessages, fetchMessagesPaginated, clearMessages, undoClearMessages, deleteChat, devServer, checkBrowser, stopBrowser } from '@/lib/chat/chats'
10
+ import { toast } from 'sonner'
11
+ import { errorMessage } from '@/lib/shared-utils'
10
12
  import { uploadImage } from '@/lib/upload'
11
13
  import { deleteAgent } from '@/lib/agents'
12
14
  import { useMediaQuery } from '@/hooks/use-media-query'
@@ -432,11 +434,59 @@ export function ChatArea() {
432
434
  const handleClear = useCallback(async () => {
433
435
  setConfirmClear(false)
434
436
  if (!sessionId) return
435
- await clearMessages(sessionId)
436
- setMessages([], { startIndex: 0, totalMessages: 0 })
437
- await refreshSession(sessionId)
437
+ const targetSessionId = sessionId
438
+ let result
439
+ try {
440
+ result = await clearMessages(targetSessionId)
441
+ } catch (err) {
442
+ toast.error(`Clear failed: ${errorMessage(err)}`)
443
+ return
444
+ }
445
+ if (selectActiveSessionId(useAppStore.getState()) === targetSessionId) {
446
+ setMessages([], { startIndex: 0, totalMessages: 0 })
447
+ }
448
+ await refreshSession(targetSessionId)
449
+ const { undoToken, cleared } = result
450
+ if (!undoToken) return
451
+ const clearedLabel = cleared === 1 ? '1 message cleared' : `${cleared.toLocaleString()} messages cleared`
452
+ toast(clearedLabel, {
453
+ duration: 10_000,
454
+ action: {
455
+ label: 'Undo',
456
+ onClick: async () => {
457
+ try {
458
+ await undoClearMessages(targetSessionId, undoToken)
459
+ const restored = await fetchMessages(targetSessionId)
460
+ if (selectActiveSessionId(useAppStore.getState()) === targetSessionId) {
461
+ setMessages(restored, { startIndex: 0, totalMessages: restored.length })
462
+ }
463
+ await refreshSession(targetSessionId)
464
+ toast.success('Chat restored.')
465
+ } catch (err) {
466
+ toast.error(`Undo failed: ${errorMessage(err)}`)
467
+ }
468
+ },
469
+ },
470
+ })
438
471
  }, [refreshSession, sessionId, setMessages])
439
472
 
473
+ const handleCompactComplete = useCallback(async () => {
474
+ if (!sessionId) return
475
+ const targetSessionId = sessionId
476
+ try {
477
+ const refreshed = await fetchMessages(targetSessionId)
478
+ if (selectActiveSessionId(useAppStore.getState()) === targetSessionId) {
479
+ setMessages(refreshed, { startIndex: 0, totalMessages: refreshed.length })
480
+ }
481
+ } catch {
482
+ // silent — next poll will catch up
483
+ }
484
+ }, [sessionId, setMessages])
485
+
486
+ const handleClearRequest = useCallback(() => {
487
+ setConfirmClear(true)
488
+ }, [])
489
+
440
490
  const handleDelete = useCallback(async () => {
441
491
  setConfirmDelete(false)
442
492
  if (!sessionId) return
@@ -515,6 +565,9 @@ export function ChatArea() {
515
565
  connectorFilter={connectorFilter}
516
566
  onConnectorFilterChange={setConnectorFilter}
517
567
  hasMultipleSources={hasMultipleSources}
568
+ messageCount={messages.length}
569
+ onCompactComplete={handleCompactComplete}
570
+ onClearRequest={handleClearRequest}
518
571
  />
519
572
  )}
520
573
  {!isDesktop && (
@@ -533,6 +586,9 @@ export function ChatArea() {
533
586
  connectorFilter={connectorFilter}
534
587
  onConnectorFilterChange={setConnectorFilter}
535
588
  hasMultipleSources={hasMultipleSources}
589
+ messageCount={messages.length}
590
+ onCompactComplete={handleCompactComplete}
591
+ onClearRequest={handleClearRequest}
536
592
  />
537
593
  )}
538
594
  <DevServerBar status={devServerStatus} onStop={handleStopDevServer} />
@@ -660,8 +716,8 @@ export function ChatArea() {
660
716
 
661
717
  <ConfirmDialog
662
718
  open={confirmClear}
663
- title="Clear History"
664
- message="This will delete all messages in this chat. This cannot be undone."
719
+ title="Clear chat"
720
+ message="Clear every message in this chat. Long-term memory, skills, and facts are preserved. You'll have 10 seconds to undo."
665
721
  confirmLabel="Clear"
666
722
  danger
667
723
  onConfirm={handleClear}
@@ -17,6 +17,7 @@ import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip
17
17
  import { copyTextToClipboard } from '@/lib/clipboard'
18
18
  import { useNavigate } from '@/lib/app/navigation'
19
19
  import { getEnabledToolIds } from '@/lib/capability-selection'
20
+ import { ContextMeterBadge } from './context-meter-badge'
20
21
 
21
22
  function Tip({ label, children, side = 'bottom' }: { label: string; children: ReactNode; side?: 'top' | 'bottom' | 'left' | 'right' }) {
22
23
  return (
@@ -80,9 +81,12 @@ interface Props {
80
81
  connectorFilter?: string | null
81
82
  onConnectorFilterChange?: (filter: string | null) => void
82
83
  hasMultipleSources?: boolean
84
+ messageCount?: number
85
+ onCompactComplete?: () => void
86
+ onClearRequest?: () => void
83
87
  }
84
88
 
85
- export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, mobile, browserActive, onStopBrowser, onVoiceToggle, voiceActive, voiceSupported, connectorSources, connectorFilter, onConnectorFilterChange, hasMultipleSources }: Props) {
89
+ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, mobile, browserActive, onStopBrowser, onVoiceToggle, voiceActive, voiceSupported, connectorSources, connectorFilter, onConnectorFilterChange, hasMultipleSources, messageCount = 0, onCompactComplete, onClearRequest }: Props) {
86
90
  const now = useNow()
87
91
  const agentStatus = useChatStore((s) => s.agentStatus)
88
92
  const agents = useAppStore((s) => s.agents)
@@ -419,6 +423,14 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
419
423
  Responding
420
424
  </HeaderChip>
421
425
  )}
426
+ {messageCount > 0 && onCompactComplete && onClearRequest && (
427
+ <ContextMeterBadge
428
+ sessionId={session.id}
429
+ messageCount={messageCount}
430
+ onCompactComplete={onCompactComplete}
431
+ onClearRequest={onClearRequest}
432
+ />
433
+ )}
422
434
  </div>
423
435
  {liveStatus?.status && (
424
436
  <div className="mt-1.5 flex min-w-0 flex-wrap items-center gap-1.5">
@@ -0,0 +1,227 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useEffect, useRef, useState } from 'react'
4
+ import { toast } from 'sonner'
5
+ import {
6
+ compactChat,
7
+ fetchContextStatus,
8
+ type ContextStatusResponse,
9
+ } from '@/lib/chat/chats'
10
+ import { useWs } from '@/hooks/use-ws'
11
+ import { errorMessage } from '@/lib/shared-utils'
12
+
13
+ interface Props {
14
+ sessionId: string
15
+ messageCount: number
16
+ onCompactComplete: () => void
17
+ onClearRequest: () => void
18
+ }
19
+
20
+ function formatTokens(n: number): string {
21
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
22
+ if (n >= 1_000) return `${(n / 1_000).toFixed(n < 10_000 ? 1 : 0)}k`
23
+ return `${n}`
24
+ }
25
+
26
+ function resolveColor(strategy: ContextStatusResponse['strategy']): {
27
+ dot: string
28
+ text: string
29
+ border: string
30
+ bg: string
31
+ } {
32
+ if (strategy === 'critical') {
33
+ return {
34
+ dot: 'bg-red-400',
35
+ text: 'text-red-300',
36
+ border: 'border-red-500/25',
37
+ bg: 'bg-red-500/10',
38
+ }
39
+ }
40
+ if (strategy === 'warning') {
41
+ return {
42
+ dot: 'bg-amber-400',
43
+ text: 'text-amber-300',
44
+ border: 'border-amber-500/25',
45
+ bg: 'bg-amber-500/10',
46
+ }
47
+ }
48
+ return {
49
+ dot: 'bg-emerald-400/80',
50
+ text: 'text-text-3/70',
51
+ border: 'border-white/[0.06]',
52
+ bg: 'bg-white/[0.03]',
53
+ }
54
+ }
55
+
56
+ export function ContextMeterBadge({ sessionId, messageCount, onCompactComplete, onClearRequest }: Props) {
57
+ const [status, setStatus] = useState<ContextStatusResponse | null>(null)
58
+ const [open, setOpen] = useState(false)
59
+ const [compacting, setCompacting] = useState(false)
60
+ const anchorRef = useRef<HTMLDivElement>(null)
61
+ const panelRef = useRef<HTMLDivElement>(null)
62
+
63
+ const loadStatus = useCallback(async () => {
64
+ try {
65
+ const next = await fetchContextStatus(sessionId)
66
+ setStatus(next)
67
+ } catch {
68
+ // silent — badge just won't render
69
+ }
70
+ }, [sessionId])
71
+
72
+ useEffect(() => {
73
+ void loadStatus()
74
+ }, [loadStatus, messageCount])
75
+
76
+ useWs(`messages:${sessionId}`, loadStatus)
77
+
78
+ useEffect(() => {
79
+ if (!open) return
80
+ const handler = (event: MouseEvent) => {
81
+ const target = event.target as Node
82
+ if (anchorRef.current?.contains(target)) return
83
+ if (panelRef.current?.contains(target)) return
84
+ setOpen(false)
85
+ }
86
+ document.addEventListener('mousedown', handler)
87
+ return () => document.removeEventListener('mousedown', handler)
88
+ }, [open])
89
+
90
+ useEffect(() => {
91
+ if (!open) return
92
+ const handler = (event: KeyboardEvent) => {
93
+ if (event.key === 'Escape') setOpen(false)
94
+ }
95
+ document.addEventListener('keydown', handler)
96
+ return () => document.removeEventListener('keydown', handler)
97
+ }, [open])
98
+
99
+ const handleCompact = useCallback(async () => {
100
+ if (compacting) return
101
+ setCompacting(true)
102
+ try {
103
+ const result = await compactChat(sessionId)
104
+ if (result.status === 'no_action') {
105
+ toast('Nothing to compact yet.')
106
+ } else {
107
+ toast.success(
108
+ `Compacted ${result.prunedCount ?? 0} message${result.prunedCount === 1 ? '' : 's'}.`,
109
+ )
110
+ }
111
+ onCompactComplete()
112
+ await loadStatus()
113
+ setOpen(false)
114
+ } catch (err) {
115
+ toast.error(`Compact failed: ${errorMessage(err)}`)
116
+ } finally {
117
+ setCompacting(false)
118
+ }
119
+ }, [compacting, loadStatus, onCompactComplete, sessionId])
120
+
121
+ const handleClearClick = useCallback(() => {
122
+ setOpen(false)
123
+ onClearRequest()
124
+ }, [onClearRequest])
125
+
126
+ if (!status) return null
127
+ const colors = resolveColor(status.strategy)
128
+ const percent = Math.min(100, Math.max(0, status.percentUsed))
129
+
130
+ return (
131
+ <div className="relative" ref={anchorRef}>
132
+ <button
133
+ type="button"
134
+ onClick={() => setOpen((prev) => !prev)}
135
+ className={`inline-flex items-center gap-1.5 rounded-[9px] border px-2.5 py-1 text-[10px] font-600 transition-colors shrink-0 cursor-pointer ${colors.bg} ${colors.border} ${colors.text} hover:border-white/[0.15] hover:text-text-2`}
136
+ title={`${status.effectiveTokens.toLocaleString()} of ${status.contextWindow.toLocaleString()} tokens used`}
137
+ aria-expanded={open}
138
+ aria-label={`Context usage ${percent}%. Click for details.`}
139
+ >
140
+ <span className={`w-1.5 h-1.5 rounded-full ${colors.dot}`} />
141
+ <span>{percent}%</span>
142
+ <span className="text-text-3/45 font-500">
143
+ {formatTokens(status.effectiveTokens)}
144
+ </span>
145
+ </button>
146
+ {open && (
147
+ <div
148
+ ref={panelRef}
149
+ className="absolute right-0 top-[calc(100%+6px)] z-50 w-[280px] rounded-[14px] border border-white/[0.08] bg-raised/95 p-3 shadow-[0_16px_64px_rgba(0,0,0,0.6)] backdrop-blur-xl"
150
+ style={{ animation: 'fade-in 0.15s cubic-bezier(0.16, 1, 0.3, 1)' }}
151
+ >
152
+ <div className="flex items-baseline justify-between">
153
+ <span className="text-[11px] font-600 uppercase tracking-wider text-text-3/60">Context window</span>
154
+ <span className={`text-[11px] font-600 ${colors.text}`}>{percent}%</span>
155
+ </div>
156
+ <div className="mt-2 h-1.5 w-full rounded-full bg-white/[0.06] overflow-hidden">
157
+ <div
158
+ className={`h-full rounded-full transition-all ${
159
+ status.strategy === 'critical' ? 'bg-red-400'
160
+ : status.strategy === 'warning' ? 'bg-amber-400'
161
+ : 'bg-emerald-400/80'
162
+ }`}
163
+ style={{ width: `${percent}%` }}
164
+ />
165
+ </div>
166
+ <dl className="mt-3 space-y-1 text-[11px]">
167
+ <div className="flex justify-between">
168
+ <dt className="text-text-3/60">Used</dt>
169
+ <dd className="text-text-2 font-mono">
170
+ {status.effectiveTokens.toLocaleString()} / {status.contextWindow.toLocaleString()}
171
+ </dd>
172
+ </div>
173
+ <div className="flex justify-between">
174
+ <dt className="text-text-3/60">Remaining</dt>
175
+ <dd className="text-text-2 font-mono">{status.remainingTokens.toLocaleString()}</dd>
176
+ </div>
177
+ <div className="flex justify-between">
178
+ <dt className="text-text-3/60">Messages</dt>
179
+ <dd className="text-text-2 font-mono">{status.messageCount}</dd>
180
+ </div>
181
+ </dl>
182
+ <div className="mt-3 flex gap-2">
183
+ <button
184
+ type="button"
185
+ onClick={handleCompact}
186
+ disabled={compacting || status.messageCount < 3}
187
+ className="flex-1 inline-flex items-center justify-center gap-1.5 rounded-[10px] border border-accent-bright/25 bg-accent-soft/40 px-2.5 py-1.5 text-[11px] font-600 text-accent-bright transition-colors hover:border-accent-bright/40 hover:bg-accent-soft/60 disabled:cursor-not-allowed disabled:opacity-50"
188
+ >
189
+ {compacting ? (
190
+ <>
191
+ <span className="w-3 h-3 rounded-full border-2 border-accent-bright/30 border-t-accent-bright animate-spin" />
192
+ Compacting
193
+ </>
194
+ ) : (
195
+ <>
196
+ <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
197
+ <path d="M4 14h6v6" />
198
+ <path d="M20 10h-6V4" />
199
+ <path d="M14 10 21 3" />
200
+ <path d="M3 21l7-7" />
201
+ </svg>
202
+ Compact
203
+ </>
204
+ )}
205
+ </button>
206
+ <button
207
+ type="button"
208
+ onClick={handleClearClick}
209
+ className="flex-1 inline-flex items-center justify-center gap-1.5 rounded-[10px] border border-white/[0.08] bg-white/[0.03] px-2.5 py-1.5 text-[11px] font-600 text-text-2 transition-colors hover:border-red-500/25 hover:bg-red-500/10 hover:text-red-300"
210
+ >
211
+ <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
212
+ <path d="M3 6h18" />
213
+ <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" />
214
+ <path d="M10 11v6" />
215
+ <path d="M14 11v6" />
216
+ </svg>
217
+ Clear
218
+ </button>
219
+ </div>
220
+ <p className="mt-2.5 text-[10px] leading-relaxed text-text-3/55">
221
+ Long-term memory, skills, and facts are preserved. Clear only affects this chat transcript.
222
+ </p>
223
+ </div>
224
+ )}
225
+ </div>
226
+ )
227
+ }