@katyella/legio 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (219) hide show
  1. package/CHANGELOG.md +422 -0
  2. package/LICENSE +21 -0
  3. package/README.md +555 -0
  4. package/agents/builder.md +141 -0
  5. package/agents/coordinator.md +351 -0
  6. package/agents/cto.md +196 -0
  7. package/agents/gateway.md +276 -0
  8. package/agents/lead.md +281 -0
  9. package/agents/merger.md +156 -0
  10. package/agents/monitor.md +212 -0
  11. package/agents/reviewer.md +142 -0
  12. package/agents/scout.md +131 -0
  13. package/agents/supervisor.md +416 -0
  14. package/bin/legio.mjs +38 -0
  15. package/package.json +77 -0
  16. package/src/agents/checkpoint.test.ts +88 -0
  17. package/src/agents/checkpoint.ts +102 -0
  18. package/src/agents/hooks-deployer.test.ts +1820 -0
  19. package/src/agents/hooks-deployer.ts +574 -0
  20. package/src/agents/identity.test.ts +614 -0
  21. package/src/agents/identity.ts +385 -0
  22. package/src/agents/lifecycle.test.ts +202 -0
  23. package/src/agents/lifecycle.ts +184 -0
  24. package/src/agents/manifest.test.ts +558 -0
  25. package/src/agents/manifest.ts +297 -0
  26. package/src/agents/overlay.test.ts +592 -0
  27. package/src/agents/overlay.ts +316 -0
  28. package/src/beads/client.test.ts +210 -0
  29. package/src/beads/client.ts +227 -0
  30. package/src/beads/molecules.test.ts +320 -0
  31. package/src/beads/molecules.ts +209 -0
  32. package/src/commands/agents.test.ts +325 -0
  33. package/src/commands/agents.ts +286 -0
  34. package/src/commands/clean.test.ts +730 -0
  35. package/src/commands/clean.ts +653 -0
  36. package/src/commands/completions.test.ts +346 -0
  37. package/src/commands/completions.ts +950 -0
  38. package/src/commands/coordinator.test.ts +1524 -0
  39. package/src/commands/coordinator.ts +880 -0
  40. package/src/commands/costs.test.ts +1015 -0
  41. package/src/commands/costs.ts +473 -0
  42. package/src/commands/dashboard.test.ts +94 -0
  43. package/src/commands/dashboard.ts +607 -0
  44. package/src/commands/doctor.test.ts +295 -0
  45. package/src/commands/doctor.ts +213 -0
  46. package/src/commands/down.test.ts +308 -0
  47. package/src/commands/down.ts +124 -0
  48. package/src/commands/errors.test.ts +648 -0
  49. package/src/commands/errors.ts +255 -0
  50. package/src/commands/feed.test.ts +579 -0
  51. package/src/commands/feed.ts +368 -0
  52. package/src/commands/gateway.test.ts +698 -0
  53. package/src/commands/gateway.ts +419 -0
  54. package/src/commands/group.test.ts +262 -0
  55. package/src/commands/group.ts +539 -0
  56. package/src/commands/hooks.test.ts +292 -0
  57. package/src/commands/hooks.ts +210 -0
  58. package/src/commands/init.test.ts +211 -0
  59. package/src/commands/init.ts +622 -0
  60. package/src/commands/inspect.test.ts +670 -0
  61. package/src/commands/inspect.ts +455 -0
  62. package/src/commands/log.test.ts +1556 -0
  63. package/src/commands/log.ts +752 -0
  64. package/src/commands/logs.test.ts +379 -0
  65. package/src/commands/logs.ts +544 -0
  66. package/src/commands/mail.test.ts +1726 -0
  67. package/src/commands/mail.ts +926 -0
  68. package/src/commands/merge.test.ts +676 -0
  69. package/src/commands/merge.ts +374 -0
  70. package/src/commands/metrics.test.ts +444 -0
  71. package/src/commands/metrics.ts +150 -0
  72. package/src/commands/monitor.test.ts +151 -0
  73. package/src/commands/monitor.ts +394 -0
  74. package/src/commands/nudge.test.ts +230 -0
  75. package/src/commands/nudge.ts +373 -0
  76. package/src/commands/prime.test.ts +467 -0
  77. package/src/commands/prime.ts +386 -0
  78. package/src/commands/replay.test.ts +742 -0
  79. package/src/commands/replay.ts +367 -0
  80. package/src/commands/run.test.ts +443 -0
  81. package/src/commands/run.ts +365 -0
  82. package/src/commands/server.test.ts +626 -0
  83. package/src/commands/server.ts +298 -0
  84. package/src/commands/sling.test.ts +810 -0
  85. package/src/commands/sling.ts +700 -0
  86. package/src/commands/spec.test.ts +206 -0
  87. package/src/commands/spec.ts +171 -0
  88. package/src/commands/status.test.ts +276 -0
  89. package/src/commands/status.ts +339 -0
  90. package/src/commands/stop.test.ts +357 -0
  91. package/src/commands/stop.ts +119 -0
  92. package/src/commands/supervisor.test.ts +186 -0
  93. package/src/commands/supervisor.ts +544 -0
  94. package/src/commands/trace.test.ts +746 -0
  95. package/src/commands/trace.ts +332 -0
  96. package/src/commands/up.test.ts +597 -0
  97. package/src/commands/up.ts +275 -0
  98. package/src/commands/watch.test.ts +152 -0
  99. package/src/commands/watch.ts +238 -0
  100. package/src/commands/worktree.test.ts +648 -0
  101. package/src/commands/worktree.ts +266 -0
  102. package/src/config.test.ts +496 -0
  103. package/src/config.ts +616 -0
  104. package/src/doctor/agents.test.ts +448 -0
  105. package/src/doctor/agents.ts +396 -0
  106. package/src/doctor/config-check.test.ts +184 -0
  107. package/src/doctor/config-check.ts +185 -0
  108. package/src/doctor/consistency.test.ts +645 -0
  109. package/src/doctor/consistency.ts +294 -0
  110. package/src/doctor/databases.test.ts +284 -0
  111. package/src/doctor/databases.ts +211 -0
  112. package/src/doctor/dependencies.test.ts +150 -0
  113. package/src/doctor/dependencies.ts +179 -0
  114. package/src/doctor/logs.test.ts +244 -0
  115. package/src/doctor/logs.ts +295 -0
  116. package/src/doctor/merge-queue.test.ts +210 -0
  117. package/src/doctor/merge-queue.ts +144 -0
  118. package/src/doctor/structure.test.ts +285 -0
  119. package/src/doctor/structure.ts +195 -0
  120. package/src/doctor/types.ts +37 -0
  121. package/src/doctor/version.test.ts +130 -0
  122. package/src/doctor/version.ts +131 -0
  123. package/src/e2e/chat-flow.test.ts +346 -0
  124. package/src/e2e/init-sling-lifecycle.test.ts +288 -0
  125. package/src/errors.test.ts +21 -0
  126. package/src/errors.ts +246 -0
  127. package/src/events/store.test.ts +660 -0
  128. package/src/events/store.ts +344 -0
  129. package/src/events/tool-filter.test.ts +330 -0
  130. package/src/events/tool-filter.ts +126 -0
  131. package/src/global-setup.ts +14 -0
  132. package/src/index.ts +339 -0
  133. package/src/insights/analyzer.test.ts +466 -0
  134. package/src/insights/analyzer.ts +203 -0
  135. package/src/logging/color.test.ts +118 -0
  136. package/src/logging/color.ts +71 -0
  137. package/src/logging/logger.test.ts +812 -0
  138. package/src/logging/logger.ts +266 -0
  139. package/src/logging/reporter.test.ts +258 -0
  140. package/src/logging/reporter.ts +109 -0
  141. package/src/logging/sanitizer.test.ts +190 -0
  142. package/src/logging/sanitizer.ts +57 -0
  143. package/src/mail/broadcast.test.ts +203 -0
  144. package/src/mail/broadcast.ts +92 -0
  145. package/src/mail/client.test.ts +873 -0
  146. package/src/mail/client.ts +236 -0
  147. package/src/mail/store.test.ts +815 -0
  148. package/src/mail/store.ts +402 -0
  149. package/src/merge/queue.test.ts +449 -0
  150. package/src/merge/queue.ts +262 -0
  151. package/src/merge/resolver.test.ts +1453 -0
  152. package/src/merge/resolver.ts +759 -0
  153. package/src/metrics/store.test.ts +1167 -0
  154. package/src/metrics/store.ts +511 -0
  155. package/src/metrics/summary.test.ts +397 -0
  156. package/src/metrics/summary.ts +178 -0
  157. package/src/metrics/transcript.test.ts +643 -0
  158. package/src/metrics/transcript.ts +351 -0
  159. package/src/mulch/client.test.ts +547 -0
  160. package/src/mulch/client.ts +416 -0
  161. package/src/server/audit-store.test.ts +384 -0
  162. package/src/server/audit-store.ts +257 -0
  163. package/src/server/headless.test.ts +180 -0
  164. package/src/server/headless.ts +151 -0
  165. package/src/server/index.test.ts +241 -0
  166. package/src/server/index.ts +317 -0
  167. package/src/server/public/app.js +187 -0
  168. package/src/server/public/apple-touch-icon.png +0 -0
  169. package/src/server/public/components/agent-badge.js +37 -0
  170. package/src/server/public/components/data-table.js +114 -0
  171. package/src/server/public/components/gateway-chat.js +256 -0
  172. package/src/server/public/components/issue-card.js +96 -0
  173. package/src/server/public/components/layout.js +88 -0
  174. package/src/server/public/components/message-bubble.js +120 -0
  175. package/src/server/public/components/stat-card.js +26 -0
  176. package/src/server/public/components/terminal-panel.js +140 -0
  177. package/src/server/public/favicon-16.png +0 -0
  178. package/src/server/public/favicon-32.png +0 -0
  179. package/src/server/public/favicon.ico +0 -0
  180. package/src/server/public/favicon.png +0 -0
  181. package/src/server/public/index.html +64 -0
  182. package/src/server/public/lib/api.js +35 -0
  183. package/src/server/public/lib/markdown.js +8 -0
  184. package/src/server/public/lib/preact-setup.js +8 -0
  185. package/src/server/public/lib/state.js +99 -0
  186. package/src/server/public/lib/utils.js +309 -0
  187. package/src/server/public/lib/ws.js +79 -0
  188. package/src/server/public/views/chat.js +983 -0
  189. package/src/server/public/views/costs.js +692 -0
  190. package/src/server/public/views/dashboard.js +781 -0
  191. package/src/server/public/views/gateway-chat.js +622 -0
  192. package/src/server/public/views/inspect.js +399 -0
  193. package/src/server/public/views/issues.js +470 -0
  194. package/src/server/public/views/setup.js +94 -0
  195. package/src/server/public/views/task-detail.js +422 -0
  196. package/src/server/routes.test.ts +3816 -0
  197. package/src/server/routes.ts +1964 -0
  198. package/src/server/websocket.test.ts +288 -0
  199. package/src/server/websocket.ts +196 -0
  200. package/src/sessions/compat.test.ts +109 -0
  201. package/src/sessions/compat.ts +17 -0
  202. package/src/sessions/store.test.ts +969 -0
  203. package/src/sessions/store.ts +480 -0
  204. package/src/test-helpers.test.ts +97 -0
  205. package/src/test-helpers.ts +143 -0
  206. package/src/types.ts +708 -0
  207. package/src/watchdog/daemon.test.ts +1233 -0
  208. package/src/watchdog/daemon.ts +533 -0
  209. package/src/watchdog/health.test.ts +371 -0
  210. package/src/watchdog/health.ts +248 -0
  211. package/src/watchdog/triage.test.ts +162 -0
  212. package/src/watchdog/triage.ts +193 -0
  213. package/src/worktree/manager.test.ts +444 -0
  214. package/src/worktree/manager.ts +224 -0
  215. package/src/worktree/tmux.test.ts +1238 -0
  216. package/src/worktree/tmux.ts +644 -0
  217. package/templates/CLAUDE.md.tmpl +89 -0
  218. package/templates/hooks.json.tmpl +132 -0
  219. package/templates/overlay.md.tmpl +79 -0
