@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.
- package/CHANGELOG.md +422 -0
- package/LICENSE +21 -0
- package/README.md +555 -0
- package/agents/builder.md +141 -0
- package/agents/coordinator.md +351 -0
- package/agents/cto.md +196 -0
- package/agents/gateway.md +276 -0
- package/agents/lead.md +281 -0
- package/agents/merger.md +156 -0
- package/agents/monitor.md +212 -0
- package/agents/reviewer.md +142 -0
- package/agents/scout.md +131 -0
- package/agents/supervisor.md +416 -0
- package/bin/legio.mjs +38 -0
- package/package.json +77 -0
- package/src/agents/checkpoint.test.ts +88 -0
- package/src/agents/checkpoint.ts +102 -0
- package/src/agents/hooks-deployer.test.ts +1820 -0
- package/src/agents/hooks-deployer.ts +574 -0
- package/src/agents/identity.test.ts +614 -0
- package/src/agents/identity.ts +385 -0
- package/src/agents/lifecycle.test.ts +202 -0
- package/src/agents/lifecycle.ts +184 -0
- package/src/agents/manifest.test.ts +558 -0
- package/src/agents/manifest.ts +297 -0
- package/src/agents/overlay.test.ts +592 -0
- package/src/agents/overlay.ts +316 -0
- package/src/beads/client.test.ts +210 -0
- package/src/beads/client.ts +227 -0
- package/src/beads/molecules.test.ts +320 -0
- package/src/beads/molecules.ts +209 -0
- package/src/commands/agents.test.ts +325 -0
- package/src/commands/agents.ts +286 -0
- package/src/commands/clean.test.ts +730 -0
- package/src/commands/clean.ts +653 -0
- package/src/commands/completions.test.ts +346 -0
- package/src/commands/completions.ts +950 -0
- package/src/commands/coordinator.test.ts +1524 -0
- package/src/commands/coordinator.ts +880 -0
- package/src/commands/costs.test.ts +1015 -0
- package/src/commands/costs.ts +473 -0
- package/src/commands/dashboard.test.ts +94 -0
- package/src/commands/dashboard.ts +607 -0
- package/src/commands/doctor.test.ts +295 -0
- package/src/commands/doctor.ts +213 -0
- package/src/commands/down.test.ts +308 -0
- package/src/commands/down.ts +124 -0
- package/src/commands/errors.test.ts +648 -0
- package/src/commands/errors.ts +255 -0
- package/src/commands/feed.test.ts +579 -0
- package/src/commands/feed.ts +368 -0
- package/src/commands/gateway.test.ts +698 -0
- package/src/commands/gateway.ts +419 -0
- package/src/commands/group.test.ts +262 -0
- package/src/commands/group.ts +539 -0
- package/src/commands/hooks.test.ts +292 -0
- package/src/commands/hooks.ts +210 -0
- package/src/commands/init.test.ts +211 -0
- package/src/commands/init.ts +622 -0
- package/src/commands/inspect.test.ts +670 -0
- package/src/commands/inspect.ts +455 -0
- package/src/commands/log.test.ts +1556 -0
- package/src/commands/log.ts +752 -0
- package/src/commands/logs.test.ts +379 -0
- package/src/commands/logs.ts +544 -0
- package/src/commands/mail.test.ts +1726 -0
- package/src/commands/mail.ts +926 -0
- package/src/commands/merge.test.ts +676 -0
- package/src/commands/merge.ts +374 -0
- package/src/commands/metrics.test.ts +444 -0
- package/src/commands/metrics.ts +150 -0
- package/src/commands/monitor.test.ts +151 -0
- package/src/commands/monitor.ts +394 -0
- package/src/commands/nudge.test.ts +230 -0
- package/src/commands/nudge.ts +373 -0
- package/src/commands/prime.test.ts +467 -0
- package/src/commands/prime.ts +386 -0
- package/src/commands/replay.test.ts +742 -0
- package/src/commands/replay.ts +367 -0
- package/src/commands/run.test.ts +443 -0
- package/src/commands/run.ts +365 -0
- package/src/commands/server.test.ts +626 -0
- package/src/commands/server.ts +298 -0
- package/src/commands/sling.test.ts +810 -0
- package/src/commands/sling.ts +700 -0
- package/src/commands/spec.test.ts +206 -0
- package/src/commands/spec.ts +171 -0
- package/src/commands/status.test.ts +276 -0
- package/src/commands/status.ts +339 -0
- package/src/commands/stop.test.ts +357 -0
- package/src/commands/stop.ts +119 -0
- package/src/commands/supervisor.test.ts +186 -0
- package/src/commands/supervisor.ts +544 -0
- package/src/commands/trace.test.ts +746 -0
- package/src/commands/trace.ts +332 -0
- package/src/commands/up.test.ts +597 -0
- package/src/commands/up.ts +275 -0
- package/src/commands/watch.test.ts +152 -0
- package/src/commands/watch.ts +238 -0
- package/src/commands/worktree.test.ts +648 -0
- package/src/commands/worktree.ts +266 -0
- package/src/config.test.ts +496 -0
- package/src/config.ts +616 -0
- package/src/doctor/agents.test.ts +448 -0
- package/src/doctor/agents.ts +396 -0
- package/src/doctor/config-check.test.ts +184 -0
- package/src/doctor/config-check.ts +185 -0
- package/src/doctor/consistency.test.ts +645 -0
- package/src/doctor/consistency.ts +294 -0
- package/src/doctor/databases.test.ts +284 -0
- package/src/doctor/databases.ts +211 -0
- package/src/doctor/dependencies.test.ts +150 -0
- package/src/doctor/dependencies.ts +179 -0
- package/src/doctor/logs.test.ts +244 -0
- package/src/doctor/logs.ts +295 -0
- package/src/doctor/merge-queue.test.ts +210 -0
- package/src/doctor/merge-queue.ts +144 -0
- package/src/doctor/structure.test.ts +285 -0
- package/src/doctor/structure.ts +195 -0
- package/src/doctor/types.ts +37 -0
- package/src/doctor/version.test.ts +130 -0
- package/src/doctor/version.ts +131 -0
- package/src/e2e/chat-flow.test.ts +346 -0
- package/src/e2e/init-sling-lifecycle.test.ts +288 -0
- package/src/errors.test.ts +21 -0
- package/src/errors.ts +246 -0
- package/src/events/store.test.ts +660 -0
- package/src/events/store.ts +344 -0
- package/src/events/tool-filter.test.ts +330 -0
- package/src/events/tool-filter.ts +126 -0
- package/src/global-setup.ts +14 -0
- package/src/index.ts +339 -0
- package/src/insights/analyzer.test.ts +466 -0
- package/src/insights/analyzer.ts +203 -0
- package/src/logging/color.test.ts +118 -0
- package/src/logging/color.ts +71 -0
- package/src/logging/logger.test.ts +812 -0
- package/src/logging/logger.ts +266 -0
- package/src/logging/reporter.test.ts +258 -0
- package/src/logging/reporter.ts +109 -0
- package/src/logging/sanitizer.test.ts +190 -0
- package/src/logging/sanitizer.ts +57 -0
- package/src/mail/broadcast.test.ts +203 -0
- package/src/mail/broadcast.ts +92 -0
- package/src/mail/client.test.ts +873 -0
- package/src/mail/client.ts +236 -0
- package/src/mail/store.test.ts +815 -0
- package/src/mail/store.ts +402 -0
- package/src/merge/queue.test.ts +449 -0
- package/src/merge/queue.ts +262 -0
- package/src/merge/resolver.test.ts +1453 -0
- package/src/merge/resolver.ts +759 -0
- package/src/metrics/store.test.ts +1167 -0
- package/src/metrics/store.ts +511 -0
- package/src/metrics/summary.test.ts +397 -0
- package/src/metrics/summary.ts +178 -0
- package/src/metrics/transcript.test.ts +643 -0
- package/src/metrics/transcript.ts +351 -0
- package/src/mulch/client.test.ts +547 -0
- package/src/mulch/client.ts +416 -0
- package/src/server/audit-store.test.ts +384 -0
- package/src/server/audit-store.ts +257 -0
- package/src/server/headless.test.ts +180 -0
- package/src/server/headless.ts +151 -0
- package/src/server/index.test.ts +241 -0
- package/src/server/index.ts +317 -0
- package/src/server/public/app.js +187 -0
- package/src/server/public/apple-touch-icon.png +0 -0
- package/src/server/public/components/agent-badge.js +37 -0
- package/src/server/public/components/data-table.js +114 -0
- package/src/server/public/components/gateway-chat.js +256 -0
- package/src/server/public/components/issue-card.js +96 -0
- package/src/server/public/components/layout.js +88 -0
- package/src/server/public/components/message-bubble.js +120 -0
- package/src/server/public/components/stat-card.js +26 -0
- package/src/server/public/components/terminal-panel.js +140 -0
- package/src/server/public/favicon-16.png +0 -0
- package/src/server/public/favicon-32.png +0 -0
- package/src/server/public/favicon.ico +0 -0
- package/src/server/public/favicon.png +0 -0
- package/src/server/public/index.html +64 -0
- package/src/server/public/lib/api.js +35 -0
- package/src/server/public/lib/markdown.js +8 -0
- package/src/server/public/lib/preact-setup.js +8 -0
- package/src/server/public/lib/state.js +99 -0
- package/src/server/public/lib/utils.js +309 -0
- package/src/server/public/lib/ws.js +79 -0
- package/src/server/public/views/chat.js +983 -0
- package/src/server/public/views/costs.js +692 -0
- package/src/server/public/views/dashboard.js +781 -0
- package/src/server/public/views/gateway-chat.js +622 -0
- package/src/server/public/views/inspect.js +399 -0
- package/src/server/public/views/issues.js +470 -0
- package/src/server/public/views/setup.js +94 -0
- package/src/server/public/views/task-detail.js +422 -0
- package/src/server/routes.test.ts +3816 -0
- package/src/server/routes.ts +1964 -0
- package/src/server/websocket.test.ts +288 -0
- package/src/server/websocket.ts +196 -0
- package/src/sessions/compat.test.ts +109 -0
- package/src/sessions/compat.ts +17 -0
- package/src/sessions/store.test.ts +969 -0
- package/src/sessions/store.ts +480 -0
- package/src/test-helpers.test.ts +97 -0
- package/src/test-helpers.ts +143 -0
- package/src/types.ts +708 -0
- package/src/watchdog/daemon.test.ts +1233 -0
- package/src/watchdog/daemon.ts +533 -0
- package/src/watchdog/health.test.ts +371 -0
- package/src/watchdog/health.ts +248 -0
- package/src/watchdog/triage.test.ts +162 -0
- package/src/watchdog/triage.ts +193 -0
- package/src/worktree/manager.test.ts +444 -0
- package/src/worktree/manager.ts +224 -0
- package/src/worktree/tmux.test.ts +1238 -0
- package/src/worktree/tmux.ts +644 -0
- package/templates/CLAUDE.md.tmpl +89 -0
- package/templates/hooks.json.tmpl +132 -0
- 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
|
+
}
|