bazilion 0.0.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 (260) hide show
  1. package/.understand-anything/.understandignore +25 -0
  2. package/.understand-anything/fingerprints.json +14267 -0
  3. package/.understand-anything/knowledge-graph.json +18128 -0
  4. package/.understand-anything/meta.json +6 -0
  5. package/CLAUDE.md +164 -0
  6. package/LICENSE +21 -0
  7. package/README.md +195 -0
  8. package/apps/cli/package.json +21 -0
  9. package/apps/cli/src/auth-file.ts +18 -0
  10. package/apps/cli/src/client.ts +37 -0
  11. package/apps/cli/src/columnize.ts +32 -0
  12. package/apps/cli/src/commands/agent.ts +574 -0
  13. package/apps/cli/src/commands/auth.ts +110 -0
  14. package/apps/cli/src/commands/backup.ts +135 -0
  15. package/apps/cli/src/commands/completion.ts +155 -0
  16. package/apps/cli/src/commands/config.ts +95 -0
  17. package/apps/cli/src/commands/doctor.ts +131 -0
  18. package/apps/cli/src/commands/group.ts +132 -0
  19. package/apps/cli/src/commands/inbox.ts +82 -0
  20. package/apps/cli/src/commands/login.ts +73 -0
  21. package/apps/cli/src/commands/memory.ts +106 -0
  22. package/apps/cli/src/commands/profile.ts +259 -0
  23. package/apps/cli/src/commands/provider.ts +170 -0
  24. package/apps/cli/src/commands/send.ts +24 -0
  25. package/apps/cli/src/commands/serve.ts +89 -0
  26. package/apps/cli/src/commands/skill.ts +120 -0
  27. package/apps/cli/src/commands/token.ts +148 -0
  28. package/apps/cli/src/commands/trigger.ts +129 -0
  29. package/apps/cli/src/commands/uninstall.ts +156 -0
  30. package/apps/cli/src/index.ts +196 -0
  31. package/apps/cli/src/paths.ts +12 -0
  32. package/apps/cli/test/agent.test.ts +213 -0
  33. package/apps/cli/test/backup.test.ts +95 -0
  34. package/apps/cli/test/chat.test.ts +539 -0
  35. package/apps/cli/test/columnize.test.ts +29 -0
  36. package/apps/cli/test/completion.test.ts +45 -0
  37. package/apps/cli/test/config-page.test.ts +151 -0
  38. package/apps/cli/test/group.test.ts +74 -0
  39. package/apps/cli/test/helpers.ts +70 -0
  40. package/apps/cli/test/inbox-autodeliver.test.ts +143 -0
  41. package/apps/cli/test/inbox.test.ts +147 -0
  42. package/apps/cli/test/memory.test.ts +69 -0
  43. package/apps/cli/test/profile.test.ts +110 -0
  44. package/apps/cli/test/send.test.ts +30 -0
  45. package/apps/cli/test/server-fixture.ts +212 -0
  46. package/apps/cli/test/session-head.test.ts +45 -0
  47. package/apps/cli/test/skill.test.ts +128 -0
  48. package/apps/cli/test/token.test.ts +109 -0
  49. package/apps/cli/test/trigger.test.ts +245 -0
  50. package/apps/cli/tsconfig.json +4 -0
  51. package/apps/daemon/package.json +31 -0
  52. package/apps/daemon/src/app.ts +41 -0
  53. package/apps/daemon/src/core/agent/archive.ts +8 -0
  54. package/apps/daemon/src/core/agent/delete.ts +30 -0
  55. package/apps/daemon/src/core/agent/resolve.ts +31 -0
  56. package/apps/daemon/src/core/agent/spawn.ts +95 -0
  57. package/apps/daemon/src/core/agent/unarchive.ts +11 -0
  58. package/apps/daemon/src/core/availableModels.ts +58 -0
  59. package/apps/daemon/src/core/db/client.ts +113 -0
  60. package/apps/daemon/src/core/db/migrate.ts +41 -0
  61. package/apps/daemon/src/core/db/migrations/0001_init.sql +179 -0
  62. package/apps/daemon/src/core/group/delete.ts +21 -0
  63. package/apps/daemon/src/core/group/register.ts +68 -0
  64. package/apps/daemon/src/core/index.ts +71 -0
  65. package/apps/daemon/src/core/paths.ts +56 -0
  66. package/apps/daemon/src/core/profile/create.ts +78 -0
  67. package/apps/daemon/src/core/profile/delete.ts +26 -0
  68. package/apps/daemon/src/core/profile/identity.ts +70 -0
  69. package/apps/daemon/src/core/profile/load.ts +45 -0
  70. package/apps/daemon/src/core/profile/seed.ts +74 -0
  71. package/apps/daemon/src/core/profile/templates.ts +60 -0
  72. package/apps/daemon/src/core/profile/update.ts +57 -0
  73. package/apps/daemon/src/core/profile/validate.ts +9 -0
  74. package/apps/daemon/src/core/repos/agents.ts +202 -0
  75. package/apps/daemon/src/core/repos/config.ts +78 -0
  76. package/apps/daemon/src/core/repos/groups.ts +55 -0
  77. package/apps/daemon/src/core/repos/messages.ts +127 -0
  78. package/apps/daemon/src/core/repos/profiles.ts +83 -0
  79. package/apps/daemon/src/core/repos/providerModels.ts +58 -0
  80. package/apps/daemon/src/core/repos/providerState.ts +37 -0
  81. package/apps/daemon/src/core/repos/secrets.ts +145 -0
  82. package/apps/daemon/src/core/repos/skillMeta.ts +49 -0
  83. package/apps/daemon/src/core/repos/triggers.ts +101 -0
  84. package/apps/daemon/src/core/repos/webTokens.ts +87 -0
  85. package/apps/daemon/src/core/secrets.ts +65 -0
  86. package/apps/daemon/src/core/services.ts +264 -0
  87. package/apps/daemon/src/core/skills/discover.ts +28 -0
  88. package/apps/daemon/src/core/skills/import.ts +136 -0
  89. package/apps/daemon/src/core/skills/parse.ts +52 -0
  90. package/apps/daemon/src/core/skills/resolve.ts +50 -0
  91. package/apps/daemon/src/index.ts +45 -0
  92. package/apps/daemon/src/lib/agent-cancel.ts +48 -0
  93. package/apps/daemon/src/lib/agent-id.ts +13 -0
  94. package/apps/daemon/src/lib/agent-turn.ts +56 -0
  95. package/apps/daemon/src/lib/api-key.ts +56 -0
  96. package/apps/daemon/src/lib/auth.ts +41 -0
  97. package/apps/daemon/src/lib/cron.ts +93 -0
  98. package/apps/daemon/src/lib/ctx.ts +80 -0
  99. package/apps/daemon/src/lib/messaging-host.ts +34 -0
  100. package/apps/daemon/src/lib/middleware-auth.ts +52 -0
  101. package/apps/daemon/src/lib/scheduler.ts +294 -0
  102. package/apps/daemon/src/routes/agents.ts +772 -0
  103. package/apps/daemon/src/routes/auth-login.ts +193 -0
  104. package/apps/daemon/src/routes/config.ts +267 -0
  105. package/apps/daemon/src/routes/groups.ts +133 -0
  106. package/apps/daemon/src/routes/messages.ts +29 -0
  107. package/apps/daemon/src/routes/misc.ts +239 -0
  108. package/apps/daemon/src/routes/profiles.ts +197 -0
  109. package/apps/daemon/src/routes/skills.ts +123 -0
  110. package/apps/daemon/src/routes/triggers.ts +29 -0
  111. package/apps/daemon/src/runtime/auth/openai-codex.ts +121 -0
  112. package/apps/daemon/src/runtime/auto-reply/heartbeat.ts +31 -0
  113. package/apps/daemon/src/runtime/index.ts +77 -0
  114. package/apps/daemon/src/runtime/memory/files.ts +103 -0
  115. package/apps/daemon/src/runtime/memory/qmd.ts +152 -0
  116. package/apps/daemon/src/runtime/memory/types.ts +16 -0
  117. package/apps/daemon/src/runtime/pi/events.ts +173 -0
  118. package/apps/daemon/src/runtime/pi/session.ts +536 -0
  119. package/apps/daemon/src/runtime/pi/tools.ts +85 -0
  120. package/apps/daemon/src/runtime/providers/catalog.ts +145 -0
  121. package/apps/daemon/src/runtime/providers/pi-adapter.ts +272 -0
  122. package/apps/daemon/src/runtime/providers/registry.ts +374 -0
  123. package/apps/daemon/src/runtime/providers/retry.ts +176 -0
  124. package/apps/daemon/src/runtime/providers/types.ts +33 -0
  125. package/apps/daemon/src/runtime/session/prompt.ts +83 -0
  126. package/apps/daemon/src/runtime/tools/bootstrap.ts +22 -0
  127. package/apps/daemon/src/runtime/tools/home.ts +114 -0
  128. package/apps/daemon/src/runtime/tools/memory.ts +81 -0
  129. package/apps/daemon/src/runtime/tools/messaging.ts +127 -0
  130. package/apps/daemon/src/runtime/tools/registry.ts +29 -0
  131. package/apps/daemon/src/runtime/tools/types.ts +13 -0
  132. package/apps/daemon/src/runtime/tools/web-extract.ts +110 -0
  133. package/apps/daemon/src/runtime/tools/web-ssrf.ts +245 -0
  134. package/apps/daemon/src/runtime/tools/web.ts +273 -0
  135. package/apps/daemon/src/runtime/worker/entry.ts +221 -0
  136. package/apps/daemon/src/runtime/worker/ipc-protocol.ts +75 -0
  137. package/apps/daemon/src/runtime/worker/spawn.ts +249 -0
  138. package/apps/daemon/test/core/agents.test.ts +319 -0
  139. package/apps/daemon/test/core/available-models.test.ts +63 -0
  140. package/apps/daemon/test/core/config.test.ts +99 -0
  141. package/apps/daemon/test/core/groups.test.ts +63 -0
  142. package/apps/daemon/test/core/helpers.ts +50 -0
  143. package/apps/daemon/test/core/identity.test.ts +85 -0
  144. package/apps/daemon/test/core/migrations.test.ts +83 -0
  145. package/apps/daemon/test/core/profiles.test.ts +182 -0
  146. package/apps/daemon/test/core/provider-models.test.ts +57 -0
  147. package/apps/daemon/test/core/provider-state.test.ts +36 -0
  148. package/apps/daemon/test/core/skill-meta.test.ts +45 -0
  149. package/apps/daemon/test/core/skills.test.ts +271 -0
  150. package/apps/daemon/test/core/triggers.test.ts +182 -0
  151. package/apps/daemon/test/core/web-tokens.test.ts +79 -0
  152. package/apps/daemon/test/cron.test.ts +90 -0
  153. package/apps/daemon/test/runtime/heartbeat.test.ts +34 -0
  154. package/apps/daemon/test/runtime/memory-qmd.test.ts +97 -0
  155. package/apps/daemon/test/runtime/memory.test.ts +78 -0
  156. package/apps/daemon/test/runtime/messaging.test.ts +213 -0
  157. package/apps/daemon/test/runtime/mock-server.ts +65 -0
  158. package/apps/daemon/test/runtime/openai-codex-auth.test.ts +116 -0
  159. package/apps/daemon/test/runtime/providers.test.ts +306 -0
  160. package/apps/daemon/test/runtime/retry.test.ts +191 -0
  161. package/apps/daemon/test/runtime/session-head.test.ts +90 -0
  162. package/apps/daemon/test/runtime/tools-home.test.ts +102 -0
  163. package/apps/daemon/test/runtime/tools-web.test.ts +206 -0
  164. package/apps/daemon/tsconfig.json +4 -0
  165. package/apps/mobile/README.md +60 -0
  166. package/apps/mobile/app/_layout.tsx +58 -0
  167. package/apps/mobile/app/agents/[id]/chat.tsx +486 -0
  168. package/apps/mobile/app/agents/[id]/index.tsx +166 -0
  169. package/apps/mobile/app/agents/index.tsx +212 -0
  170. package/apps/mobile/app/index.tsx +21 -0
  171. package/apps/mobile/app/pair.tsx +226 -0
  172. package/apps/mobile/app/settings.tsx +419 -0
  173. package/apps/mobile/app.json +49 -0
  174. package/apps/mobile/assets/adaptive-icon.png +0 -0
  175. package/apps/mobile/assets/favicon.png +0 -0
  176. package/apps/mobile/assets/icon.png +0 -0
  177. package/apps/mobile/assets/splash-icon.png +0 -0
  178. package/apps/mobile/babel.config.js +6 -0
  179. package/apps/mobile/metro.config.js +28 -0
  180. package/apps/mobile/package.json +44 -0
  181. package/apps/mobile/src/auth.ts +66 -0
  182. package/apps/mobile/src/pair-url.ts +48 -0
  183. package/apps/mobile/src/theme-context.tsx +88 -0
  184. package/apps/mobile/src/theme.ts +135 -0
  185. package/apps/mobile/test/pair-url.test.ts +46 -0
  186. package/apps/mobile/tsconfig.json +23 -0
  187. package/apps/web/components.json +25 -0
  188. package/apps/web/package.json +39 -0
  189. package/apps/web/public/baziu.svg +8 -0
  190. package/apps/web/src/components/AgentTabs.tsx +45 -0
  191. package/apps/web/src/components/BaziuLogo.tsx +21 -0
  192. package/apps/web/src/components/ChatPane.tsx +1033 -0
  193. package/apps/web/src/components/ConfigTabs.tsx +29 -0
  194. package/apps/web/src/components/CopyButton.tsx +68 -0
  195. package/apps/web/src/components/CreateGroupDialog.tsx +127 -0
  196. package/apps/web/src/components/FieldRow.tsx +94 -0
  197. package/apps/web/src/components/Footer.tsx +10 -0
  198. package/apps/web/src/components/PawIcon.tsx +15 -0
  199. package/apps/web/src/components/Sidebar.tsx +287 -0
  200. package/apps/web/src/components/SpawnDialog.tsx +129 -0
  201. package/apps/web/src/components/ThemeToggle.tsx +75 -0
  202. package/apps/web/src/components/TopNav.tsx +34 -0
  203. package/apps/web/src/components/ui/button.tsx +67 -0
  204. package/apps/web/src/components/ui/card.tsx +103 -0
  205. package/apps/web/src/components/ui/checkbox.tsx +31 -0
  206. package/apps/web/src/components/ui/dialog.tsx +168 -0
  207. package/apps/web/src/components/ui/input.tsx +19 -0
  208. package/apps/web/src/components/ui/label.tsx +22 -0
  209. package/apps/web/src/components/ui/radio-group.tsx +44 -0
  210. package/apps/web/src/components/ui/select.tsx +192 -0
  211. package/apps/web/src/components/ui/separator.tsx +26 -0
  212. package/apps/web/src/components/ui/table.tsx +116 -0
  213. package/apps/web/src/components/ui/tabs.tsx +88 -0
  214. package/apps/web/src/components/ui/textarea.tsx +18 -0
  215. package/apps/web/src/lib/auth.ts +50 -0
  216. package/apps/web/src/lib/daemon-client.ts +34 -0
  217. package/apps/web/src/lib/md.ts +45 -0
  218. package/apps/web/src/lib/utils.ts +6 -0
  219. package/apps/web/src/lib/wire-constants.ts +27 -0
  220. package/apps/web/src/routeTree.gen.ts +408 -0
  221. package/apps/web/src/router.tsx +20 -0
  222. package/apps/web/src/routes/__root.tsx +123 -0
  223. package/apps/web/src/routes/agents/$id/inbox.tsx +207 -0
  224. package/apps/web/src/routes/agents/$id/index.tsx +527 -0
  225. package/apps/web/src/routes/agents/$id/triggers.tsx +239 -0
  226. package/apps/web/src/routes/agents/index.tsx +265 -0
  227. package/apps/web/src/routes/api/$.ts +88 -0
  228. package/apps/web/src/routes/config/index.tsx +315 -0
  229. package/apps/web/src/routes/config/services.tsx +49 -0
  230. package/apps/web/src/routes/config/tokens.tsx +192 -0
  231. package/apps/web/src/routes/groups/$id/index.tsx +153 -0
  232. package/apps/web/src/routes/groups/$id/memory.tsx +321 -0
  233. package/apps/web/src/routes/groups/index.tsx +191 -0
  234. package/apps/web/src/routes/index.tsx +133 -0
  235. package/apps/web/src/routes/login.tsx +63 -0
  236. package/apps/web/src/routes/profiles/$id.tsx +549 -0
  237. package/apps/web/src/routes/profiles/index.tsx +458 -0
  238. package/apps/web/src/routes/skills/index.tsx +297 -0
  239. package/apps/web/src/routes/welcome.tsx +61 -0
  240. package/apps/web/src/styles.css +449 -0
  241. package/apps/web/tsconfig.json +25 -0
  242. package/apps/web/vite.config.ts +25 -0
  243. package/biome.json +23 -0
  244. package/docs/agent-engine.md +219 -0
  245. package/docs/architecture.md +627 -0
  246. package/docs/backlog/README.md +42 -0
  247. package/docs/backlog/draft/BAZ-001-a2a-federation-spike.md +125 -0
  248. package/docs/openclaw-reference.md +210 -0
  249. package/package.json +38 -0
  250. package/packages/api-types/package.json +11 -0
  251. package/packages/api-types/src/entities.ts +146 -0
  252. package/packages/api-types/src/events.ts +55 -0
  253. package/packages/api-types/src/index.ts +488 -0
  254. package/packages/api-types/src/memory.ts +15 -0
  255. package/packages/client/package.json +13 -0
  256. package/packages/client/src/index.ts +117 -0
  257. package/pnpm-workspace.yaml +3 -0
  258. package/tsconfig.base.json +23 -0
  259. package/tsconfig.json +11 -0
  260. package/vitest.config.ts +22 -0