@@ -0,0 +1,622 @@
1
+ // gateway-chat.js — GatewayChat standalone component
2
+ // Unified chat feed: all human-audience messages across all agents in one timeline.
3
+ // Agent messages on left (labeled with agent name), user messages on right.
4
+
5
+ import { TerminalPanel } from "../components/terminal-panel.js";
6
+ import { fetchJson, postJson } from "../lib/api.js";
7
+ import { renderMarkdown } from "../lib/markdown.js";
8
+ import { html, useCallback, useEffect, useRef, useState } from "../lib/preact-setup.js";
9
+ import { appState } from "../lib/state.js";
10
+ import { timeAgo } from "../lib/utils.js";
11
+
12
+ // Slash commands available in gateway chat
13
+ const SLASH_COMMANDS = [
14
+ { cmd: "/status", desc: "Show agent status overview" },
15
+ { cmd: "/merge", desc: "Merge a completed branch" },
16
+ { cmd: "/nudge", desc: "Send a nudge to a stalled agent" },
17
+ { cmd: "/mail", desc: "Send mail to an agent" },
18
+ { cmd: "/help", desc: "Show available commands" },
19
+ ];
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // GatewayChat — main export
23
+ // ---------------------------------------------------------------------------
24
+
25
+ export function GatewayChat({ gwRunning }) {
26
+ const [input, setInput] = useState("");
27
+ const [sending, setSending] = useState(false);
28
+ const [sendError, setSendError] = useState("");
29
+ const [pendingMessages, setPendingMessages] = useState([]);
30
+ const [historyMessages, setHistoryMessages] = useState([]);
31
+ const [coordinatorMessages, setCoordinatorMessages] = useState([]);
32
+ const [showCoordinator, setShowCoordinator] = useState(false);
33
+ const [thinking, setThinking] = useState(false);
34
+ const [dropdown, setDropdown] = useState({
35
+ visible: false,
36
+ items: [],
37
+ selectedIndex: 0,
38
+ type: "mention",
39
+ });
40
+ const feedRef = useRef(null);
41
+ const isNearBottomRef = useRef(true);
42
+ const prevFromAgentCountRef = useRef(0);
43
+ const inputRef = useRef(null);
44
+ const pendingCursorRef = useRef(null);
45
+ const thinkingTimeoutRef = useRef(null);
46
+ const thinkingCountRef = useRef(0);
47
+
48
+ const inputClass =
49
+ "bg-[#1a1a1a] border border-[#2a2a2a] rounded px-2 py-1 text-sm text-[#e5e5e5]" +
50
+ " placeholder-[#666] outline-none focus:border-[#E64415]";
51
+
52
+ // Load gateway chat history on mount and poll for updates
53
+ // Poll faster (2000ms) when thinking so we catch replies quickly
54
+ useEffect(() => {
55
+ let cancelled = false;
56
+
57
+ async function fetchHistory() {
58
+ try {
59
+ const data = await fetchJson("/api/gateway/chat/history?limit=200");
60
+ if (!cancelled) {
61
+ const next = Array.isArray(data) ? data : [];
62
+ setHistoryMessages((prev) => {
63
+ if (JSON.stringify(prev) === JSON.stringify(next)) return prev;
64
+ return next;
65
+ });
66
+ }
67
+ } catch (_err) {
68
+ // non-fatal — history may not be available yet
69
+ }
70
+ }
71
+
72
+ fetchHistory();
73
+ const interval = setInterval(fetchHistory, thinking ? 2000 : 5000);
74
+ return () => {
75
+ cancelled = true;
76
+ clearInterval(interval);
77
+ };
78
+ }, [thinking]);
79
+
80
+ // Fetch coordinator messages when toggle is enabled
81
+ useEffect(() => {
82
+ if (!showCoordinator) {
83
+ setCoordinatorMessages([]);
84
+ return;
85
+ }
86
+ let cancelled = false;
87
+
88
+ async function fetchCoordinatorHistory() {
89
+ try {
90
+ const data = await fetchJson("/api/coordinator/chat/history?limit=200");
91
+ if (!cancelled) {
92
+ const next = Array.isArray(data) ? data : [];
93
+ setCoordinatorMessages((prev) => {
94
+ if (JSON.stringify(prev) === JSON.stringify(next)) return prev;
95
+ return next;
96
+ });
97
+ }
98
+ } catch (_err) {
99
+ // non-fatal
100
+ }
101
+ }
102
+
103
+ fetchCoordinatorHistory();
104
+ const interval = setInterval(fetchCoordinatorHistory, thinking ? 2000 : 5000);
105
+ return () => {
106
+ cancelled = true;
107
+ clearInterval(interval);
108
+ };
109
+ }, [showCoordinator, thinking]);
110
+
111
+ // Subscribe to WebSocket mail_new events via appState.mail signal for instant updates
112
+ useEffect(() => {
113
+ const mailMessages = appState.mail.value ?? [];
114
+ const gatewayMessages = mailMessages.filter(
115
+ (m) =>
116
+ ((m.from === "human" && m.to === "gateway") ||
117
+ (m.from === "gateway" && m.to === "human")) &&
118
+ (m.audience === "human" || m.audience === "both"),
119
+ );
120
+ const coordMessages = showCoordinator
121
+ ? mailMessages.filter(
122
+ (m) =>
123
+ (m.from === "coordinator" && m.to === "human") ||
124
+ (m.from === "human" && m.to === "coordinator"),
125
+ )
126
+ : [];
127
+ if (gatewayMessages.length === 0 && coordMessages.length === 0) return;
128
+ if (gatewayMessages.length > 0) {
129
+ setHistoryMessages((prev) => {
130
+ const existingIds = new Set(prev.map((m) => m.id));
131
+ const newMsgs = gatewayMessages.filter((m) => !existingIds.has(m.id));
132
+ if (newMsgs.length === 0) return prev;
133
+ return [...prev, ...newMsgs].sort(
134
+ (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
135
+ );
136
+ });
137
+ }
138
+ if (coordMessages.length > 0) {
139
+ setCoordinatorMessages((prev) => {
140
+ const existingIds = new Set(prev.map((m) => m.id));
141
+ const newMsgs = coordMessages.filter((m) => !existingIds.has(m.id));
142
+ if (newMsgs.length === 0) return prev;
143
+ return [...prev, ...newMsgs].sort(
144
+ (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
145
+ );
146
+ });
147
+ }
148
+ }, [appState.mail.value, showCoordinator]); // eslint-disable-line react-hooks/exhaustive-deps
149
+
150
+ // Poll POST /api/chat/transcript-sync to sync gateway transcript responses into mail.db
151
+ useEffect(() => {
152
+ let cancelled = false;
153
+ async function syncTranscript() {
154
+ if (cancelled) return;
155
+ try {
156
+ await postJson("/api/chat/transcript-sync", { agent: "gateway" });
157
+ } catch (_err) {
158
+ // non-fatal
159
+ }
160
+ }
161
+ syncTranscript();
162
+ const interval = setInterval(syncTranscript, thinking ? 2000 : 10000);
163
+ return () => {
164
+ cancelled = true;
165
+ clearInterval(interval);
166
+ };
167
+ }, [thinking]);
168
+
169
+ // Consume pendingChatContext from issue click-through
170
+ useEffect(() => {
171
+ const ctx = appState.pendingChatContext.value;
172
+ if (!ctx) return;
173
+ setInput(`Discuss issue ${ctx.issueId}: ${ctx.title}\n${ctx.description || ""}`);
174
+ appState.pendingChatContext.value = null;
175
+ inputRef.current?.focus();
176
+ }, [appState.pendingChatContext.value]); // eslint-disable-line react-hooks/exhaustive-deps
177
+
178
+ // Count non-human responses in history — detect new agent replies to clear thinking
179
+ const fromAgentCount = historyMessages.filter((m) => m.from !== "human").length;
180
+
181
+ useEffect(() => {
182
+ if (fromAgentCount > prevFromAgentCountRef.current) {
183
+ // Decrement thinking counter; clear indicator only when all sends have been replied to
184
+ thinkingCountRef.current = Math.max(0, thinkingCountRef.current - 1);
185
+ if (thinkingCountRef.current === 0) setThinking(false);
186
+ // Deduplicate pending messages that now appear in history
187
+ setPendingMessages((prev) =>
188
+ prev.filter(
189
+ (pm) =>
190
+ !historyMessages.some(
191
+ (hm) =>
192
+ hm.from === "human" &&
193
+ hm.body === pm.body &&
194
+ Math.abs(new Date(hm.createdAt).getTime() - new Date(pm.createdAt).getTime()) <
195
+ 60000,
196
+ ),
197
+ ),
198
+ );
199
+ }
200
+ prevFromAgentCountRef.current = fromAgentCount;
201
+ }, [fromAgentCount]); // eslint-disable-line react-hooks/exhaustive-deps
202
+
203
+ // Remove pending messages whenever history updates (catches the case where agent hasn't replied yet)
204
+ useEffect(() => {
205
+ if (historyMessages.length === 0) return;
206
+ setPendingMessages((prev) =>
207
+ prev.filter(
208
+ (pm) =>
209
+ !historyMessages.some(
210
+ (hm) =>
211
+ hm.from === "human" &&
212
+ hm.body === pm.body &&
213
+ Math.abs(new Date(hm.createdAt).getTime() - new Date(pm.createdAt).getTime()) < 60000,
214
+ ),
215
+ ),
216
+ );
217
+ }, [historyMessages]);
218
+
219
+ // Auto-clear thinking after 60 seconds to prevent it from getting stuck forever
220
+ useEffect(() => {
221
+ if (thinking) {
222
+ thinkingTimeoutRef.current = setTimeout(() => {
223
+ thinkingCountRef.current = 0;
224
+ setThinking(false);
225
+ }, 60000);
226
+ }
227
+ return () => {
228
+ if (thinkingTimeoutRef.current) {
229
+ clearTimeout(thinkingTimeoutRef.current);
230
+ thinkingTimeoutRef.current = null;
231
+ }
232
+ };
233
+ }, [thinking]);
234
+
235
+ // Merge history + pending (+ coordinator when toggle on), deduplicate by id, sort oldest first
236
+ const seenIds = new Set();
237
+ const allMessages = [];
238
+ const mergeSource = showCoordinator
239
+ ? [...historyMessages, ...coordinatorMessages, ...pendingMessages]
240
+ : [...historyMessages, ...pendingMessages];
241
+ for (const msg of mergeSource) {
242
+ if (!seenIds.has(msg.id)) {
243
+ seenIds.add(msg.id);
244
+ allMessages.push(msg);
245
+ }
246
+ }
247
+ allMessages.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
248
+
249
+ // Auto-scroll to bottom when near bottom
250
+ useEffect(() => {
251
+ const feed = feedRef.current;
252
+ if (feed && isNearBottomRef.current) {
253
+ requestAnimationFrame(() => {
254
+ feed.scrollTop = feed.scrollHeight;
255
+ });
256
+ }
257
+ }, [allMessages.length, thinking]);
258
+
259
+ // Restore cursor position after programmatic input update (e.g., @-mention insertion)
260
+ useEffect(() => {
261
+ if (pendingCursorRef.current !== null && inputRef.current) {
262
+ const pos = pendingCursorRef.current;
263
+ pendingCursorRef.current = null;
264
+ inputRef.current.setSelectionRange(pos, pos);
265
+ }
266
+ });
267
+
268
+ const handleFeedScroll = useCallback(() => {
269
+ const feed = feedRef.current;
270
+ if (!feed) return;
271
+ isNearBottomRef.current = feed.scrollHeight - feed.scrollTop - feed.clientHeight < 50;
272
+ }, []);
273
+
274
+ const handleSend = useCallback(async () => {
275
+ const text = input.trim();
276
+ if (!text || sending) return;
277
+ setSendError("");
278
+ setSending(true);
279
+
280
+ // Optimistic: show user message immediately before POST completes
281
+ const pendingId = `pending-${Date.now()}`;
282
+ const pending = {
283
+ id: pendingId,
284
+ from: "human",
285
+ to: "gateway",
286
+ body: text,
287
+ createdAt: new Date().toISOString(),
288
+ status: "sending",
289
+ };
290
+ setPendingMessages((prev) => [...prev, pending]);
291
+
292
+ try {
293
+ setInput("");
294
+ if (inputRef.current) {
295
+ inputRef.current.style.height = "auto";
296
+ }
297
+ inputRef.current?.focus();
298
+ thinkingCountRef.current += 1;
299
+ setThinking(true);
300
+ await postJson("/api/gateway/chat", { text });
301
+ // Do NOT remove pending here — let the historyMessages useEffect deduplicate
302
+ // once the history poll confirms the message, preventing a visible flash/gap.
303
+ try {
304
+ await postJson("/api/audit", {
305
+ type: "command",
306
+ source: "web_ui",
307
+ summary: text,
308
+ agent: "gateway",
309
+ });
310
+ } catch (_e) {
311
+ // intentionally ignored
312
+ }
313
+ } catch (err) {
314
+ setSendError(err.message || "Send failed");
315
+ setPendingMessages((prev) => prev.filter((m) => m.id !== pendingId));
316
+ } finally {
317
+ setSending(false);
318
+ }
319
+ }, [input, sending]);
320
+
321
+ // Detect @-mention and /command triggers from input text and update dropdown state
322
+ const handleInput = useCallback((e) => {
323
+ const value = e.target.value;
324
+ setInput(value);
325
+ // Auto-resize textarea: grow to content height, cap at 200px then scroll internally
326
+ e.target.style.height = "auto";
327
+ e.target.style.height = `${Math.min(e.target.scrollHeight, 200)}px`;
328
+ const cursorPos = e.target.selectionStart ?? value.length;
329
+ const textBeforeCursor = value.slice(0, cursorPos);
330
+
331
+ // Check for @-mention trigger — scan backward from cursor to find @
332
+ const atIdx = textBeforeCursor.lastIndexOf("@");
333
+ if (atIdx !== -1) {
334
+ const triggerText = textBeforeCursor.slice(atIdx + 1);
335
+ // Only trigger if no spaces in the mention text (mentions are single tokens)
336
+ if (!triggerText.includes(" ")) {
337
+ const agents = appState.agents.value ?? [];
338
+ const filter = triggerText.toLowerCase();
339
+ const filtered = agents.filter((a) => {
340
+ const name = (a.agentName ?? a.name ?? "").toLowerCase();
341
+ return !filter || name.includes(filter);
342
+ });
343
+ if (filtered.length > 0) {
344
+ setDropdown({ visible: true, items: filtered, selectedIndex: 0, type: "mention" });
345
+ return;
346
+ }
347
+ }
348
+ }
349
+
350
+ // Check for /command trigger — only when input starts with / and no space yet
351
+ if (value.startsWith("/") && value.indexOf(" ") === -1) {
352
+ const filter = value.slice(1).toLowerCase();
353
+ const filtered = SLASH_COMMANDS.filter((c) => !filter || c.cmd.slice(1).startsWith(filter));
354
+ if (filtered.length > 0) {
355
+ setDropdown({ visible: true, items: filtered, selectedIndex: 0, type: "command" });
356
+ return;
357
+ }
358
+ }
359
+
360
+ // No trigger active — close dropdown if it was open
361
+ setDropdown((prev) => (prev.visible ? { ...prev, visible: false } : prev));
362
+ }, []);
363
+
364
+ // Insert the selected dropdown item into the input
365
+ const selectDropdownItem = useCallback(
366
+ (item) => {
367
+ if (dropdown.type === "mention") {
368
+ const cursorPos = inputRef.current?.selectionStart ?? input.length;
369
+ const textBefore = input.slice(0, cursorPos);
370
+ const atIdx = textBefore.lastIndexOf("@");
371
+ const name = item.agentName ?? item.name ?? "";
372
+ const inserted = `@${name} `;
373
+ const newValue = input.slice(0, atIdx) + inserted + input.slice(cursorPos);
374
+ pendingCursorRef.current = atIdx + inserted.length;
375
+ setInput(newValue);
376
+ } else {
377
+ // Slash command — replace the /filter prefix with the selected command
378
+ setInput(`${item.cmd} `);
379
+ }
380
+ setDropdown({ visible: false, items: [], selectedIndex: 0, type: "mention" });
381
+ inputRef.current?.focus();
382
+ },
383
+ [dropdown.type, input],
384
+ );
385
+
386
+ const handleKeyDown = useCallback(
387
+ (e) => {
388
+ // When dropdown is open, arrow keys and Enter navigate/select; Escape dismisses
389
+ if (dropdown.visible) {
390
+ if (e.key === "ArrowDown") {
391
+ e.preventDefault();
392
+ setDropdown((prev) => ({
393
+ ...prev,
394
+ selectedIndex: Math.min(prev.selectedIndex + 1, prev.items.length - 1),
395
+ }));
396
+ return;
397
+ }
398
+ if (e.key === "ArrowUp") {
399
+ e.preventDefault();
400
+ setDropdown((prev) => ({
401
+ ...prev,
402
+ selectedIndex: Math.max(prev.selectedIndex - 1, 0),
403
+ }));
404
+ return;
405
+ }
406
+ if (e.key === "Enter") {
407
+ e.preventDefault();
408
+ const item = dropdown.items[dropdown.selectedIndex];
409
+ if (item !== undefined) selectDropdownItem(item);
410
+ return;
411
+ }
412
+ if (e.key === "Escape") {
413
+ e.preventDefault();
414
+ setDropdown({ visible: false, items: [], selectedIndex: 0, type: "mention" });
415
+ return;
416
+ }
417
+ }
418
+
419
+ if (e.key === "Enter" && !e.shiftKey) {
420
+ e.preventDefault();
421
+ handleSend();
422
+ }
423
+ },
424
+ [dropdown, handleSend, selectDropdownItem],
425
+ );
426
+
427
+ return html`
428
+ <div class="flex flex-col h-full min-h-0">
429
+ <!-- Header -->
430
+ <div class="px-3 py-2 border-b border-[#2a2a2a] shrink-0 flex items-center gap-2">
431
+ <span class="text-sm font-medium text-[#e5e5e5]">Chat</span>
432
+ <span class="ml-1 text-xs text-[#555]">All agents</span>
433
+ <button
434
+ onClick=${() => setShowCoordinator((v) => !v)}
435
+ class=${
436
+ "ml-auto text-xs px-2 py-0.5 rounded border cursor-pointer " +
437
+ (showCoordinator
438
+ ? "border-purple-600 text-purple-400 bg-purple-900/20"
439
+ : "border-[#2a2a2a] text-[#666] hover:border-[#444] bg-transparent")
440
+ }
441
+ >
442
+ ${showCoordinator ? "Hide Coordinator" : "Show Coordinator"}
443
+ </button>
444
+ </div>
445
+
446
+ <!-- Message feed — unified timeline of all human-audience messages -->
447
+ <div
448
+ class="flex-1 overflow-y-auto p-3 min-h-0 flex flex-col gap-2"
449
+ ref=${feedRef}
450
+ onScroll=${handleFeedScroll}
451
+ >
452
+ ${
453
+ allMessages.length === 0
454
+ ? html`
455
+ <div class="flex items-center justify-center h-full text-[#666] text-sm">
456
+ ${gwRunning ? "No messages yet" : "Start gateway to chat"}
457
+ </div>
458
+ `
459
+ : allMessages.map((msg) => {
460
+ const isFromUser = msg.from === "human";
461
+ const isSending = msg.status === "sending";
462
+ const isCommand = isFromUser && (msg.body ?? "").startsWith("/");
463
+ const isCoordinator = msg.from === "coordinator" || msg.to === "coordinator";
464
+
465
+ // Determine bubble styling
466
+ let bubbleClass = "max-w-[85%] rounded px-3 py-2 text-sm ";
467
+ if (isFromUser && isCoordinator) {
468
+ // Human -> coordinator
469
+ bubbleClass +=
470
+ "bg-purple-600/20 text-[#e5e5e5] border border-purple-600/30" +
471
+ (isSending ? " opacity-70" : "");
472
+ } else if (isFromUser) {
473
+ bubbleClass +=
474
+ "bg-[#E64415]/20 text-[#e5e5e5] border border-[#E64415]/30" +
475
+ (isSending ? " opacity-70" : "");
476
+ } else if (isCoordinator) {
477
+ // Coordinator -> human
478
+ bubbleClass += "bg-purple-900/20 text-[#e5e5e5] border border-purple-800/40";
479
+ } else {
480
+ bubbleClass += "bg-[#1a1a1a] text-[#e5e5e5] border border-[#2a2a2a]";
481
+ }
482
+
483
+ // Conversational messages: user on right, agents on left with name label
484
+ return html`
485
+ <div
486
+ key=${msg.id}
487
+ class=${`flex ${isFromUser ? "justify-end" : "justify-start"}`}
488
+ >
489
+ <div class=${bubbleClass}>
490
+ <div class="flex items-center gap-1 mb-1">
491
+ <span class=${`text-xs ${isCoordinator ? "text-purple-400" : "text-[#999]"}`}>
492
+ ${isFromUser ? "You" : msg.from || "unknown"}
493
+ </span>
494
+ ${
495
+ isCoordinator && !isFromUser
496
+ ? html`<span class="text-xs px-1 py-0.5 rounded bg-purple-900/40 text-purple-400 font-mono">coordinator</span>`
497
+ : null
498
+ }
499
+ <span class="text-xs text-[#555]">
500
+ ${isSending ? "\u00b7 sending\u2026" : `\u00b7 ${timeAgo(msg.createdAt)}`}
501
+ </span>
502
+ </div>
503
+ ${
504
+ isCommand
505
+ ? html`<div class="text-[#e5e5e5] whitespace-pre-wrap break-words">
506
+ <span class="text-xs px-1 py-0.5 rounded bg-[#2a2a2a] text-[#888] font-mono mr-1">cmd</span
507
+ ><span class="font-mono">${msg.body || ""}</span>
508
+ </div>`
509
+ : html`<div
510
+ class="text-[#e5e5e5] break-words chat-markdown"
511
+ dangerouslySetInnerHTML=${{ __html: renderMarkdown(msg.body) }}
512
+ ></div>`
513
+ }
514
+ </div>
515
+ </div>
516
+ `;
517
+ })
518
+ }
519
+ ${
520
+ thinking
521
+ ? html`
522
+ <div class="flex justify-start">
523
+ <div class="max-w-[85%] rounded px-3 py-2 text-sm bg-[#1a1a1a] text-[#e5e5e5] border border-[#2a2a2a]">
524
+ <div class="flex items-center gap-1 mb-1">
525
+ <span class="text-xs text-[#999]">gateway</span>
526
+ <span class="text-xs text-[#555] animate-pulse">\u00b7 working\u2026</span>
527
+ </div>
528
+ <div class="flex items-center gap-2 text-sm text-[#666]">
529
+ <span class="animate-pulse">\u25cf\u25cf\u25cf</span>
530
+ </div>
531
+ </div>
532
+ </div>
533
+ `
534
+ : null
535
+ }
536
+ </div>
537
+
538
+ <!-- Terminal panel (collapsible, collapsed by default) -->
539
+ <${TerminalPanel} chatTarget="gateway" thinking=${thinking} />
540
+
541
+ <!-- Input area (always visible) -->
542
+ <div class="border-t border-[#2a2a2a] p-3 shrink-0">
543
+ <div class="relative">
544
+ ${
545
+ dropdown.visible
546
+ ? html`
547
+ <div
548
+ class="absolute bottom-full left-0 right-0 mb-1 bg-[#1a1a1a] border border-[#2a2a2a] rounded shadow-lg max-h-48 overflow-y-auto z-50"
549
+ >
550
+ ${dropdown.items.map(
551
+ (item, i) =>
552
+ html`
553
+ <div
554
+ key=${
555
+ dropdown.type === "mention"
556
+ ? (item.agentName ?? item.name ?? String(i))
557
+ : item.cmd
558
+ }
559
+ class=${
560
+ "flex items-center gap-2 px-3 py-2 cursor-pointer text-sm text-[#e5e5e5] " +
561
+ (i === dropdown.selectedIndex ? "bg-[#E64415]/20" : "hover:bg-[#2a2a2a]")
562
+ }
563
+ onMouseDown=${(e) => {
564
+ e.preventDefault();
565
+ selectDropdownItem(item);
566
+ }}
567
+ >
568
+ ${
569
+ dropdown.type === "mention"
570
+ ? html`
571
+ <span class="flex-1 font-mono">
572
+ @${item.agentName ?? item.name ?? ""}
573
+ </span>
574
+ ${
575
+ item.capability
576
+ ? html`<span
577
+ class="text-xs px-1.5 py-0.5 rounded bg-[#2a2a2a] text-[#888] shrink-0"
578
+ >
579
+ ${item.capability}
580
+ </span>`
581
+ : null
582
+ }
583
+ `
584
+ : html`
585
+ <span class="flex-1 font-mono">${item.cmd}</span>
586
+ <span class="text-xs text-[#666] shrink-0">${item.desc}</span>
587
+ `
588
+ }
589
+ </div>
590
+ `,
591
+ )}
592
+ </div>
593
+ `
594
+ : null
595
+ }
596
+ <div class="flex gap-2">
597
+ <input
598
+ ref=${inputRef}
599
+ type="text"
600
+ placeholder=${
601
+ gwRunning ? "Send command to gateway\u2026" : "Start gateway to chat\u2026"
602
+ }
603
+ value=${input}
604
+ onInput=${handleInput}
605
+ onKeyDown=${handleKeyDown}
606
+ disabled=${sending || !gwRunning}
607
+ class=${`${inputClass} flex-1 min-w-0`}
608
+ />
609
+ <button
610
+ onClick=${handleSend}
611
+ disabled=${sending || !input.trim() || !gwRunning}
612
+ class="bg-[#E64415] hover:bg-[#cc3d12] disabled:opacity-50 text-white text-sm px-3 py-1 rounded cursor-pointer border-none shrink-0"
613
+ >
614
+ ${sending ? "\u2026" : "Send"}
615
+ </button>
616
+ </div>
617
+ </div>
618
+ ${sendError ? html`<div class="text-xs text-red-400 mt-1">${sendError}</div>` : null}
619
+ </div>
620
+ </div>
621
+ `;
622
+ }