@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,983 @@
1
+ // Legio Web UI — ChatView component
2
+ // Preact+HTM component providing the full chat interface:
3
+ // - Task-based sidebar grouping conversations by beads issue
4
+ // - Conversations section for direct agent/coordinator/gateway chat
5
+ // - Message feed with thread grouping + smart scroll
6
+ // - Chat input with POST /api/mail/send integration
7
+ // No npm dependencies — uses CDN imports. Served as a static ES module.
8
+
9
+ import { ActivityCard, MessageBubble } from "../components/message-bubble.js";
10
+ import { fetchJson, postJson } from "../lib/api.js";
11
+ import {
12
+ html,
13
+ useCallback,
14
+ useEffect,
15
+ useLayoutEffect,
16
+ useMemo,
17
+ useRef,
18
+ useState,
19
+ } from "../lib/preact-setup.js";
20
+ import { agentColor, inferCapability, isActivityMessage, timeAgo } from "../lib/utils.js";
21
+
22
+ // Issue status icon colors
23
+ const STATUS_ICON_COLORS = {
24
+ open: "text-blue-400",
25
+ in_progress: "text-yellow-400",
26
+ closed: "text-green-400",
27
+ };
28
+
29
+ // Issue status icons (Unicode)
30
+ const STATUS_ICONS = {
31
+ open: "\u25CB",
32
+ in_progress: "\u25D0",
33
+ closed: "\u2713",
34
+ };
35
+
36
+ // Status sort order for task groups
37
+ const STATUS_ORDER = { in_progress: 0, open: 1, closed: 2 };
38
+
39
+ /**
40
+ * Format a timestamp as a time divider label.
41
+ * Shows "Today at HH:MM", "Yesterday at HH:MM", or "MMM DD at HH:MM".
42
+ */
43
+ function formatTimeDivider(isoString) {
44
+ if (!isoString) return "";
45
+ const d = new Date(isoString);
46
+ const now = new Date();
47
+ const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
48
+ const yesterdayStart = new Date(todayStart.getTime() - 86400000);
49
+ const hhmm = d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
50
+ if (d >= todayStart) return `Today at ${hhmm}`;
51
+ if (d >= yesterdayStart) return `Yesterday at ${hhmm}`;
52
+ const month = d.toLocaleString("default", { month: "short" });
53
+ return `${month} ${d.getDate()} at ${hhmm}`;
54
+ }
55
+
56
+ /**
57
+ * Build task groups from agents, issues, and mail.
58
+ *
59
+ * @returns {{ taskGroups: Map, generalAgentNames: Set, agentTaskMap: Map }}
60
+ * - taskGroups: Map<beadId, { issue, agents[], agentNames: Set, msgCount, unreadCount }>
61
+ * - generalAgentNames: Set<string> of agents with no beadId
62
+ * - agentTaskMap: Map<agentName, beadId>
63
+ */
64
+ function buildTaskGroups(agents, issues, mail) {
65
+ const taskGroups = new Map();
66
+ const generalAgentNames = new Set();
67
+ const agentTaskMap = new Map();
68
+ const issueMap = new Map(issues.map((i) => [i.id, i]));
69
+
70
+ for (const agent of agents) {
71
+ const beadId = agent.beadId;
72
+ if (!beadId) {
73
+ generalAgentNames.add(agent.agentName);
74
+ continue;
75
+ }
76
+ agentTaskMap.set(agent.agentName, beadId);
77
+
78
+ if (!taskGroups.has(beadId)) {
79
+ taskGroups.set(beadId, {
80
+ issue: issueMap.get(beadId) || null,
81
+ agents: [],
82
+ agentNames: new Set(),
83
+ msgCount: 0,
84
+ unreadCount: 0,
85
+ });
86
+ }
87
+ const group = taskGroups.get(beadId);
88
+ group.agents.push(agent);
89
+ group.agentNames.add(agent.agentName);
90
+ }
91
+
92
+ // Count messages per task
93
+ for (const msg of mail) {
94
+ const fromTask = agentTaskMap.get(msg.from);
95
+ const toTask = agentTaskMap.get(msg.to);
96
+ // Attribute message to the task of either participant (prefer from)
97
+ const taskId = fromTask || toTask;
98
+ if (taskId && taskGroups.has(taskId)) {
99
+ const group = taskGroups.get(taskId);
100
+ group.msgCount++;
101
+ if (!msg.read) group.unreadCount++;
102
+ }
103
+ }
104
+
105
+ return { taskGroups, generalAgentNames, agentTaskMap };
106
+ }
107
+
108
+ /**
109
+ * Sort task groups: in_progress first, then open, then closed.
110
+ * Within same status, sort by unread count (desc) then msgCount (desc).
111
+ */
112
+ function sortTaskGroups(taskGroups) {
113
+ return [...taskGroups.entries()].sort(([, a], [, b]) => {
114
+ const aStatus = a.issue?.status || "open";
115
+ const bStatus = b.issue?.status || "open";
116
+ const aOrder = STATUS_ORDER[aStatus] ?? 3;
117
+ const bOrder = STATUS_ORDER[bStatus] ?? 3;
118
+ if (aOrder !== bOrder) return aOrder - bOrder;
119
+ if (b.unreadCount !== a.unreadCount) return b.unreadCount - a.unreadCount;
120
+ return b.msgCount - a.msgCount;
121
+ });
122
+ }
123
+
124
+ /**
125
+ * ChatView — 3-panel chat interface with task-based sidebar.
126
+ *
127
+ * Accepts state via props or falls back to window.state (for integration
128
+ * with the existing app.js render cycle).
129
+ *
130
+ * @param {object} props
131
+ * @param {object} [props.state] - App state object (mail, agents, issues, etc.)
132
+ * @param {Function} [props.onSendMessage] - Send callback(from, to, subject, body, type)
133
+ */
134
+ export function ChatView({ state: propState, onSendMessage: propOnSendMessage }) {
135
+ const appState = propState || (typeof window !== "undefined" ? window.state : null) || {};
136
+ const mail = appState.mail || [];
137
+ const agents = appState.agents || [];
138
+ const issues = appState.issues || [];
139
+
140
+ // UI state — local to this component, persisted across re-renders
141
+ const [selectedTask, setSelectedTask] = useState(null);
142
+ const [selectedAgent, setSelectedAgent] = useState(null);
143
+ const [expandedTasks, setExpandedTasks] = useState(() => new Set());
144
+ const [collapsedThreads, setCollapsedThreads] = useState(() => new Set());
145
+
146
+ // Conversation state — direct chat with coordinator/gateway/agents
147
+ // null | { type: 'coordinator' | 'gateway' | 'agent', name: string, label: string, capability?: string, state?: string }
148
+ const [selectedConversation, setSelectedConversation] = useState(null);
149
+ const [conversationHistory, setConversationHistory] = useState([]);
150
+ const [pendingConvMessages, setPendingConvMessages] = useState([]);
151
+
152
+ // Chat/All mode toggle
153
+ const [chatMode, setChatMode] = useState("chat");
154
+
155
+ // Chat input form state (simplified: no From, Subject, or Type)
156
+ const [toVal, setToVal] = useState("");
157
+ const [bodyVal, setBodyVal] = useState("");
158
+ const [sending, setSending] = useState(false);
159
+ const [sendError, setSendError] = useState("");
160
+
161
+ // Feed container ref for smart scroll
162
+ const feedRef = useRef(null);
163
+ // Track whether user is near the bottom of the feed
164
+ const isNearBottomRef = useRef(true);
165
+
166
+ // Keep "To" field pre-filled with selected agent (only when no conversation selected)
167
+ useEffect(() => {
168
+ if (selectedConversation) return;
169
+ setToVal(selectedAgent || "");
170
+ }, [selectedAgent, selectedConversation]);
171
+
172
+ // Fetch conversation history when selectedConversation changes, then poll every 3s
173
+ useEffect(() => {
174
+ if (!selectedConversation) {
175
+ setConversationHistory([]);
176
+ setPendingConvMessages([]);
177
+ return;
178
+ }
179
+
180
+ setPendingConvMessages([]);
181
+ setConversationHistory([]);
182
+
183
+ let url = null;
184
+ if (selectedConversation.type === "coordinator") {
185
+ url = "/api/coordinator/chat/history?limit=100";
186
+ } else if (selectedConversation.type === "agent") {
187
+ url = `/api/agents/${selectedConversation.name}/chat/history`;
188
+ }
189
+ // gateway has no history endpoint
190
+
191
+ if (!url) return;
192
+
193
+ const fetchHistory = () => {
194
+ fetchJson(url)
195
+ .then((data) => {
196
+ setConversationHistory(Array.isArray(data) ? data : data.messages || []);
197
+ })
198
+ .catch(() => {
199
+ // Gracefully handle 404s since backend may not be deployed yet
200
+ setConversationHistory([]);
201
+ });
202
+ };
203
+
204
+ fetchHistory();
205
+ const intervalId = setInterval(fetchHistory, 3000);
206
+ return () => clearInterval(intervalId);
207
+ }, [selectedConversation]);
208
+
209
+ // Smart scroll: after every render, scroll to bottom only if near bottom
210
+ useLayoutEffect(() => {
211
+ const feed = feedRef.current;
212
+ if (feed && isNearBottomRef.current) {
213
+ feed.scrollTop = feed.scrollHeight;
214
+ }
215
+ });
216
+
217
+ const handleFeedScroll = useCallback(() => {
218
+ const feed = feedRef.current;
219
+ if (!feed) return;
220
+ isNearBottomRef.current = feed.scrollHeight - feed.scrollTop - feed.clientHeight < 50;
221
+ }, []);
222
+
223
+ // Build task groups (memoized)
224
+ const { taskGroups, generalAgentNames, agentTaskMap } = useMemo(
225
+ () => buildTaskGroups(agents, issues, mail),
226
+ [agents, issues, mail],
227
+ );
228
+
229
+ // Sorted task group entries (memoized)
230
+ const sortedGroups = useMemo(() => sortTaskGroups(taskGroups), [taskGroups]);
231
+
232
+ // General (unassigned) message counts
233
+ const generalCounts = useMemo(() => {
234
+ let msgCount = 0;
235
+ let unreadCount = 0;
236
+ for (const msg of mail) {
237
+ if (!agentTaskMap.has(msg.from) && !agentTaskMap.has(msg.to)) {
238
+ msgCount++;
239
+ if (!msg.read) unreadCount++;
240
+ }
241
+ }
242
+ return { msgCount, unreadCount };
243
+ }, [mail, agentTaskMap]);
244
+
245
+ // Conversation entries for sidebar (memoized)
246
+ // Always shows Coordinator and Gateway; adds all active agents
247
+ const conversationEntries = useMemo(() => {
248
+ const entries = [
249
+ { type: "coordinator", name: "coordinator", label: "Coordinator" },
250
+ { type: "gateway", name: "gateway", label: "Gateway" },
251
+ ];
252
+ for (const agent of agents) {
253
+ if (agent.agentName === "coordinator" || agent.agentName === "gateway") continue;
254
+ entries.push({
255
+ type: "agent",
256
+ name: agent.agentName,
257
+ label: agent.agentName,
258
+ capability: agent.capability,
259
+ state: agent.state,
260
+ });
261
+ }
262
+ return entries;
263
+ }, [agents]);
264
+
265
+ // ----- Message filtering and thread grouping -----
266
+
267
+ let filteredMessages;
268
+
269
+ if (selectedConversation) {
270
+ // Merge conversation history + relevant mail + pending optimistic messages
271
+ const target = selectedConversation.name;
272
+ const convMail = mail.filter((m) => {
273
+ const isRelevant = m.from === target || m.to === target;
274
+ const isHuman = !m.audience || m.audience === "human" || m.audience === "both";
275
+ return isRelevant && isHuman;
276
+ });
277
+
278
+ // Deduplicate by id, sort chronologically
279
+ const seenIds = new Set();
280
+ const merged = [];
281
+ for (const m of [...conversationHistory, ...convMail, ...pendingConvMessages]) {
282
+ if (m.id && seenIds.has(m.id)) continue;
283
+ if (m.id) seenIds.add(m.id);
284
+ merged.push(m);
285
+ }
286
+ filteredMessages = merged;
287
+ } else {
288
+ filteredMessages = [...mail];
289
+
290
+ if (selectedAgent) {
291
+ // Filter to just this agent's messages
292
+ filteredMessages = filteredMessages.filter(
293
+ (m) => m.from === selectedAgent || m.to === selectedAgent,
294
+ );
295
+ } else if (selectedTask === "__general__") {
296
+ // Show messages where neither from nor to has a beadId
297
+ filteredMessages = filteredMessages.filter(
298
+ (m) => !agentTaskMap.has(m.from) && !agentTaskMap.has(m.to),
299
+ );
300
+ } else if (selectedTask) {
301
+ // Show messages for this task's agents
302
+ const group = taskGroups.get(selectedTask);
303
+ if (group) {
304
+ const agentNames = group.agentNames;
305
+ filteredMessages = filteredMessages.filter(
306
+ (m) => agentNames.has(m.from) || agentNames.has(m.to),
307
+ );
308
+ } else {
309
+ filteredMessages = [];
310
+ }
311
+ }
312
+
313
+ // In chat mode, hide protocol/activity messages (only for non-conversation views)
314
+ if (chatMode === "chat") {
315
+ filteredMessages = filteredMessages.filter((m) => {
316
+ // Use audience field when available (new API), fall back to type heuristic (backward compat)
317
+ if (m.audience) {
318
+ return m.audience === "human" || m.audience === "both";
319
+ }
320
+ return !isActivityMessage(m);
321
+ });
322
+ }
323
+ }
324
+
325
+ filteredMessages.sort(
326
+ (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
327
+ );
328
+
329
+ // Root messages: no threadId, or threadId equals their own id
330
+ const roots = filteredMessages.filter((m) => !m.threadId || m.threadId === m.id);
331
+
332
+ // Reply map: threadId -> MailMessage[]
333
+ const replyMap = {};
334
+ filteredMessages.forEach((m) => {
335
+ if (m.threadId && m.threadId !== m.id) {
336
+ if (!replyMap[m.threadId]) replyMap[m.threadId] = [];
337
+ replyMap[m.threadId].push(m);
338
+ }
339
+ });
340
+
341
+ // ----- Event handlers -----
342
+
343
+ const handleTaskClick = useCallback((taskId) => {
344
+ setSelectedTask(taskId);
345
+ setSelectedAgent(null);
346
+ setSelectedConversation(null);
347
+ // Auto-expand task on click (not for general section)
348
+ if (taskId && taskId !== "__general__") {
349
+ setExpandedTasks((prev) => {
350
+ const next = new Set(prev);
351
+ next.add(taskId);
352
+ return next;
353
+ });
354
+ }
355
+ }, []);
356
+
357
+ const handleTaskToggle = useCallback((e, taskId) => {
358
+ e.stopPropagation();
359
+ setExpandedTasks((prev) => {
360
+ const next = new Set(prev);
361
+ if (next.has(taskId)) {
362
+ next.delete(taskId);
363
+ } else {
364
+ next.add(taskId);
365
+ }
366
+ return next;
367
+ });
368
+ }, []);
369
+
370
+ const handleAgentClick = useCallback((e, agentName) => {
371
+ e.stopPropagation();
372
+ setSelectedAgent(agentName);
373
+ setSelectedConversation(null);
374
+ }, []);
375
+
376
+ const handleAllMessagesClick = useCallback(() => {
377
+ setSelectedTask(null);
378
+ setSelectedAgent(null);
379
+ setSelectedConversation(null);
380
+ }, []);
381
+
382
+ const handleThreadToggle = useCallback((threadId) => {
383
+ setCollapsedThreads((prev) => {
384
+ const next = new Set(prev);
385
+ if (next.has(threadId)) {
386
+ next.delete(threadId);
387
+ } else {
388
+ next.add(threadId);
389
+ }
390
+ return next;
391
+ });
392
+ }, []);
393
+
394
+ const handleConversationClick = useCallback((conv) => {
395
+ setSelectedConversation(conv);
396
+ setSelectedTask(null);
397
+ setSelectedAgent(null);
398
+ }, []);
399
+
400
+ const handleSend = useCallback(async () => {
401
+ // Conversation mode: route to agent/coordinator/gateway endpoint
402
+ if (selectedConversation) {
403
+ if (!bodyVal.trim()) {
404
+ setSendError("Message body is required.");
405
+ return;
406
+ }
407
+ setSendError("");
408
+ setSending(true);
409
+
410
+ // Create optimistic pending message
411
+ const optimisticId = `pending-${Date.now()}`;
412
+ const optimistic = {
413
+ id: optimisticId,
414
+ from: "you",
415
+ to: selectedConversation.name,
416
+ subject: "",
417
+ body: bodyVal.trim(),
418
+ createdAt: new Date().toISOString(),
419
+ audience: "human",
420
+ _chatTarget: selectedConversation.name,
421
+ };
422
+ setPendingConvMessages((prev) => [...prev, optimistic]);
423
+
424
+ let url;
425
+ if (selectedConversation.type === "coordinator") {
426
+ url = "/api/coordinator/chat";
427
+ } else if (selectedConversation.type === "gateway") {
428
+ url = "/api/gateway/chat";
429
+ } else {
430
+ url = `/api/agents/${selectedConversation.name}/chat`;
431
+ }
432
+
433
+ try {
434
+ await postJson(url, { text: bodyVal.trim() });
435
+ setBodyVal("");
436
+ // Keep optimistic message until the next poll picks up the real response
437
+ } catch (err) {
438
+ setSendError(err.message || "Send failed");
439
+ // Remove the failed optimistic message
440
+ setPendingConvMessages((prev) => prev.filter((m) => m.id !== optimisticId));
441
+ } finally {
442
+ setSending(false);
443
+ }
444
+ return;
445
+ }
446
+
447
+ // Standard mail mode
448
+ if (!toVal.trim() || !bodyVal.trim()) {
449
+ setSendError("To and body are required.");
450
+ return;
451
+ }
452
+ setSendError("");
453
+ setSending(true);
454
+ try {
455
+ const sendFn =
456
+ propOnSendMessage || (typeof window !== "undefined" ? window.sendChatMessage : null);
457
+ if (sendFn) {
458
+ await sendFn("orchestrator", toVal.trim(), "", bodyVal.trim(), "status");
459
+ } else {
460
+ // Direct fetch fallback when app.js globals are unavailable
461
+ const res = await fetch("/api/mail/send", {
462
+ method: "POST",
463
+ headers: { "Content-Type": "application/json" },
464
+ body: JSON.stringify({
465
+ from: "orchestrator",
466
+ to: toVal.trim(),
467
+ subject: "",
468
+ body: bodyVal.trim(),
469
+ type: "status",
470
+ priority: "normal",
471
+ audience: "human",
472
+ }),
473
+ });
474
+ if (!res.ok) {
475
+ const err = await res.json().catch(() => ({}));
476
+ throw new Error(err.error || "Send failed");
477
+ }
478
+ const msg = await res.json();
479
+ if (typeof window !== "undefined" && window.state) {
480
+ window.state.mail.push(msg);
481
+ }
482
+ }
483
+ // Clear body on success; preserve to
484
+ setBodyVal("");
485
+ } catch (err) {
486
+ setSendError(err.message || "Send failed");
487
+ } finally {
488
+ setSending(false);
489
+ }
490
+ }, [selectedConversation, toVal, bodyVal, propOnSendMessage]);
491
+
492
+ // ----- Header data -----
493
+
494
+ const selectedAgentData = selectedAgent
495
+ ? agents.find((a) => a.agentName === selectedAgent)
496
+ : null;
497
+
498
+ const selectedTaskData =
499
+ selectedTask && selectedTask !== "__general__" ? taskGroups.get(selectedTask) : null;
500
+
501
+ // Unread count for messages in the current view
502
+ const unreadInView = filteredMessages.filter((m) => !m.read).length;
503
+
504
+ // Input field shared classes
505
+ const inputClass =
506
+ "bg-[#1a1a1a] border border-[#2a2a2a] rounded px-2 py-1 text-sm text-[#e5e5e5] placeholder-[#666] outline-none focus:border-[#E64415]";
507
+
508
+ const showGeneral = generalAgentNames.size > 0 || generalCounts.msgCount > 0;
509
+
510
+ // ----- Message feed rendering helpers -----
511
+
512
+ // Render a single message as ActivityCard or MessageBubble based on type.
513
+ function renderMessage(msg, showName, compact) {
514
+ // Use audience field when available, fall back to type-based detection
515
+ const isActivity = msg.audience ? msg.audience === "agent" : isActivityMessage(msg);
516
+ if (isActivity) {
517
+ return html`<${ActivityCard}
518
+ key=${msg.id}
519
+ event=${msg}
520
+ capability=${inferCapability(msg.from, agents) || inferCapability(msg.to, agents)}
521
+ />`;
522
+ }
523
+ const capability = inferCapability(msg.from, agents);
524
+ const isUser = msg.from === "orchestrator" || msg.from === "you" || msg.from === "human";
525
+ return html`<${MessageBubble}
526
+ key=${msg.id}
527
+ msg=${msg}
528
+ capability=${capability || (isUser ? "coordinator" : null)}
529
+ isUser=${isUser}
530
+ showName=${showName}
531
+ compact=${compact}
532
+ />`;
533
+ }
534
+
535
+ // Render root messages with grouping and time dividers.
536
+ // Tracks prevRootMsg via closure to determine showHeader and time gaps.
537
+ // Returns flat array so each root can emit [divider?, bubble].
538
+ let prevRootMsg = null;
539
+ const feedItems = roots.flatMap((root) => {
540
+ const replies = replyMap[root.id] || [];
541
+
542
+ // Show header when sender changes or gap > 2 minutes
543
+ const showHeader =
544
+ !prevRootMsg ||
545
+ prevRootMsg.from !== root.from ||
546
+ new Date(root.createdAt).getTime() - new Date(prevRootMsg.createdAt).getTime() > 120000;
547
+
548
+ // Time divider when gap > 30 minutes between root messages
549
+ const showDivider =
550
+ prevRootMsg &&
551
+ new Date(root.createdAt).getTime() - new Date(prevRootMsg.createdAt).getTime() > 1800000;
552
+
553
+ prevRootMsg = root;
554
+
555
+ const items = [];
556
+
557
+ if (showDivider) {
558
+ items.push(html`
559
+ <div key=${`divider-${root.id}`} class="flex items-center gap-3 my-4">
560
+ <div class="flex-1 border-t border-[#2a2a2a]"></div>
561
+ <span class="text-xs text-[#555]">${formatTimeDivider(root.createdAt)}</span>
562
+ <div class="flex-1 border-t border-[#2a2a2a]"></div>
563
+ </div>
564
+ `);
565
+ }
566
+
567
+ if (replies.length === 0) {
568
+ items.push(renderMessage(root, showHeader, !showHeader));
569
+ } else {
570
+ const isCollapsed = collapsedThreads.has(root.id);
571
+ const replyWord = replies.length === 1 ? "reply" : "replies";
572
+ const lastReply = replies[replies.length - 1];
573
+ items.push(html`
574
+ <div key=${root.id}>
575
+ ${renderMessage(root, showHeader, !showHeader)}
576
+ <div class="ml-3 mb-2">
577
+ <button
578
+ class="text-xs text-[#666] hover:text-[#999] flex items-center gap-1 mb-1 bg-transparent border-none cursor-pointer p-0"
579
+ onClick=${() => handleThreadToggle(root.id)}
580
+ >
581
+ ${isCollapsed ? "\u25B6" : "\u25BC"} ${replies.length} ${replyWord}
582
+ ${
583
+ lastReply
584
+ ? html`<span class="text-[#444] ml-1">· ${timeAgo(lastReply.createdAt)}</span>`
585
+ : null
586
+ }
587
+ </button>
588
+ ${!isCollapsed && replies.map((reply) => renderMessage(reply, true, false))}
589
+ </div>
590
+ </div>
591
+ `);
592
+ }
593
+
594
+ return items;
595
+ });
596
+
597
+ // ----- Agent color helpers for sidebar and header -----
598
+
599
+ // Get capability colors for an agent (safe fallback for missing capability)
600
+ function capabilityColors(capability) {
601
+ return agentColor(capability || null);
602
+ }
603
+
604
+ return html`
605
+ <div class="flex h-full">
606
+
607
+ <!-- Sidebar -->
608
+ <div
609
+ class="w-64 bg-[#0f0f0f] border-r border-[#2a2a2a] overflow-y-auto flex-shrink-0"
610
+ >
611
+ <!-- All Messages item -->
612
+ <div
613
+ class=${
614
+ "px-3 py-2 cursor-pointer hover:bg-[#1a1a1a] flex items-center gap-2 text-sm" +
615
+ (!selectedTask && !selectedAgent && !selectedConversation
616
+ ? " bg-[#1a1a1a] border-l-2 border-[#E64415]"
617
+ : "")
618
+ }
619
+ onClick=${handleAllMessagesClick}
620
+ >
621
+ <span class="text-[#e5e5e5]">All Messages</span>
622
+ </div>
623
+
624
+ <!-- Conversations section -->
625
+ <div class="mt-1 border-t border-[#1a1a1a]">
626
+ <div class="px-3 py-1 text-xs text-[#555] uppercase tracking-wider">
627
+ Conversations
628
+ </div>
629
+ ${conversationEntries.map((conv) => {
630
+ const isSelected =
631
+ selectedConversation &&
632
+ selectedConversation.type === conv.type &&
633
+ selectedConversation.name === conv.name;
634
+ const colors = capabilityColors(conv.capability || null);
635
+ const convItemClass =
636
+ "px-3 py-2 cursor-pointer hover:bg-[#1a1a1a] flex items-center gap-2 text-sm" +
637
+ (isSelected ? " bg-[#1a1a1a] border-l-2 border-[#E64415]" : "");
638
+ return html`
639
+ <div
640
+ key=${conv.name}
641
+ class=${convItemClass}
642
+ onClick=${() => handleConversationClick(conv)}
643
+ >
644
+ <span class=${`text-xs shrink-0 ${colors.dot}`}>\u25CF</span>
645
+ <span class="flex-1 truncate text-[#e5e5e5] text-xs">${conv.label}</span>
646
+ ${
647
+ conv.capability
648
+ ? html`<span
649
+ class=${`text-xs px-1 rounded shrink-0 ${colors.bg} ${colors.text}`}
650
+ >${conv.capability}</span>`
651
+ : null
652
+ }
653
+ ${
654
+ conv.state
655
+ ? html`<span class="text-[#555] text-xs shrink-0">${conv.state}</span>`
656
+ : null
657
+ }
658
+ </div>
659
+ `;
660
+ })}
661
+ </div>
662
+
663
+ <!-- Tasks section header -->
664
+ ${
665
+ sortedGroups.length > 0 &&
666
+ html`<div
667
+ class="px-3 py-1 text-xs text-[#555] uppercase tracking-wider border-b border-[#1a1a1a] mt-1"
668
+ >
669
+ Tasks
670
+ </div>`
671
+ }
672
+
673
+ <!-- Task list -->
674
+ ${sortedGroups.map(([beadId, group]) => {
675
+ const isTaskSelected = selectedTask === beadId && !selectedAgent && !selectedConversation;
676
+ const isExpanded = expandedTasks.has(beadId);
677
+ const status = group.issue?.status || "open";
678
+ const statusIcon = STATUS_ICONS[status] || STATUS_ICONS.open;
679
+ const statusColor = STATUS_ICON_COLORS[status] || STATUS_ICON_COLORS.open;
680
+ const rawTitle = group.issue?.title;
681
+ const title = rawTitle
682
+ ? rawTitle.length > 32
683
+ ? `${rawTitle.slice(0, 32)}\u2026`
684
+ : rawTitle
685
+ : beadId;
686
+ const taskItemClass =
687
+ "px-3 py-2 cursor-pointer hover:bg-[#1a1a1a] flex items-center gap-1.5 text-sm" +
688
+ (isTaskSelected ? " bg-[#1a1a1a] border-l-2 border-[#E64415]" : "");
689
+
690
+ // Primary agent capability color for the task row dot
691
+ const primaryAgent = group.agents[0];
692
+ const primaryColors = capabilityColors(primaryAgent?.capability);
693
+
694
+ return html`
695
+ <div key=${beadId}>
696
+ <div class=${taskItemClass} onClick=${() => handleTaskClick(beadId)}>
697
+ <button
698
+ class="text-xs text-[#555] hover:text-[#999] bg-transparent border-none cursor-pointer p-0 shrink-0"
699
+ onClick=${(e) => handleTaskToggle(e, beadId)}
700
+ >
701
+ ${isExpanded ? "\u25BC" : "\u25B6"}
702
+ </button>
703
+ <span class=${`text-xs shrink-0 ${statusColor}`}>${statusIcon}</span>
704
+ <span class="flex-1 truncate text-[#e5e5e5] text-xs">${title}</span>
705
+ ${
706
+ primaryAgent
707
+ ? html`<span class=${`text-xs shrink-0 ${primaryColors.dot}`}>\u25CF</span>`
708
+ : null
709
+ }
710
+ ${
711
+ group.unreadCount > 0
712
+ ? html`<span
713
+ class="text-xs bg-[#E64415] text-white px-1 rounded-full shrink-0"
714
+ >${group.unreadCount}</span
715
+ >`
716
+ : group.msgCount > 0
717
+ ? html`<span class="text-xs text-[#555] shrink-0"
718
+ >${group.msgCount}</span
719
+ >`
720
+ : null
721
+ }
722
+ </div>
723
+
724
+ ${
725
+ isExpanded &&
726
+ group.agents.map((ag) => {
727
+ const isAgentSelected = selectedAgent === ag.agentName && !selectedConversation;
728
+ const colors = capabilityColors(ag.capability);
729
+ const agentItemClass =
730
+ "pl-8 pr-3 py-1.5 cursor-pointer hover:bg-[#1a1a1a] flex items-center gap-1.5 text-xs" +
731
+ (isAgentSelected ? " bg-[#1a1a1a] border-l-2 border-[#E64415]" : "");
732
+
733
+ return html`
734
+ <div
735
+ key=${ag.agentName}
736
+ class=${agentItemClass}
737
+ onClick=${(e) => handleAgentClick(e, ag.agentName)}
738
+ >
739
+ <span class=${`text-xs ${colors.dot}`}>\u25CF</span>
740
+ <span class="flex-1 truncate text-[#e5e5e5]">${ag.agentName}</span>
741
+ ${
742
+ ag.capability
743
+ ? html`<span
744
+ class=${`text-xs px-1 rounded shrink-0 ${colors.bg} ${colors.text}`}
745
+ >${ag.capability}</span>`
746
+ : null
747
+ }
748
+ <span class="text-[#555] shrink-0 ml-1">${ag.state || ""}</span>
749
+ </div>
750
+ `;
751
+ })
752
+ }
753
+ </div>
754
+ `;
755
+ })}
756
+
757
+ <!-- General / Unassigned section -->
758
+ ${
759
+ showGeneral &&
760
+ html`<div class="mt-1 border-t border-[#1a1a1a]">
761
+ <div class="px-3 py-1 text-xs text-[#555] uppercase tracking-wider">
762
+ General
763
+ </div>
764
+ <div
765
+ class=${
766
+ "px-3 py-2 cursor-pointer hover:bg-[#1a1a1a] flex items-center gap-2 text-sm" +
767
+ (selectedTask === "__general__" && !selectedAgent && !selectedConversation
768
+ ? " bg-[#1a1a1a] border-l-2 border-[#E64415]"
769
+ : "")
770
+ }
771
+ onClick=${() => handleTaskClick("__general__")}
772
+ >
773
+ <span class="flex-1 truncate text-[#e5e5e5] text-xs">Unassigned</span>
774
+ ${
775
+ generalCounts.unreadCount > 0
776
+ ? html`<span
777
+ class="text-xs bg-[#E64415] text-white px-1 rounded-full shrink-0"
778
+ >${generalCounts.unreadCount}</span
779
+ >`
780
+ : generalCounts.msgCount > 0
781
+ ? html`<span class="text-xs text-[#555] shrink-0"
782
+ >${generalCounts.msgCount}</span
783
+ >`
784
+ : null
785
+ }
786
+ </div>
787
+ </div>`
788
+ }
789
+ </div>
790
+
791
+ <!-- Chat main area -->
792
+ <div class="flex-1 flex flex-col min-w-0">
793
+
794
+ <!-- Header -->
795
+ <div class="px-4 py-3 border-b border-[#2a2a2a] flex items-center gap-2 flex-wrap">
796
+ ${
797
+ selectedConversation
798
+ ? html`
799
+ <div class="flex items-center gap-2 border-l-4 pl-2 border-[#E64415]">
800
+ <span class="font-semibold text-[#e5e5e5]">${selectedConversation.label}</span>
801
+ ${
802
+ selectedConversation.capability &&
803
+ html`<span class=${
804
+ `text-xs px-1.5 py-0.5 rounded` +
805
+ ` ${capabilityColors(selectedConversation.capability).bg}` +
806
+ ` ${capabilityColors(selectedConversation.capability).text}`
807
+ }>
808
+ ${selectedConversation.capability}
809
+ </span>`
810
+ }
811
+ ${
812
+ selectedConversation.state &&
813
+ html`<span class="text-xs px-1.5 py-0.5 rounded bg-[#333] text-[#999]">
814
+ ${selectedConversation.state}
815
+ </span>`
816
+ }
817
+ <span class="text-xs text-[#555]">conversation</span>
818
+ </div>
819
+ `
820
+ : selectedAgent
821
+ ? html`
822
+ <div class=${
823
+ "flex items-center gap-2 border-l-4 pl-2" +
824
+ (selectedAgentData
825
+ ? ` ${capabilityColors(selectedAgentData.capability).border}`
826
+ : "")
827
+ }>
828
+ <span class="font-semibold text-[#e5e5e5]">${selectedAgent}</span>
829
+ ${
830
+ selectedAgentData &&
831
+ html`
832
+ <span class=${
833
+ `text-xs px-1.5 py-0.5 rounded` +
834
+ ` ${capabilityColors(selectedAgentData.capability).bg}` +
835
+ ` ${capabilityColors(selectedAgentData.capability).text}`
836
+ }>
837
+ ${selectedAgentData.capability || ""}
838
+ </span>
839
+ <span class="text-xs px-1.5 py-0.5 rounded bg-[#333] text-[#999]">
840
+ ${selectedAgentData.state || ""}
841
+ </span>
842
+ `
843
+ }
844
+ </div>
845
+ `
846
+ : selectedTask === "__general__"
847
+ ? html`<span class="font-semibold text-[#e5e5e5]">Unassigned Messages</span>`
848
+ : selectedTaskData
849
+ ? html`
850
+ <span class="font-mono text-xs text-[#999]">${selectedTask}</span>
851
+ <span class="font-semibold text-[#e5e5e5] truncate">
852
+ ${selectedTaskData.issue?.title || selectedTask}
853
+ </span>
854
+ ${
855
+ selectedTaskData.issue &&
856
+ html`<span
857
+ class=${
858
+ "text-xs px-1.5 py-0.5 rounded bg-[#333] " +
859
+ (STATUS_ICON_COLORS[selectedTaskData.issue.status] || "text-gray-400")
860
+ }
861
+ >
862
+ ${selectedTaskData.issue.status}
863
+ </span>`
864
+ }
865
+ <span class="flex items-center gap-1">
866
+ ${selectedTaskData.agents.map(
867
+ (ag) => html`<span
868
+ key=${ag.agentName}
869
+ class=${`text-xs ${capabilityColors(ag.capability).dot}`}
870
+ title=${ag.agentName}
871
+ >\u25CF</span>`,
872
+ )}
873
+ </span>
874
+ <span class="text-xs text-[#666]">
875
+ ${selectedTaskData.agents.length}
876
+ ${selectedTaskData.agents.length === 1 ? "agent" : "agents"}
877
+ </span>
878
+ `
879
+ : html`<span class="font-semibold text-[#e5e5e5]">All Messages</span>`
880
+ }
881
+ <span class="flex-1"></span>
882
+ ${
883
+ unreadInView > 0 &&
884
+ html`<span class="text-xs bg-[#E64415] text-white px-1.5 py-0.5 rounded-full">
885
+ ${unreadInView} unread
886
+ </span>`
887
+ }
888
+ <!-- Chat/All mode toggle (only shown when not in conversation mode) -->
889
+ ${
890
+ !selectedConversation &&
891
+ html`<div class="flex items-center gap-0.5 bg-[#1a1a1a] border border-[#2a2a2a] rounded-full px-0.5 py-0.5">
892
+ <button
893
+ class=${
894
+ "text-xs px-2.5 py-0.5 rounded-full border-none cursor-pointer transition-colors" +
895
+ (chatMode === "chat"
896
+ ? " bg-[#E64415] text-white"
897
+ : " bg-transparent text-[#666] hover:text-[#999]")
898
+ }
899
+ onClick=${() => setChatMode("chat")}
900
+ >Chat</button>
901
+ <button
902
+ class=${
903
+ "text-xs px-2.5 py-0.5 rounded-full border-none cursor-pointer transition-colors" +
904
+ (chatMode === "all"
905
+ ? " bg-[#E64415] text-white"
906
+ : " bg-transparent text-[#666] hover:text-[#999]")
907
+ }
908
+ onClick=${() => setChatMode("all")}
909
+ >All</button>
910
+ </div>`
911
+ }
912
+ </div>
913
+
914
+ <!-- Message feed -->
915
+ <div
916
+ class="flex-1 overflow-y-auto p-4 min-h-0"
917
+ ref=${feedRef}
918
+ onScroll=${handleFeedScroll}
919
+ >
920
+ ${
921
+ roots.length === 0
922
+ ? selectedConversation
923
+ ? html`<div class="flex flex-col items-center justify-center h-full text-[#666] text-sm gap-1">
924
+ <span>No messages yet.</span>
925
+ <span class="text-xs">Start a conversation below.</span>
926
+ </div>`
927
+ : chatMode === "chat"
928
+ ? html`<div class="flex flex-col items-center justify-center h-full text-[#666] text-sm gap-1">
929
+ <span>No conversation messages yet.</span>
930
+ <span class="text-xs">Switch to "All" to see protocol activity.</span>
931
+ </div>`
932
+ : html`<div class="flex items-center justify-center h-full text-[#666] text-sm">No messages yet</div>`
933
+ : feedItems
934
+ }
935
+ </div>
936
+
937
+ <!-- Chat input (simplified: To + Body + Send) -->
938
+ <div class="border-t border-[#2a2a2a] p-3">
939
+ <div class="flex gap-2 items-end">
940
+ <div class="flex-1">
941
+ <div class="flex items-center gap-1 mb-1">
942
+ <span class="text-xs text-[#555]">To:</span>
943
+ ${
944
+ selectedConversation
945
+ ? html`<span class="text-xs text-[#e5e5e5] font-medium">${selectedConversation.label}</span>`
946
+ : html`<input
947
+ type="text"
948
+ value=${toVal}
949
+ onInput=${(e) => setToVal(e.target.value)}
950
+ class="bg-transparent border-none text-xs text-[#e5e5e5] outline-none w-24"
951
+ />`
952
+ }
953
+ </div>
954
+ <textarea
955
+ placeholder="Message... (Ctrl+Enter or Cmd+Enter to send)"
956
+ rows="2"
957
+ value=${bodyVal}
958
+ onInput=${(e) => setBodyVal(e.target.value)}
959
+ onKeyDown=${(e) => {
960
+ if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
961
+ e.preventDefault();
962
+ if (!bodyVal.trim()) return;
963
+ handleSend();
964
+ }
965
+ }}
966
+ class=${`w-full ${inputClass} resize-none`}
967
+ />
968
+ </div>
969
+ <button
970
+ onClick=${handleSend}
971
+ disabled=${sending || !bodyVal.trim()}
972
+ class="bg-[#E64415] hover:bg-[#cc3d12] disabled:opacity-50 text-white text-sm px-3 py-1 rounded cursor-pointer border-none self-end"
973
+ >
974
+ ${sending ? "..." : "Send"}
975
+ </button>
976
+ </div>
977
+ ${sendError && html`<span class="text-xs text-red-400 mt-1 block">${sendError}</span>`}
978
+ </div>
979
+
980
+ </div>
981
+ </div>
982
+ `;
983
+ }