@@ -0,0 +1,1033 @@
1
+ // Chat panel: renders the running transcript, sends messages, consumes the
2
+ // daemon's NDJSON ChatFrame stream. Initial messages come from the route
3
+ // loader; new ones land via fetch + ReadableStream consume.
4
+
5
+ import type { ChatFrame, ProviderMessage, SessionHeadResponse } from '@bazilion/api-types'
6
+ import { type ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
7
+ import { renderMd } from '../lib/md'
8
+
9
+ const INBOX_WAKE_PREFIX = '[[bazilion:inbox-wake]]\n'
10
+ const COMPACTION_REPLAY_PREFIX = '[conversation summary]'
11
+ const AUTOSCROLL_THRESHOLD_PX = 40
12
+ const SESSION_POLL_MS = 10_000
13
+ const TOOL_GROUP_MAX_HEIGHT_PX = 320
14
+ const MAX_INPUT_HEIGHT = 200
15
+
16
+ type ToolItem = {
17
+ kind: 'call' | 'result' | 'error'
18
+ id: string
19
+ name: string
20
+ body: string
21
+ }
22
+
23
+ type RenderEntry =
24
+ | { type: 'user'; content: string }
25
+ | { type: 'assistant'; content: string }
26
+ | { type: 'tool'; items: ToolItem[] }
27
+ | { type: 'system'; content: string }
28
+ | { type: 'error'; content: string }
29
+
30
+ const SLASH_HELP =
31
+ 'slash commands:\n' +
32
+ ' /context — context breakdown (system prompt, tools, skills, history)\n' +
33
+ ' /compact [N] — summarize the head; keep the last N messages verbatim (default 10)\n' +
34
+ ' /reset — reset chat history for this agent\n' +
35
+ ' /help — show this list'
36
+
37
+ interface ChatContextResponse {
38
+ agentId: string
39
+ model: string
40
+ systemPrompt: { chars: number; tokens: number; files: { name: string; chars: number }[] }
41
+ tools: {
42
+ count: number
43
+ listChars: number
44
+ schemaChars: number
45
+ entries: { name: string; schemaChars: number; paramCount: number | null }[]
46
+ }
47
+ skills: { count: number; entries: { name: string; blockChars: number }[] }
48
+ history: {
49
+ messageEntries: number
50
+ compactionEntries: number
51
+ chars: number
52
+ bytes: number
53
+ tokensEstimate: number
54
+ }
55
+ totals: { chars: number; tokens: number }
56
+ }
57
+
58
+ interface ChatCompactResponse {
59
+ before: number
60
+ after: number
61
+ summarized: number
62
+ keptTail: number
63
+ tokensBefore: number
64
+ tokensAfter: number
65
+ summary: string
66
+ }
67
+
68
+ function prettyArgs(raw: string): string {
69
+ if (!raw) return ''
70
+ try {
71
+ return JSON.stringify(JSON.parse(raw), null, 2)
72
+ } catch {
73
+ return raw
74
+ }
75
+ }
76
+
77
+ function formatBytes(n: number): string {
78
+ if (n < 1024) return `${n} B`
79
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KiB`
80
+ return `${(n / (1024 * 1024)).toFixed(2)} MiB`
81
+ }
82
+
83
+ // Project canonical ProviderMessage[] into render entries: assistant content +
84
+ // any tool calls, then tool-role messages collapse into the same group.
85
+ function projectMessages(msgs: ProviderMessage[]): RenderEntry[] {
86
+ const entries: RenderEntry[] = []
87
+ let openTool: { type: 'tool'; items: ToolItem[] } | null = null
88
+ for (const m of msgs) {
89
+ if (m.role === 'user') {
90
+ openTool = null
91
+ entries.push({ type: 'user', content: m.content })
92
+ } else if (m.role === 'assistant') {
93
+ if (m.content) {
94
+ openTool = null
95
+ entries.push({ type: 'assistant', content: m.content })
96
+ }
97
+ if (m.toolCalls && m.toolCalls.length > 0) {
98
+ if (!openTool) {
99
+ openTool = { type: 'tool', items: [] }
100
+ entries.push(openTool)
101
+ }
102
+ for (const tc of m.toolCalls) {
103
+ openTool.items.push({ kind: 'call', id: tc.id, name: tc.name, body: tc.arguments })
104
+ }
105
+ }
106
+ } else if (m.role === 'tool') {
107
+ if (!openTool) {
108
+ openTool = { type: 'tool', items: [] }
109
+ entries.push(openTool)
110
+ }
111
+ const isError = m.content.startsWith('Error:') || m.content.startsWith('error:')
112
+ openTool.items.push({
113
+ kind: isError ? 'error' : 'result',
114
+ id: m.toolCallId ?? '',
115
+ name: m.toolName ?? '',
116
+ body: m.content,
117
+ })
118
+ }
119
+ }
120
+ return entries
121
+ }
122
+
123
+ export interface ChatPaneProps {
124
+ agentId: string
125
+ agentName: string
126
+ initialMessages: ProviderMessage[]
127
+ /** SSR snapshot of the session-file head; the stale-banner poll compares against it. */
128
+ initialSessionHead?: SessionHeadResponse
129
+ }
130
+
131
+ export function ChatPane({
132
+ agentId,
133
+ agentName,
134
+ initialMessages,
135
+ initialSessionHead,
136
+ }: ChatPaneProps) {
137
+ const [serverMessages, setServerMessages] = useState<ProviderMessage[]>(initialMessages)
138
+ const [liveEntries, setLiveEntries] = useState<RenderEntry[]>([])
139
+ const [systemBubbles, setSystemBubbles] = useState<
140
+ Array<{ id: number; content: string; afterIdx: number }>
141
+ >([])
142
+ const [thinking, setThinking] = useState(false)
143
+ const [streaming, setStreaming] = useState(false)
144
+ const [editIdx, setEditIdx] = useState<number | null>(null)
145
+ const [input, setInput] = useState('')
146
+ const [staleBanner, setStaleBanner] = useState(false)
147
+
148
+ const messagesRef = useRef<HTMLDivElement | null>(null)
149
+ const inputRef = useRef<HTMLTextAreaElement | null>(null)
150
+ const currentAbortRef = useRef<AbortController | null>(null)
151
+ // Slash-command output bubbles are anchored to the count of visible
152
+ // (non-system) entries at push time, so they stay in place when later
153
+ // messages arrive instead of being pinned to the bottom of the transcript.
154
+ const systemIdRef = useRef(0)
155
+ const visibleEntryCountRef = useRef(0)
156
+ const knownHeadRef = useRef<SessionHeadResponse>(
157
+ initialSessionHead ?? { file: null, size: 0 },
158
+ )
159
+ // Keep current streaming/state references stable across closures (the poll
160
+ // loop and visibilitychange handler both read them).
161
+ const streamingRef = useRef(false)
162
+ streamingRef.current = streaming
163
+
164
+ // Re-seed when the agent prop changes (the loader returns new initialMessages
165
+ // for a different agent on the home page).
166
+ // biome-ignore lint/correctness/useExhaustiveDependencies: explicit reset on agent switch
167
+ useEffect(() => {
168
+ setServerMessages(initialMessages)
169
+ setLiveEntries([])
170
+ setSystemBubbles([])
171
+ setEditIdx(null)
172
+ setInput('')
173
+ setThinking(false)
174
+ setStreaming(false)
175
+ setStaleBanner(false)
176
+ knownHeadRef.current = initialSessionHead ?? { file: null, size: 0 }
177
+ }, [agentId])
178
+
179
+ // --- smart autoscroll ---
180
+ const wasNearBottomRef = useRef(true)
181
+ useLayoutEffect(() => {
182
+ const el = messagesRef.current
183
+ if (!el) return
184
+ if (wasNearBottomRef.current) {
185
+ el.scrollTop = el.scrollHeight
186
+ }
187
+ })
188
+ function captureScroll() {
189
+ const el = messagesRef.current
190
+ if (!el) {
191
+ wasNearBottomRef.current = true
192
+ return
193
+ }
194
+ wasNearBottomRef.current =
195
+ el.scrollHeight - el.scrollTop - el.clientHeight < AUTOSCROLL_THRESHOLD_PX
196
+ }
197
+
198
+ // --- textarea auto-resize ---
199
+ useLayoutEffect(() => {
200
+ const el = inputRef.current
201
+ if (!el) return
202
+ el.style.height = 'auto'
203
+ el.style.height = `${Math.min(el.scrollHeight, MAX_INPUT_HEIGHT)}px`
204
+ }, [input])
205
+
206
+ // --- pending-send marker (visibilitychange → reload if midflight) ---
207
+ useEffect(() => {
208
+ const key = `bz_pending_${agentId}`
209
+ function onVis() {
210
+ if (document.visibilityState === 'visible' && sessionStorage.getItem(key)) {
211
+ sessionStorage.removeItem(key)
212
+ window.location.reload()
213
+ }
214
+ }
215
+ document.addEventListener('visibilitychange', onVis)
216
+ return () => document.removeEventListener('visibilitychange', onVis)
217
+ }, [agentId])
218
+
219
+ // --- stale-banner poll loop ---
220
+ useEffect(() => {
221
+ if (!initialSessionHead) return
222
+ let stopped = false
223
+ let timer: ReturnType<typeof setTimeout> | null = null
224
+ async function tick() {
225
+ if (stopped) return
226
+ if (!document.hidden && !streamingRef.current && !staleBanner) {
227
+ try {
228
+ const res = await fetch(
229
+ `/api/agents/${encodeURIComponent(agentId)}/sessions/head`,
230
+ )
231
+ if (res.ok) {
232
+ const body = (await res.json()) as SessionHeadResponse
233
+ if (typeof body.size === 'number') {
234
+ const known = knownHeadRef.current
235
+ if (body.file !== known.file || body.size !== known.size) {
236
+ setStaleBanner(true)
237
+ }
238
+ }
239
+ }
240
+ } catch {
241
+ // transient — try again next tick
242
+ }
243
+ }
244
+ timer = setTimeout(tick, SESSION_POLL_MS)
245
+ }
246
+ timer = setTimeout(tick, SESSION_POLL_MS)
247
+ return () => {
248
+ stopped = true
249
+ if (timer) clearTimeout(timer)
250
+ }
251
+ }, [agentId, initialSessionHead, staleBanner])
252
+
253
+ async function refreshKnownHead() {
254
+ try {
255
+ const res = await fetch(`/api/agents/${encodeURIComponent(agentId)}/sessions/head`)
256
+ if (!res.ok) return
257
+ const body = (await res.json()) as SessionHeadResponse
258
+ if (typeof body.size === 'number') {
259
+ knownHeadRef.current = { file: body.file ?? null, size: body.size }
260
+ }
261
+ } catch {
262
+ // swallow
263
+ }
264
+ }
265
+
266
+ // --- system bubble helper (slash command output) ---
267
+ function pushSystem(text: string) {
268
+ captureScroll()
269
+ setSystemBubbles((prev) => [
270
+ ...prev,
271
+ { id: systemIdRef.current++, content: text, afterIdx: visibleEntryCountRef.current },
272
+ ])
273
+ }
274
+
275
+ // --- slash commands ---
276
+ async function runContextCommand() {
277
+ try {
278
+ const res = await fetch(`/api/agents/${encodeURIComponent(agentId)}/chat/context`)
279
+ if (!res.ok) {
280
+ pushSystem(`/context failed: ${res.statusText}`)
281
+ return
282
+ }
283
+ const ctx = (await res.json()) as ChatContextResponse
284
+ const fmt = (chars: number, tokens: number) =>
285
+ `${chars.toLocaleString()} chars (~${tokens.toLocaleString()} tok)`
286
+ const lines: string[] = []
287
+ lines.push(`context for ${ctx.agentId}`)
288
+ lines.push(`model: ${ctx.model}`)
289
+ lines.push('')
290
+ lines.push(`system prompt: ${fmt(ctx.systemPrompt.chars, ctx.systemPrompt.tokens)}`)
291
+ for (const f of ctx.systemPrompt.files) {
292
+ lines.push(` - ${f.name}: ${f.chars.toLocaleString()} chars`)
293
+ }
294
+ lines.push('')
295
+ lines.push(
296
+ `tools: ${ctx.tools.count} (${ctx.tools.schemaChars.toLocaleString()} chars of schema)`,
297
+ )
298
+ for (const t of ctx.tools.entries.slice(0, 5)) {
299
+ lines.push(` - ${t.name}: ${t.schemaChars.toLocaleString()} chars`)
300
+ }
301
+ if (ctx.skills.count > 0) {
302
+ lines.push('')
303
+ lines.push(`skills: ${ctx.skills.count}`)
304
+ for (const s of ctx.skills.entries.slice(0, 10)) {
305
+ lines.push(` - ${s.name}: ${s.blockChars.toLocaleString()} chars`)
306
+ }
307
+ }
308
+ lines.push('')
309
+ lines.push(
310
+ `history: ${ctx.history.messageEntries} messages / ${ctx.history.compactionEntries} compactions`,
311
+ )
312
+ lines.push(` ${fmt(ctx.history.chars, ctx.history.tokensEstimate)}`)
313
+ lines.push(` bytes on disk: ${formatBytes(ctx.history.bytes)}`)
314
+ lines.push('')
315
+ lines.push(`TOTAL: ${fmt(ctx.totals.chars, ctx.totals.tokens)}`)
316
+ pushSystem(lines.join('\n'))
317
+ } catch (err) {
318
+ pushSystem(`/context failed: ${(err as Error).message}`)
319
+ }
320
+ }
321
+
322
+ async function runResetCommand() {
323
+ if (serverMessages.length === 0) {
324
+ pushSystem('/reset: history already empty')
325
+ return
326
+ }
327
+ if (!confirm('reset chat history for this agent?')) return
328
+ exitEditMode()
329
+ try {
330
+ const res = await fetch(`/api/agents/${encodeURIComponent(agentId)}/chat/reset`, {
331
+ method: 'POST',
332
+ })
333
+ if (!res.ok) {
334
+ let msg = res.statusText
335
+ try {
336
+ msg = ((await res.json()) as { error?: string }).error || msg
337
+ } catch {}
338
+ pushSystem(`/reset failed: ${msg}`)
339
+ return
340
+ }
341
+ setServerMessages([])
342
+ setLiveEntries([])
343
+ // Drop prior slash-command bubbles whose anchors point into the wiped
344
+ // history — they'd otherwise render at the trailing end with stale context.
345
+ setSystemBubbles([])
346
+ pushSystem('/reset: history wiped')
347
+ } catch (err) {
348
+ pushSystem(`/reset failed: ${(err as Error).message}`)
349
+ }
350
+ }
351
+
352
+ async function runCompactCommand(rest: string) {
353
+ if (serverMessages.length < 2) {
354
+ pushSystem('/compact: need ≥2 messages to compact')
355
+ return
356
+ }
357
+ let keepTail: number | undefined
358
+ const argText = rest.trim()
359
+ if (argText) {
360
+ const n = Number(argText)
361
+ if (Number.isFinite(n) && n >= 0) keepTail = Math.floor(n)
362
+ }
363
+ exitEditMode()
364
+ setStreaming(true)
365
+ setThinking(true)
366
+ try {
367
+ const res = await fetch(`/api/agents/${encodeURIComponent(agentId)}/chat/compact`, {
368
+ method: 'POST',
369
+ headers: { 'content-type': 'application/json' },
370
+ body: JSON.stringify(keepTail !== undefined ? { keepTail } : {}),
371
+ })
372
+ if (!res.ok) {
373
+ let msg = res.statusText
374
+ try {
375
+ msg = ((await res.json()) as { error?: string }).error || msg
376
+ } catch {}
377
+ pushSystem(`/compact failed: ${msg}`)
378
+ return
379
+ }
380
+ const body = (await res.json()) as ChatCompactResponse
381
+ pushSystem(
382
+ `/compact: ${body.before} → ${body.after} entries (${body.summarized} summarized, ${body.keptTail} kept verbatim; ~${body.tokensBefore.toLocaleString()} → ~${body.tokensAfter.toLocaleString()} tok). reloading…`,
383
+ )
384
+ setTimeout(() => window.location.reload(), 400)
385
+ } catch (err) {
386
+ pushSystem(`/compact failed: ${(err as Error).message}`)
387
+ } finally {
388
+ setThinking(false)
389
+ setStreaming(false)
390
+ }
391
+ }
392
+
393
+ async function maybeHandleSlashCommand(text: string): Promise<boolean> {
394
+ const trimmed = text.trim()
395
+ if (!trimmed.startsWith('/')) return false
396
+ const parts = trimmed.split(/\s+/)
397
+ const cmd = (parts[0] ?? '').toLowerCase()
398
+ const rest = parts.slice(1).join(' ')
399
+ switch (cmd) {
400
+ case '/help':
401
+ pushSystem(SLASH_HELP)
402
+ return true
403
+ case '/context':
404
+ await runContextCommand()
405
+ return true
406
+ case '/reset':
407
+ await runResetCommand()
408
+ return true
409
+ case '/compact':
410
+ await runCompactCommand(rest)
411
+ return true
412
+ default:
413
+ return false
414
+ }
415
+ }
416
+
417
+ // --- edit-last-message ---
418
+ function findLastUserIdx(): number {
419
+ for (let i = serverMessages.length - 1; i >= 0; i--) {
420
+ if (serverMessages[i]?.role === 'user') return i
421
+ }
422
+ return -1
423
+ }
424
+
425
+ function enterEditMode() {
426
+ if (liveEntries.length > 0 || streaming) return
427
+ const idx = findLastUserIdx()
428
+ if (idx === -1) return
429
+ const userMsg = serverMessages[idx]
430
+ if (!userMsg) return
431
+ setEditIdx(idx)
432
+ setInput(userMsg.content)
433
+ inputRef.current?.focus()
434
+ }
435
+
436
+ function exitEditMode() {
437
+ setEditIdx(null)
438
+ }
439
+
440
+ // --- send ---
441
+ const send = useCallback(
442
+ async (text: string) => {
443
+ if (!text.trim() || streaming) return
444
+ setInput('')
445
+
446
+ // Slash commands shortcut.
447
+ if (await maybeHandleSlashCommand(text)) return
448
+
449
+ // Edit-mode truncate.
450
+ let truncatedServerMessages: ProviderMessage[] | null = null
451
+ if (editIdx !== null) {
452
+ const keep = editIdx
453
+ try {
454
+ const res = await fetch(
455
+ `/api/agents/${encodeURIComponent(agentId)}/chat/truncate`,
456
+ {
457
+ method: 'POST',
458
+ headers: { 'content-type': 'application/json' },
459
+ body: JSON.stringify({ keepCount: keep }),
460
+ },
461
+ )
462
+ if (!res.ok) {
463
+ let err = res.statusText
464
+ try {
465
+ err = ((await res.json()) as { error?: string }).error || err
466
+ } catch {}
467
+ setInput(text)
468
+ alert(`failed to replace last turn: ${err}`)
469
+ return
470
+ }
471
+ truncatedServerMessages = serverMessages.slice(0, keep)
472
+ setServerMessages(truncatedServerMessages)
473
+ setEditIdx(null)
474
+ } catch (err) {
475
+ setInput(text)
476
+ alert(`failed to replace last turn: ${(err as Error).message}`)
477
+ return
478
+ }
479
+ }
480
+
481
+ captureScroll()
482
+ setLiveEntries([{ type: 'user', content: text }])
483
+ sessionStorage.setItem(`bz_pending_${agentId}`, '1')
484
+ const abort = new AbortController()
485
+ currentAbortRef.current = abort
486
+ setStreaming(true)
487
+ setThinking(true)
488
+
489
+ try {
490
+ const res = await fetch(`/api/agents/${encodeURIComponent(agentId)}/chat`, {
491
+ method: 'POST',
492
+ headers: { 'content-type': 'application/json' },
493
+ body: JSON.stringify({ message: text }),
494
+ signal: abort.signal,
495
+ })
496
+ if (!res.ok || !res.body) {
497
+ let err = res.statusText
498
+ try {
499
+ err = ((await res.json()) as { error?: string }).error || err
500
+ } catch {}
501
+ setLiveEntries((prev) => [...prev, { type: 'error', content: `[error] ${err}` }])
502
+ return
503
+ }
504
+ const reader = res.body.getReader()
505
+ const decoder = new TextDecoder()
506
+ let buffer = ''
507
+ while (true) {
508
+ const { done, value } = await reader.read()
509
+ if (done) break
510
+ buffer += decoder.decode(value, { stream: true })
511
+ const lines = buffer.split('\n')
512
+ buffer = lines.pop() ?? ''
513
+ for (const line of lines) {
514
+ if (!line.trim()) continue
515
+ let frame: ChatFrame
516
+ try {
517
+ frame = JSON.parse(line) as ChatFrame
518
+ } catch {
519
+ continue
520
+ }
521
+ handleFrame(frame)
522
+ }
523
+ }
524
+ if (buffer.trim()) {
525
+ try {
526
+ handleFrame(JSON.parse(buffer) as ChatFrame)
527
+ } catch {}
528
+ }
529
+ await refreshKnownHead()
530
+ sessionStorage.removeItem(`bz_pending_${agentId}`)
531
+ } catch (err) {
532
+ const name = (err as Error).name
533
+ if (name !== 'AbortError') {
534
+ setLiveEntries((prev) => [
535
+ ...prev,
536
+ { type: 'error', content: `[network error] ${(err as Error).message}` },
537
+ ])
538
+ }
539
+ sessionStorage.removeItem(`bz_pending_${agentId}`)
540
+ } finally {
541
+ setThinking(false)
542
+ setStreaming(false)
543
+ currentAbortRef.current = null
544
+ }
545
+ },
546
+ // biome-ignore lint/correctness/useExhaustiveDependencies: stable refs intentional
547
+ [agentId, editIdx, serverMessages, streaming],
548
+ )
549
+
550
+ function handleFrame(frame: ChatFrame) {
551
+ if (frame.kind === 'fatal') {
552
+ setLiveEntries((prev) => [
553
+ ...prev,
554
+ { type: 'error', content: `[fatal] ${frame.error}` },
555
+ ])
556
+ return
557
+ }
558
+ if (frame.kind === 'done') {
559
+ setServerMessages(frame.messages)
560
+ setLiveEntries([])
561
+ return
562
+ }
563
+ if (frame.kind !== 'event') return
564
+ const ev = frame.event
565
+ captureScroll()
566
+ if (ev.type === 'user_message') return
567
+ if (ev.type === 'assistant_delta') {
568
+ setThinking(false)
569
+ setLiveEntries((prev) => {
570
+ const next = [...prev]
571
+ const last = next[next.length - 1]
572
+ if (last?.type === 'assistant') {
573
+ next[next.length - 1] = { type: 'assistant', content: last.content + ev.delta }
574
+ } else {
575
+ next.push({ type: 'assistant', content: ev.delta })
576
+ }
577
+ return next
578
+ })
579
+ return
580
+ }
581
+ if (ev.type === 'assistant_message') {
582
+ setThinking(false)
583
+ setLiveEntries((prev) => {
584
+ const next = [...prev]
585
+ const last = next[next.length - 1]
586
+ if (last?.type === 'assistant') {
587
+ next[next.length - 1] = { type: 'assistant', content: ev.text }
588
+ } else {
589
+ next.push({ type: 'assistant', content: ev.text })
590
+ }
591
+ return next
592
+ })
593
+ return
594
+ }
595
+ if (ev.type === 'tool_call' || ev.type === 'tool_result' || ev.type === 'tool_error') {
596
+ setThinking(ev.type !== 'tool_call')
597
+ const item: ToolItem =
598
+ ev.type === 'tool_call'
599
+ ? { kind: 'call', id: ev.id, name: ev.name, body: ev.arguments }
600
+ : ev.type === 'tool_result'
601
+ ? { kind: 'result', id: ev.id, name: ev.name, body: ev.result }
602
+ : { kind: 'error', id: ev.id, name: ev.name, body: ev.error }
603
+ setLiveEntries((prev) => {
604
+ const next = [...prev]
605
+ const last = next[next.length - 1]
606
+ if (last?.type === 'tool') {
607
+ next[next.length - 1] = { type: 'tool', items: [...last.items, item] }
608
+ } else {
609
+ next.push({ type: 'tool', items: [item] })
610
+ }
611
+ return next
612
+ })
613
+ return
614
+ }
615
+ if (ev.type === 'error') {
616
+ setLiveEntries((prev) => [...prev, { type: 'error', content: `[error] ${ev.error}` }])
617
+ }
618
+ }
619
+
620
+ async function cancel() {
621
+ try {
622
+ const res = await fetch(`/api/agents/${encodeURIComponent(agentId)}/cancel`, {
623
+ method: 'POST',
624
+ })
625
+ if (res.ok || res.status === 204) return
626
+ } catch {
627
+ // fall through to local fetch abort
628
+ }
629
+ currentAbortRef.current?.abort()
630
+ }
631
+
632
+ function onKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
633
+ if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
634
+ e.preventDefault()
635
+ void send(input)
636
+ }
637
+ }
638
+
639
+ // --- render projection ---
640
+ const baseEntries = projectMessages(serverMessages)
641
+ // Update the anchor reference so the next pushSystem() captures the current
642
+ // count. Writing to a ref during render is supported by React.
643
+ visibleEntryCountRef.current = baseEntries.length + liveEntries.length
644
+ const lastUserIdx = (() => {
645
+ if (liveEntries.length > 0 || streaming) return -1
646
+ for (let i = baseEntries.length - 1; i >= 0; i--) {
647
+ if (baseEntries[i]?.type === 'user') return i
648
+ }
649
+ return -1
650
+ })()
651
+ // willDropFromIdx: when in edit mode, every entry from the last user msg
652
+ // onward will be dropped on submit. Compute the entry index of the
653
+ // serverMessages[editIdx] user message.
654
+ const willDropFromIdx = (() => {
655
+ if (editIdx === null) return -1
656
+ let userCount = 0
657
+ for (let i = 0; i < baseEntries.length; i++) {
658
+ if (baseEntries[i]?.type === 'user') {
659
+ if (userCount === serverMessages.slice(0, editIdx + 1).filter((m) => m.role === 'user').length - 1) {
660
+ return i
661
+ }
662
+ userCount++
663
+ }
664
+ }
665
+ // Fallback: last user
666
+ return lastUserIdx
667
+ })()
668
+
669
+ return (
670
+ <div className="flex h-full flex-col overflow-hidden rounded-[16px] border border-frost bg-snow shadow-baziu-sm">
671
+ <div className="flex items-baseline justify-between border-b border-frost px-4 py-2.5">
672
+ <h1 className="font-display text-[1.2rem] text-charcoal">{agentName}</h1>
673
+ <a
674
+ href={`/agents/${agentId}`}
675
+ className="text-xs text-mocha-light hover:text-sapphire"
676
+ >
677
+ settings →
678
+ </a>
679
+ </div>
680
+
681
+ {staleBanner && (
682
+ <div className="mx-5 mt-2 flex items-center gap-2 rounded-md border border-sapphire bg-frost px-4 py-2 text-[0.86em] text-mocha">
683
+ <span
684
+ className="h-2 w-2 flex-none rounded-full bg-sapphire"
685
+ aria-hidden="true"
686
+ />
687
+ <span className="flex-1">new activity from another source — reload to see it</span>
688
+ <button
689
+ type="button"
690
+ onClick={() => window.location.reload()}
691
+ className="rounded-sm bg-sapphire px-3 py-1 text-[0.92em] text-snow hover:opacity-90"
692
+ >
693
+ reload
694
+ </button>
695
+ <button
696
+ type="button"
697
+ onClick={() => setStaleBanner(false)}
698
+ className="px-1 text-mocha-light hover:text-mocha"
699
+ aria-label="dismiss"
700
+ >
701
+ ×
702
+ </button>
703
+ </div>
704
+ )}
705
+
706
+ <div
707
+ ref={messagesRef}
708
+ onScroll={captureScroll}
709
+ className={`min-h-[240px] flex-1 overflow-y-auto px-5 py-5 ${editIdx !== null ? 'is-editing' : ''}`}
710
+ >
711
+ {baseEntries.length === 0 && liveEntries.length === 0 && systemBubbles.length === 0 && (
712
+ <p className="py-12 text-center italic text-fawn">start a conversation…</p>
713
+ )}
714
+ {(() => {
715
+ const out: ReactNode[] = []
716
+ let sysIdx = 0
717
+ const flushSysUpTo = (idx: number) => {
718
+ while (
719
+ sysIdx < systemBubbles.length &&
720
+ (systemBubbles[sysIdx] as { afterIdx: number }).afterIdx <= idx
721
+ ) {
722
+ const sb = systemBubbles[sysIdx] as {
723
+ id: number
724
+ content: string
725
+ afterIdx: number
726
+ }
727
+ out.push(
728
+ <Bubble
729
+ key={`y-${sb.id}`}
730
+ entry={{ type: 'system', content: sb.content }}
731
+ />,
732
+ )
733
+ sysIdx++
734
+ }
735
+ }
736
+ flushSysUpTo(0)
737
+ for (let i = 0; i < baseEntries.length; i++) {
738
+ const entry = baseEntries[i] as RenderEntry
739
+ out.push(
740
+ <Bubble
741
+ key={`s-${i}`}
742
+ entry={entry}
743
+ isLastUser={i === lastUserIdx && editIdx === null}
744
+ isWillDrop={willDropFromIdx !== -1 && i >= willDropFromIdx}
745
+ onEdit={enterEditMode}
746
+ />,
747
+ )
748
+ flushSysUpTo(i + 1)
749
+ }
750
+ for (let i = 0; i < liveEntries.length; i++) {
751
+ const entry = liveEntries[i] as RenderEntry
752
+ out.push(<Bubble key={`l-${i}`} entry={entry} />)
753
+ flushSysUpTo(baseEntries.length + i + 1)
754
+ }
755
+ // Anchors past the current real-entry count (e.g. after edit-mode
756
+ // submit drops the tail) render at the end — better than vanishing.
757
+ while (sysIdx < systemBubbles.length) {
758
+ const sb = systemBubbles[sysIdx] as {
759
+ id: number
760
+ content: string
761
+ afterIdx: number
762
+ }
763
+ out.push(
764
+ <Bubble
765
+ key={`y-${sb.id}`}
766
+ entry={{ type: 'system', content: sb.content }}
767
+ />,
768
+ )
769
+ sysIdx++
770
+ }
771
+ return out
772
+ })()}
773
+ {thinking && (
774
+ <div className="flex items-center gap-2 px-1 py-1 text-[0.85em] text-mocha-light">
775
+ <Dot />
776
+ <Dot delay="0.15s" />
777
+ <Dot delay="0.3s" />
778
+ <span>agent is thinking…</span>
779
+ </div>
780
+ )}
781
+ </div>
782
+
783
+ {editIdx !== null && (
784
+ <div className="flex items-center gap-2 border-t border-frost bg-sapphire-glow px-5 py-2 text-[0.85em] text-sapphire-deep">
785
+ <span>editing last message — submit to replace, or</span>
786
+ <button
787
+ type="button"
788
+ onClick={exitEditMode}
789
+ className="ml-auto rounded-sm border border-sapphire-light bg-transparent px-2 py-0.5 text-[0.92em] text-sapphire-deep hover:bg-snow"
790
+ >
791
+ cancel
792
+ </button>
793
+ </div>
794
+ )}
795
+
796
+ <form
797
+ className="flex items-end gap-2 border-t border-frost bg-ivory px-5 py-3"
798
+ onSubmit={(e) => {
799
+ e.preventDefault()
800
+ void send(input)
801
+ }}
802
+ >
803
+ <textarea
804
+ ref={inputRef}
805
+ rows={1}
806
+ value={input}
807
+ onChange={(e) => setInput(e.target.value)}
808
+ onKeyDown={onKeyDown}
809
+ disabled={streaming}
810
+ placeholder="say something… (Shift+Enter for newline; try /context, /compact, /reset)"
811
+ autoComplete="off"
812
+ className="max-h-[200px] min-h-[2.4rem] flex-1 resize-none overflow-y-auto rounded-md border-[1.5px] border-frost bg-snow px-3 py-2 text-[0.93em] leading-[1.45] text-chocolate outline-none transition-colors focus:border-sapphire focus:shadow-[0_0_0_3px_var(--color-sapphire-glow)]"
813
+ />
814
+ <button
815
+ type="submit"
816
+ disabled={streaming || !input.trim()}
817
+ className="rounded-md bg-sapphire px-4 py-2 text-[0.92em] font-semibold text-snow transition-colors hover:bg-sapphire-deep disabled:cursor-not-allowed disabled:opacity-50"
818
+ >
819
+ send
820
+ </button>
821
+ {streaming && (
822
+ <button
823
+ type="button"
824
+ onClick={cancel}
825
+ className="rounded-md border-[1.5px] border-frost bg-transparent px-3 py-1.5 text-[0.92em] font-medium text-mocha hover:border-sapphire-light hover:bg-sapphire-glow hover:text-sapphire"
826
+ >
827
+ cancel
828
+ </button>
829
+ )}
830
+ </form>
831
+ </div>
832
+ )
833
+ }
834
+
835
+ interface BubbleProps {
836
+ entry: RenderEntry
837
+ isLastUser?: boolean
838
+ isWillDrop?: boolean
839
+ onEdit?: () => void
840
+ }
841
+
842
+ function Bubble({ entry, isLastUser, isWillDrop, onEdit }: BubbleProps) {
843
+ const dropCls = isWillDrop ? 'opacity-40 [&_.bubble-content]:line-through' : ''
844
+ if (entry.type === 'user') {
845
+ if (entry.content.startsWith(INBOX_WAKE_PREFIX)) {
846
+ const body = entry.content.slice(INBOX_WAKE_PREFIX.length)
847
+ return (
848
+ <div className={`my-4 flex flex-col items-end ${dropCls}`}>
849
+ <span className="mb-1 text-[0.72em] font-semibold uppercase tracking-wider text-mocha opacity-90">
850
+ inbox
851
+ </span>
852
+ <div className="bubble-content max-w-[85%] whitespace-pre-wrap break-words rounded-[14px_14px_4px_14px] border border-fawn border-l-[3px] border-l-mocha-light bg-ivory px-3 py-2 font-mono text-[0.88em] leading-[1.5] text-mocha">
853
+ {body}
854
+ </div>
855
+ </div>
856
+ )
857
+ }
858
+ return (
859
+ <div className={`group relative my-4 flex flex-col items-end ${dropCls}`}>
860
+ <span className="sr-only">you</span>
861
+ <div className="bubble-content max-w-[85%] whitespace-pre-wrap break-words rounded-[14px_14px_4px_14px] border border-sapphire-light bg-sapphire-glow px-3 py-2 text-chocolate">
862
+ {entry.content}
863
+ </div>
864
+ {isLastUser && onEdit && (
865
+ <button
866
+ type="button"
867
+ onClick={onEdit}
868
+ title="edit and resend — replaces the last turn"
869
+ className="absolute left-1 top-1 rounded-sm border border-fawn bg-ivory px-2 py-0.5 text-[0.78em] font-medium text-mocha opacity-65 transition hover:border-sapphire hover:bg-sapphire-glow hover:text-sapphire hover:opacity-100 group-hover:opacity-100 group-focus-within:opacity-100"
870
+ >
871
+ edit
872
+ </button>
873
+ )}
874
+ </div>
875
+ )
876
+ }
877
+ if (entry.type === 'assistant') {
878
+ if (entry.content.startsWith(COMPACTION_REPLAY_PREFIX)) {
879
+ const summary = entry.content.slice(COMPACTION_REPLAY_PREFIX.length).trim()
880
+ return (
881
+ <div className={`my-3 border-y border-dashed border-frost py-2 text-[0.85em] italic text-mocha-light ${dropCls}`}>
882
+ <details>
883
+ <summary className="cursor-pointer select-none text-center [&::-webkit-details-marker]:hidden">
884
+ — conversation summary (click to expand) —
885
+ </summary>
886
+ <div
887
+ className="md-content mt-2 rounded-sm bg-frost/40 p-2 not-italic"
888
+ // biome-ignore lint/security/noDangerouslySetInnerHtml: marked + DOMPurify
889
+ dangerouslySetInnerHTML={{ __html: renderMd(summary) }}
890
+ />
891
+ </details>
892
+ </div>
893
+ )
894
+ }
895
+ const isErr =
896
+ entry.content.startsWith('[error]') ||
897
+ entry.content.startsWith('[fatal]') ||
898
+ entry.content.startsWith('[network error]')
899
+ if (isErr) {
900
+ return (
901
+ <div className={`my-4 flex flex-col items-start ${dropCls}`}>
902
+ <span className="mb-1 text-[0.72em] font-semibold uppercase tracking-wider text-[#9B3D3D]">
903
+ error
904
+ </span>
905
+ <div
906
+ className="bubble-content rounded-r-sm border-l-[3px] border-l-[#9B3D3D] bg-[rgba(196,135,138,0.06)] py-1 pl-3 pr-2 leading-[1.55]"
907
+ // biome-ignore lint/security/noDangerouslySetInnerHtml: marked + DOMPurify
908
+ dangerouslySetInnerHTML={{ __html: renderMd(entry.content) }}
909
+ />
910
+ </div>
911
+ )
912
+ }
913
+ return (
914
+ <div className={`my-4 flex flex-col items-start ${dropCls}`}>
915
+ <span className="sr-only">agent</span>
916
+ <div
917
+ className="md-content bubble-content w-full leading-[1.55] text-chocolate"
918
+ // biome-ignore lint/security/noDangerouslySetInnerHtml: marked + DOMPurify
919
+ dangerouslySetInnerHTML={{ __html: renderMd(entry.content) }}
920
+ />
921
+ </div>
922
+ )
923
+ }
924
+ if (entry.type === 'tool') {
925
+ return <ToolGroup items={entry.items} dropCls={dropCls} />
926
+ }
927
+ if (entry.type === 'system') {
928
+ return (
929
+ <div className={`my-3 rounded-r-sm border-l-[3px] border-sapphire bg-sapphire-glow px-3 py-1 font-mono text-[0.88em] text-sapphire-deep ${dropCls}`}>
930
+ <span className="mr-2 font-semibold uppercase tracking-wider opacity-80">system</span>
931
+ <span className="whitespace-pre-wrap">{entry.content}</span>
932
+ </div>
933
+ )
934
+ }
935
+ if (entry.type === 'error') {
936
+ return (
937
+ <div className={`my-3 rounded-r-sm border-l-[3px] border-[#9B3D3D] bg-[rgba(196,135,138,0.08)] px-3 py-1 text-[0.92em] text-[#9B3D3D] ${dropCls}`}>
938
+ {entry.content}
939
+ </div>
940
+ )
941
+ }
942
+ return null
943
+ }
944
+
945
+ function ToolGroup({ items, dropCls }: { items: ToolItem[]; dropCls: string }) {
946
+ const [expanded, setExpanded] = useState(false)
947
+ const contentRef = useRef<HTMLDivElement | null>(null)
948
+ const [overflows, setOverflows] = useState(false)
949
+ useLayoutEffect(() => {
950
+ const el = contentRef.current
951
+ if (!el) return
952
+ setOverflows(el.scrollHeight > TOOL_GROUP_MAX_HEIGHT_PX + 4)
953
+ }, [items.length])
954
+
955
+ return (
956
+ <div
957
+ className={`my-1.5 rounded-r-sm border-l-[3px] border-fawn bg-ivory px-3 py-2 font-mono text-[0.82em] leading-[1.5] text-mocha-light ${dropCls}`}
958
+ >
959
+ <div
960
+ ref={contentRef}
961
+ className="relative overflow-hidden"
962
+ style={{ maxHeight: expanded ? 'none' : `${TOOL_GROUP_MAX_HEIGHT_PX}px` }}
963
+ >
964
+ {items.map((it, i) => (
965
+ <ToolLine key={i} item={it} />
966
+ ))}
967
+ {overflows && !expanded && (
968
+ <div
969
+ className="pointer-events-none absolute inset-x-0 bottom-0 h-10"
970
+ style={{
971
+ background:
972
+ 'linear-gradient(to bottom, rgba(247,240,229,0), var(--color-ivory))',
973
+ }}
974
+ />
975
+ )}
976
+ </div>
977
+ {overflows && (
978
+ <button
979
+ type="button"
980
+ onClick={() => setExpanded((v) => !v)}
981
+ className="mt-2 rounded-sm border border-fawn bg-transparent px-2 py-0.5 text-[0.9em] text-mocha hover:border-sapphire hover:text-sapphire-deep"
982
+ >
983
+ {expanded ? 'show less ↑' : 'show more ↓'}
984
+ </button>
985
+ )}
986
+ </div>
987
+ )
988
+ }
989
+
990
+ function ToolLine({ item }: { item: ToolItem }) {
991
+ if (item.kind === 'call') {
992
+ const args = prettyArgs(item.body)
993
+ const multiLine = args.includes('\n')
994
+ return (
995
+ <div className="whitespace-pre-wrap break-words py-0.5">
996
+ <span className="mr-1 opacity-45">→</span>
997
+ <span className="font-medium text-mocha">{item.name}</span>
998
+ {multiLine ? (
999
+ <pre className="ml-4 mt-0.5 rounded-sm bg-[rgba(42,31,22,0.04)] px-2 py-1 font-mono text-[0.95em] leading-[1.4] text-mocha">
1000
+ {args}
1001
+ </pre>
1002
+ ) : args ? (
1003
+ <span> ({args})</span>
1004
+ ) : null}
1005
+ </div>
1006
+ )
1007
+ }
1008
+ if (item.kind === 'error') {
1009
+ return (
1010
+ <div className="whitespace-pre-wrap break-words py-0.5 text-rose-baziu">
1011
+ <span className="mr-1 opacity-45">←</span>
1012
+ {item.body}
1013
+ </div>
1014
+ )
1015
+ }
1016
+ return (
1017
+ <div className="whitespace-pre-wrap break-words py-0.5">
1018
+ <span className="mr-1 opacity-45">←</span>
1019
+ {item.body}
1020
+ </div>
1021
+ )
1022
+ }
1023
+
1024
+ function Dot({ delay = '0s' }: { delay?: string }) {
1025
+ return (
1026
+ <span
1027
+ className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-mocha-light/60"
1028
+ style={{ animationDelay: delay }}
1029
+ aria-hidden="true"
1030
+ />
1031
+ )
1032
+ }
1033
+