@katyella/legio 0.1.3 → 0.2.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 (110) hide show
  1. package/CHANGELOG.md +40 -3
  2. package/README.md +15 -8
  3. package/agents/builder.md +11 -10
  4. package/agents/coordinator.md +36 -27
  5. package/agents/cto.md +9 -8
  6. package/agents/gateway.md +28 -12
  7. package/agents/lead.md +45 -30
  8. package/agents/merger.md +4 -4
  9. package/agents/monitor.md +10 -9
  10. package/agents/reviewer.md +8 -8
  11. package/agents/scout.md +10 -10
  12. package/agents/supervisor.md +60 -45
  13. package/package.json +2 -2
  14. package/src/agents/hooks-deployer.test.ts +46 -41
  15. package/src/agents/hooks-deployer.ts +10 -9
  16. package/src/agents/manifest.test.ts +6 -2
  17. package/src/agents/overlay.test.ts +9 -7
  18. package/src/agents/overlay.ts +29 -7
  19. package/src/commands/agents.test.ts +1 -5
  20. package/src/commands/clean.test.ts +2 -5
  21. package/src/commands/clean.ts +25 -1
  22. package/src/commands/completions.test.ts +1 -1
  23. package/src/commands/completions.ts +26 -7
  24. package/src/commands/coordinator.test.ts +78 -78
  25. package/src/commands/coordinator.ts +92 -47
  26. package/src/commands/costs.test.ts +2 -6
  27. package/src/commands/dashboard.test.ts +2 -5
  28. package/src/commands/doctor.test.ts +2 -6
  29. package/src/commands/down.ts +3 -3
  30. package/src/commands/errors.test.ts +2 -6
  31. package/src/commands/feed.test.ts +2 -6
  32. package/src/commands/gateway.test.ts +39 -13
  33. package/src/commands/gateway.ts +95 -7
  34. package/src/commands/hooks.test.ts +2 -5
  35. package/src/commands/init.test.ts +4 -13
  36. package/src/commands/inspect.test.ts +2 -6
  37. package/src/commands/log.test.ts +2 -6
  38. package/src/commands/logs.test.ts +2 -9
  39. package/src/commands/mail.test.ts +76 -215
  40. package/src/commands/mail.ts +43 -187
  41. package/src/commands/metrics.test.ts +3 -10
  42. package/src/commands/nudge.ts +15 -0
  43. package/src/commands/prime.test.ts +4 -11
  44. package/src/commands/replay.test.ts +2 -6
  45. package/src/commands/server.test.ts +1 -5
  46. package/src/commands/server.ts +1 -1
  47. package/src/commands/sling.ts +40 -16
  48. package/src/commands/spec.test.ts +2 -5
  49. package/src/commands/status.test.ts +2 -4
  50. package/src/commands/stop.test.ts +2 -5
  51. package/src/commands/supervisor.ts +6 -6
  52. package/src/commands/trace.test.ts +2 -6
  53. package/src/commands/up.test.ts +43 -9
  54. package/src/commands/up.ts +15 -11
  55. package/src/commands/watchman.ts +327 -0
  56. package/src/commands/worktree.test.ts +2 -6
  57. package/src/config.test.ts +34 -104
  58. package/src/config.ts +120 -32
  59. package/src/doctor/agents.test.ts +7 -2
  60. package/src/doctor/config-check.test.ts +7 -2
  61. package/src/doctor/consistency.test.ts +7 -2
  62. package/src/doctor/databases.test.ts +6 -2
  63. package/src/doctor/dependencies.test.ts +35 -10
  64. package/src/doctor/dependencies.ts +16 -92
  65. package/src/doctor/logs.test.ts +7 -2
  66. package/src/doctor/merge-queue.test.ts +6 -2
  67. package/src/doctor/structure.test.ts +7 -2
  68. package/src/doctor/version.test.ts +7 -2
  69. package/src/e2e/init-sling-lifecycle.test.ts +2 -5
  70. package/src/index.ts +7 -7
  71. package/src/mail/pending.ts +120 -0
  72. package/src/mail/store.test.ts +89 -0
  73. package/src/mail/store.ts +11 -0
  74. package/src/merge/resolver.test.ts +518 -489
  75. package/src/server/index.ts +33 -2
  76. package/src/server/public/app.js +3 -3
  77. package/src/server/public/components/message-bubble.js +11 -1
  78. package/src/server/public/components/terminal-panel.js +66 -74
  79. package/src/server/public/views/chat.js +18 -2
  80. package/src/server/public/views/costs.js +5 -5
  81. package/src/server/public/views/dashboard.js +80 -51
  82. package/src/server/public/views/gateway-chat.js +37 -131
  83. package/src/server/public/views/inspect.js +16 -4
  84. package/src/server/public/views/issues.js +16 -12
  85. package/src/server/routes.test.ts +55 -39
  86. package/src/server/routes.ts +38 -26
  87. package/src/test-helpers.ts +6 -3
  88. package/src/tracker/beads.ts +159 -0
  89. package/src/tracker/exec.ts +44 -0
  90. package/src/tracker/factory.test.ts +283 -0
  91. package/src/tracker/factory.ts +59 -0
  92. package/src/tracker/seeds.ts +156 -0
  93. package/src/tracker/types.ts +46 -0
  94. package/src/types.ts +11 -2
  95. package/src/{watchdog → watchman}/daemon.test.ts +421 -515
  96. package/src/watchman/daemon.ts +940 -0
  97. package/src/worktree/tmux.test.ts +2 -1
  98. package/src/worktree/tmux.ts +4 -4
  99. package/templates/hooks.json.tmpl +17 -17
  100. package/src/beads/client.test.ts +0 -210
  101. package/src/commands/merge.test.ts +0 -676
  102. package/src/commands/watch.test.ts +0 -152
  103. package/src/commands/watch.ts +0 -238
  104. package/src/test-helpers.test.ts +0 -97
  105. package/src/watchdog/daemon.ts +0 -533
  106. package/src/watchdog/health.test.ts +0 -371
  107. package/src/watchdog/triage.test.ts +0 -162
  108. package/src/worktree/manager.test.ts +0 -444
  109. /package/src/{watchdog → watchman}/health.ts +0 -0
  110. /package/src/{watchdog → watchman}/triage.ts +0 -0
@@ -15,7 +15,7 @@ export interface ServerOptions {
15
15
  host: string;
16
16
  root: string; // Project root directory
17
17
  shouldOpen?: boolean; // Auto-open browser
18
- autoStartCoordinator?: boolean; // Auto-start coordinator with --watchdog on server start
18
+ autoStartCoordinator?: boolean; // Auto-start coordinator with --watchman on server start
19
19
  }
20
20
 
21
21
  /** Dependency injection for testing. */
@@ -97,13 +97,44 @@ async function tryStartCoordinator(root: string): Promise<void> {
97
97
  }
98
98
 
99
99
  // Start coordinator detached so the server doesn't wait on it
100
- const startProc = spawn("legio", ["coordinator", "start", "--watchdog", "--no-attach"], {
100
+ const startProc = spawn("legio", ["coordinator", "start", "--watchman", "--no-attach"], {
101
101
  cwd: root,
102
102
  detached: true,
103
103
  stdio: "ignore",
104
104
  });
105
105
  startProc.unref();
106
106
  process.stdout.write("[legio] Coordinator started\n");
107
+
108
+ // Verify coordinator comes up: poll status up to 5 times at 3s intervals
109
+ const MAX_POLLS = 5;
110
+ const POLL_INTERVAL_MS = 3_000;
111
+ for (let i = 0; i < MAX_POLLS; i++) {
112
+ await new Promise<void>((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
113
+ const pollProc = spawn("legio", ["coordinator", "status", "--json"], {
114
+ cwd: root,
115
+ stdio: ["ignore", "pipe", "pipe"],
116
+ });
117
+ const pollChunks: Buffer[] = [];
118
+ pollProc.stdout?.on("data", (chunk: Buffer) => pollChunks.push(chunk));
119
+ const pollCode = await new Promise<number>((resolve) => {
120
+ pollProc.on("close", (code) => resolve(code ?? 1));
121
+ });
122
+ if (pollCode === 0) {
123
+ try {
124
+ const pollStatus = JSON.parse(Buffer.concat(pollChunks).toString()) as {
125
+ running?: boolean;
126
+ state?: string;
127
+ };
128
+ if (pollStatus.running && pollStatus.state !== "booting") {
129
+ process.stdout.write("[legio] Coordinator verified running\n");
130
+ return;
131
+ }
132
+ } catch {
133
+ // Cannot parse — keep polling
134
+ }
135
+ }
136
+ }
137
+ process.stderr.write("[legio] Warning: coordinator still booting after verification checks\n");
107
138
  }
108
139
 
109
140
  /**
@@ -84,7 +84,7 @@ function Layout({ view, param }) {
84
84
 
85
85
  return html`
86
86
  <div class="flex flex-col h-screen bg-[#0f0f0f]">
87
- <nav class="flex items-center justify-between px-4 border-b border-[#2a2a2a] bg-[#1a1a1a] shrink-0">
87
+ <nav class="flex items-center justify-between px-2 sm:px-4 border-b border-[#2a2a2a] bg-[#1a1a1a] shrink-0">
88
88
  <div class="flex items-center">
89
89
  ${NAV_LINKS.map((link) => {
90
90
  const isActive = link.view === view;
@@ -93,7 +93,7 @@ function Layout({ view, param }) {
93
93
  key=${link.view}
94
94
  href=${link.href}
95
95
  class=${
96
- "px-4 py-3 text-sm font-medium transition-colors border-b-2 " +
96
+ "px-3 sm:px-4 py-3 text-sm font-medium transition-colors border-b-2 " +
97
97
  (isActive
98
98
  ? "text-white border-[#E64415]"
99
99
  : "text-[#888] border-transparent hover:text-[#ccc]")
@@ -104,7 +104,7 @@ function Layout({ view, param }) {
104
104
  `;
105
105
  })}
106
106
  </div>
107
- <div class="flex items-center gap-3 pr-2">
107
+ <div class="flex items-center gap-2 pr-1 shrink-0">
108
108
  <span
109
109
  class=${`w-2 h-2 rounded-full ${connected ? "bg-green-500" : "bg-[#444]"}`}
110
110
  title=${connected ? "WebSocket connected" : "WebSocket disconnected"}
@@ -6,6 +6,16 @@ import { renderMarkdown } from "../lib/markdown.js";
6
6
  import { html } from "../lib/preact-setup.js";
7
7
  import { agentColor, timeAgo } from "../lib/utils.js";
8
8
 
9
+ function addStatusPrefixes(htmlStr) {
10
+ return htmlStr
11
+ .replace(/\[DONE\]/g, '<span class="text-green-400 font-semibold">[DONE]</span>')
12
+ .replace(/\[ERROR\]/g, '<span class="text-red-400 font-semibold">[ERROR]</span>')
13
+ .replace(/\[INFO\]/g, '<span class="text-blue-400 font-semibold">[INFO]</span>')
14
+ .replace(/\[WARN\]/g, '<span class="text-yellow-400 font-semibold">[WARN]</span>')
15
+ .replace(/\[PENDING\]/g, '<span class="text-[#999] font-semibold">[PENDING]</span>')
16
+ .replace(/\[MERGED\]/g, '<span class="text-purple-400 font-semibold">[MERGED]</span>');
17
+ }
18
+
9
19
  /**
10
20
  * MessageBubble — renders a single mail message as a conversational bubble.
11
21
  *
@@ -64,7 +74,7 @@ export function MessageBubble({
64
74
  </div>`
65
75
  }
66
76
  <div class="text-sm text-[#e5e5e5] break-words chat-markdown"
67
- dangerouslySetInnerHTML=${{ __html: renderMarkdown(msg.body) }}></div>
77
+ dangerouslySetInnerHTML=${{ __html: addStatusPrefixes(renderMarkdown(msg.body)) }}></div>
68
78
  </div>
69
79
  `;
70
80
  }
@@ -5,61 +5,45 @@ import { html, useEffect, useRef, useState } from "../lib/preact-setup.js";
5
5
 
6
6
  function stripAnsi(str) {
7
7
  // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional ANSI escape strip
8
- return str.replace(/\x1b\[[0-9;]*[mGKHF]/g, "");
8
+ return str.replace(/\x1b\[[?0-9;]*[a-zA-Z]/g, "");
9
9
  }
10
10
 
11
- function diffCapture(baselineText, currentText) {
12
- const baselineLines = baselineText.trimEnd().split("\n");
13
- const currentLines = currentText.trimEnd().split("\n");
14
- const anchorLen = Math.min(3, baselineLines.length);
15
- const anchor = baselineLines.slice(-anchorLen);
16
-
17
- // Search for the anchor sequence in current capture (prefer latest match)
18
- for (let i = currentLines.length - anchorLen; i >= 0; i--) {
19
- let match = true;
20
- for (let j = 0; j < anchorLen; j++) {
21
- if (currentLines[i + j] !== anchor[j]) {
22
- match = false;
23
- break;
24
- }
25
- }
26
- if (match) {
27
- return currentLines.slice(i + anchorLen).join("\n");
28
- }
29
- }
30
- // Baseline scrolled off — show tail
31
- return currentLines.slice(-20).join("\n");
11
+ const ACTIVITY_LEVEL = { idle: 0, ready: 1, active: 2 };
12
+
13
+ function higherActivity(a, b) {
14
+ return (ACTIVITY_LEVEL[a] ?? 0) >= (ACTIVITY_LEVEL[b] ?? 0) ? a : b;
32
15
  }
33
16
 
34
17
  // TerminalPanel — collapsible terminal capture sub-component
35
18
  // Props:
36
- // chatTarget {string} — agent name to capture terminal from
37
- // thinking {boolean} when true, streams diff-based new output
38
- export function TerminalPanel({ chatTarget, thinking }) {
39
- const [expanded, setExpanded] = useState(false);
40
- const [streamText, setStreamText] = useState("");
41
- const [loading, setLoading] = useState(false);
42
- const baselineCaptureRef = useRef(null);
19
+ // chatTarget {string} — agent name to capture terminal from
20
+ // activity {string} 'idle'|'active'|'ready' hint from parent (e.g. gateway just sent a message)
21
+ // agentState {string|null} agent session state ('working'|'booting'|'completed'|'zombie'|null)
22
+ export function TerminalPanel({ chatTarget, activity = "idle", agentState = null }) {
23
+ const [userExpanded, setUserExpanded] = useState(false);
24
+ const [captureText, setCaptureText] = useState("");
25
+ const [captureActivity, setCaptureActivity] = useState("idle");
26
+ const lastChangeTimeRef = useRef(Date.now());
27
+ const lastHashRef = useRef("");
28
+ const agentStateRef = useRef(agentState);
43
29
  const terminalRef = useRef(null);
44
30
 
31
+ // Keep agentStateRef in sync with the agentState prop
32
+ useEffect(() => {
33
+ agentStateRef.current = agentState;
34
+ }, [agentState]);
35
+
45
36
  // Reset when chatTarget changes
46
37
  useEffect(() => {
47
- setStreamText("");
48
- setLoading(false);
49
- baselineCaptureRef.current = null;
38
+ setCaptureText("");
39
+ setCaptureActivity("idle");
40
+ lastChangeTimeRef.current = Date.now();
41
+ lastHashRef.current = "";
50
42
  }, [chatTarget]);
51
43
 
52
- // Poll terminal capture when thinking OR expanded; clear when neither
44
+ // Always poll to drive the activity state machine via hash-based change detection
53
45
  useEffect(() => {
54
- if (!thinking && !expanded) {
55
- setStreamText("");
56
- setLoading(false);
57
- baselineCaptureRef.current = null;
58
- return;
59
- }
60
-
61
46
  let cancelled = false;
62
- setLoading(true);
63
47
 
64
48
  async function pollCapture() {
65
49
  try {
@@ -67,69 +51,77 @@ export function TerminalPanel({ chatTarget, thinking }) {
67
51
  if (!res.ok || cancelled) return;
68
52
  const data = await res.json();
69
53
  const output = stripAnsi(data.output || "");
70
- if (!cancelled) setLoading(false);
54
+ if (cancelled) return;
71
55
 
72
- if (thinking) {
73
- // Streaming mode: diff against baseline to show new output
74
- if (baselineCaptureRef.current === null) {
75
- baselineCaptureRef.current = output;
76
- return;
77
- }
78
- const delta = diffCapture(baselineCaptureRef.current, output);
79
- if (!cancelled && delta.trim()) {
80
- setStreamText(delta);
81
- }
56
+ const now = Date.now();
57
+ if (output !== lastHashRef.current) {
58
+ // Capture changed transition to active
59
+ lastHashRef.current = output;
60
+ lastChangeTimeRef.current = now;
61
+ setCaptureActivity("active");
82
62
  } else {
83
- // Expanded viewer mode: show full terminal capture directly
84
- if (!cancelled && output.trim()) {
85
- setStreamText(output);
63
+ // No change use agent session state to determine activity level
64
+ const state = agentStateRef.current;
65
+ if (state === "working" || state === "booting") {
66
+ setCaptureActivity("ready");
67
+ } else {
68
+ setCaptureActivity("idle");
86
69
  }
87
70
  }
71
+
72
+ if (output.trim()) {
73
+ setCaptureText(output);
74
+ }
88
75
  } catch (_err) {
89
- // non-fatal — capture may fail if coordinator tmux not ready
76
+ // non-fatal — capture may fail if agent tmux session not ready
90
77
  }
91
78
  }
92
79
 
93
80
  pollCapture();
94
- const interval = setInterval(pollCapture, 1500);
81
+ const interval = setInterval(pollCapture, 2000);
95
82
  return () => {
96
83
  cancelled = true;
97
84
  clearInterval(interval);
98
85
  };
99
- }, [thinking, expanded, chatTarget]);
86
+ }, [chatTarget]);
100
87
 
101
88
  // Auto-scroll terminal to bottom when new output arrives
102
89
  useEffect(() => {
103
90
  const el = terminalRef.current;
104
91
  if (el) el.scrollTop = el.scrollHeight;
105
- }, [streamText]);
92
+ }, [captureText]);
93
+
94
+ // Effective activity: max of parent hint and internal capture-driven state
95
+ const effectiveActivity = higherActivity(activity, captureActivity);
96
+
97
+ const dotClass =
98
+ "w-2 h-2 rounded-full flex-shrink-0 " +
99
+ (effectiveActivity === "active"
100
+ ? "bg-yellow-500 animate-pulse"
101
+ : effectiveActivity === "ready"
102
+ ? "bg-green-500"
103
+ : "bg-[#333]");
106
104
 
107
105
  return html`
108
106
  <div class="border-t border-[#2a2a2a] shrink-0">
109
107
  <div
110
108
  class="flex items-center gap-2 px-3 py-1.5 cursor-pointer hover:bg-white/5"
111
- onClick=${() => setExpanded((prev) => !prev)}
109
+ onClick=${() => setUserExpanded((prev) => !prev)}
112
110
  >
113
- <span
114
- class=${
115
- "w-2 h-2 rounded-full flex-shrink-0 " +
116
- (thinking ? "bg-yellow-500 animate-pulse" : "bg-[#333]")
117
- }
118
- ></span>
111
+ <span class=${dotClass}></span>
119
112
  <span class="text-xs text-[#666]">Terminal</span>
120
- ${thinking ? html`<span class="text-xs text-yellow-500 animate-pulse">active</span>` : null}
121
- <span class="ml-auto text-xs text-[#444]">${expanded ? "\u25b2" : "\u25bc"}</span>
113
+ ${effectiveActivity === "active" ? html`<span class="text-xs text-yellow-500 animate-pulse">active</span>` : null}
114
+ ${effectiveActivity === "ready" ? html`<span class="text-xs text-green-500">ready</span>` : null}
115
+ <span class="ml-auto text-xs text-[#444]">${userExpanded ? "\u25b2" : "\u25bc"}</span>
122
116
  </div>
123
117
  ${
124
- expanded
118
+ userExpanded
125
119
  ? html`
126
120
  <div ref=${terminalRef} class="max-h-[200px] overflow-y-auto px-3 pb-2">
127
121
  ${
128
- streamText
129
- ? html`<pre class="text-xs text-[#ccc] font-mono whitespace-pre-wrap break-words">${streamText}</pre>`
130
- : loading
131
- ? html`<div class="text-xs text-[#666] py-1 italic animate-pulse">Connecting...</div>`
132
- : html`<div class="text-xs text-[#444] py-1 italic">No output yet</div>`
122
+ captureText
123
+ ? html`<pre class="text-xs text-[#ccc] font-mono whitespace-pre-wrap break-words">${captureText}</pre>`
124
+ : html`<div class="text-xs text-[#444] py-1 italic">No output yet</div>`
133
125
  }
134
126
  </div>
135
127
  `
@@ -138,6 +138,7 @@ export function ChatView({ state: propState, onSendMessage: propOnSendMessage })
138
138
  const issues = appState.issues || [];
139
139
 
140
140
  // UI state — local to this component, persisted across re-renders
141
+ const [sidebarOpen, setSidebarOpen] = useState(false);
141
142
  const [selectedTask, setSelectedTask] = useState(null);
142
143
  const [selectedAgent, setSelectedAgent] = useState(null);
143
144
  const [expandedTasks, setExpandedTasks] = useState(() => new Set());
@@ -344,6 +345,7 @@ export function ChatView({ state: propState, onSendMessage: propOnSendMessage })
344
345
  setSelectedTask(taskId);
345
346
  setSelectedAgent(null);
346
347
  setSelectedConversation(null);
348
+ setSidebarOpen(false);
347
349
  // Auto-expand task on click (not for general section)
348
350
  if (taskId && taskId !== "__general__") {
349
351
  setExpandedTasks((prev) => {
@@ -371,12 +373,14 @@ export function ChatView({ state: propState, onSendMessage: propOnSendMessage })
371
373
  e.stopPropagation();
372
374
  setSelectedAgent(agentName);
373
375
  setSelectedConversation(null);
376
+ setSidebarOpen(false);
374
377
  }, []);
375
378
 
376
379
  const handleAllMessagesClick = useCallback(() => {
377
380
  setSelectedTask(null);
378
381
  setSelectedAgent(null);
379
382
  setSelectedConversation(null);
383
+ setSidebarOpen(false);
380
384
  }, []);
381
385
 
382
386
  const handleThreadToggle = useCallback((threadId) => {
@@ -395,6 +399,7 @@ export function ChatView({ state: propState, onSendMessage: propOnSendMessage })
395
399
  setSelectedConversation(conv);
396
400
  setSelectedTask(null);
397
401
  setSelectedAgent(null);
402
+ setSidebarOpen(false);
398
403
  }, []);
399
404
 
400
405
  const handleSend = useCallback(async () => {
@@ -602,11 +607,11 @@ export function ChatView({ state: propState, onSendMessage: propOnSendMessage })
602
607
  }
603
608
 
604
609
  return html`
605
- <div class="flex h-full">
610
+ <div class="flex flex-col md:flex-row h-full">
606
611
 
607
612
  <!-- Sidebar -->
608
613
  <div
609
- class="w-64 bg-[#0f0f0f] border-r border-[#2a2a2a] overflow-y-auto flex-shrink-0"
614
+ class={`w-full md:w-64 bg-[#0f0f0f] border-r border-[#2a2a2a] border-b md:border-b-0 overflow-y-auto flex-shrink-0${sidebarOpen ? '' : ' hidden md:block'}`}
610
615
  >
611
616
  <!-- All Messages item -->
612
617
  <div
@@ -793,6 +798,17 @@ export function ChatView({ state: propState, onSendMessage: propOnSendMessage })
793
798
 
794
799
  <!-- Header -->
795
800
  <div class="px-4 py-3 border-b border-[#2a2a2a] flex items-center gap-2 flex-wrap">
801
+ <button
802
+ class="md:hidden flex-shrink-0 text-[#999] hover:text-[#e5e5e5] p-1"
803
+ onClick=${() => setSidebarOpen((o) => !o)}
804
+ aria-label="Toggle sidebar"
805
+ >
806
+ <svg width="18" height="18" viewBox="0 0 18 18" fill="currentColor">
807
+ <rect x="1" y="3" width="16" height="2"/>
808
+ <rect x="1" y="8" width="16" height="2"/>
809
+ <rect x="1" y="13" width="16" height="2"/>
810
+ </svg>
811
+ </button>
796
812
  ${
797
813
  selectedConversation
798
814
  ? html`
@@ -66,7 +66,7 @@ function modelColor(model) {
66
66
 
67
67
  function StatCard({ label, value }) {
68
68
  return html`
69
- <div class="bg-surface border border-border rounded-sm p-4 flex-1 min-w-0">
69
+ <div class="bg-surface border border-border rounded-sm p-4 flex-1 min-w-[140px]">
70
70
  <div class="text-xs text-gray-500 uppercase tracking-wider mb-1">${label}</div>
71
71
  <div class="text-2xl font-mono text-white">${value}</div>
72
72
  </div>
@@ -95,7 +95,7 @@ function ModelBreakdown({ modelData }) {
95
95
  const color = modelColor(row.model);
96
96
  return html`
97
97
  <div key=${row.model || "unknown"} class="flex items-center gap-3">
98
- <div class="flex items-center gap-2 w-[140px] shrink-0">
98
+ <div class="flex items-center gap-2 w-[100px] md:w-[140px] shrink-0">
99
99
  <div class=${`${color} w-2 h-2 rounded-full shrink-0`}></div>
100
100
  <span class="text-sm text-gray-300 truncate">${row.model || "unknown"}</span>
101
101
  </div>
@@ -198,7 +198,7 @@ function AgentBarChart({ metrics }) {
198
198
  <div key=${name} class="flex items-center gap-3">
199
199
  <a
200
200
  href=${`#inspect/${encodeURIComponent(name)}`}
201
- class="text-blue-400 hover:text-blue-300 text-sm w-[140px] shrink-0 truncate"
201
+ class="text-blue-400 hover:text-blue-300 text-sm w-[100px] md:w-[140px] shrink-0 truncate"
202
202
  >
203
203
  ${name}
204
204
  </a>
@@ -298,7 +298,7 @@ function CapabilityChart({ metrics }) {
298
298
  const pct = maxCost > 0 ? (cost / maxCost) * 100 : 0;
299
299
  return html`
300
300
  <div key=${cap} class="flex items-center gap-3">
301
- <span class="text-sm text-gray-400 w-[140px] shrink-0 truncate">${cap}</span>
301
+ <span class="text-sm text-gray-400 w-[100px] md:w-[140px] shrink-0 truncate">${cap}</span>
302
302
  <div class="flex-1 bg-white/5 rounded-sm h-5 overflow-hidden">
303
303
  <div
304
304
  class="bg-blue-400 h-full rounded-sm"
@@ -510,7 +510,7 @@ export function CostsView({ metrics: initialMetrics, snapshots }) {
510
510
  </div>
511
511
 
512
512
  <!-- Summary Stat Cards -->
513
- <div class="flex gap-4">
513
+ <div class="flex flex-wrap gap-4">
514
514
  <${StatCard}
515
515
  label="Total Cost"
516
516
  value=${totals.cost != null ? formatCostShort(totals.cost) : "—"}
@@ -93,7 +93,7 @@ function MetricsStrip({ agents, status }) {
93
93
  <div class="border-b border-[#2a2a2a] px-3 py-1.5 text-xs font-bold uppercase tracking-wider text-gray-400">
94
94
  Metrics
95
95
  </div>
96
- <div class="flex flex-wrap gap-4 px-3 py-2">
96
+ <div class="flex flex-wrap gap-x-3 gap-y-1 px-3 py-2">
97
97
  ${stats.map(
98
98
  ({ label, value }) => html`
99
99
  <span key=${label} class="text-xs text-gray-400">
@@ -352,7 +352,7 @@ function AgentRoster({ agents, mail, events }) {
352
352
  <div
353
353
  key=${agent.agentName}
354
354
  class="mb-1 rounded border border-[#2a2a2a] bg-[#1a1a1a] overflow-hidden"
355
- style=${{ marginLeft: `${depth * 16}px` }}
355
+ style=${{ marginLeft: `${Math.min(depth * 12, 36)}px` }}
356
356
  >
357
357
  <!-- Row -->
358
358
  <div
@@ -609,54 +609,50 @@ function CoordinatorBar() {
609
609
  const gwStatusText = gwIsRunning ? "Running" : gwIsStopped ? "Stopped" : "Unknown";
610
610
 
611
611
  return html`
612
- <div class="flex items-center gap-3 px-3 py-2 bg-[#1a1a1a] border-b border-[#2a2a2a] shrink-0">
613
- <div class="flex items-center gap-2">
614
- <span class="text-xs text-[#666] uppercase tracking-wide">Coordinator</span>
615
- <div class="flex items-center gap-1">
616
- <div class="w-2 h-2 rounded-full ${dotColor}"></div>
612
+ <div class="bg-[#1a1a1a] border-b border-[#2a2a2a] shrink-0 px-3 py-2">
613
+ <div class="flex flex-wrap gap-x-4 gap-y-1.5 items-center">
614
+ <!-- Coordinator group -->
615
+ <div class="flex items-center gap-2">
616
+ <span class="text-xs text-[#666] uppercase tracking-wide whitespace-nowrap">Coordinator</span>
617
+ <div class="w-2 h-2 rounded-full shrink-0 ${dotColor}"></div>
617
618
  <span class="text-sm text-[#e5e5e5]">${statusText}</span>
619
+ <button
620
+ onClick=${handleStart}
621
+ disabled=${loading || isRunning}
622
+ class="bg-[#E64415] hover:bg-[#cc3d12] disabled:opacity-50 text-white text-xs px-2 py-0.5 rounded cursor-pointer border-none"
623
+ >
624
+ ${loading && !isRunning ? "\u2026" : "Start"}
625
+ </button>
626
+ <button
627
+ onClick=${handleStop}
628
+ disabled=${loading || isStopped || isUnknown}
629
+ class="bg-[#E64415] hover:bg-[#cc3d12] disabled:opacity-50 text-white text-xs px-2 py-0.5 rounded cursor-pointer border-none"
630
+ >
631
+ ${loading && isRunning ? "\u2026" : "Stop"}
632
+ </button>
618
633
  </div>
619
- </div>
620
- <div class="flex items-center gap-2">
621
- <button
622
- onClick=${handleStart}
623
- disabled=${loading || isRunning}
624
- class="bg-[#E64415] hover:bg-[#cc3d12] disabled:opacity-50 text-white text-sm px-3 py-1 rounded cursor-pointer border-none"
625
- >
626
- ${loading && !isRunning ? "\u2026" : "Start"}
627
- </button>
628
- <button
629
- onClick=${handleStop}
630
- disabled=${loading || isStopped || isUnknown}
631
- class="bg-[#E64415] hover:bg-[#cc3d12] disabled:opacity-50 text-white text-sm px-3 py-1 rounded cursor-pointer border-none"
632
- >
633
- ${loading && isRunning ? "\u2026" : "Stop"}
634
- </button>
635
- </div>
636
- <div class="border-l border-[#2a2a2a] pl-3 ml-1 flex items-center gap-2">
637
- <span class="text-xs text-[#666] uppercase tracking-wide">Gateway</span>
638
- <div class="flex items-center gap-1">
639
- <div class="w-2 h-2 rounded-full ${gwDotColor}"></div>
634
+ <!-- Gateway group -->
635
+ <div class="flex items-center gap-2">
636
+ <span class="text-xs text-[#666] uppercase tracking-wide whitespace-nowrap">Gateway</span>
637
+ <div class="w-2 h-2 rounded-full shrink-0 ${gwDotColor}"></div>
640
638
  <span class="text-sm text-[#e5e5e5]">${gwStatusText}</span>
639
+ <button
640
+ onClick=${handleGwStart}
641
+ disabled=${gwLoading || gwIsRunning}
642
+ class="bg-[#E64415] hover:bg-[#cc3d12] disabled:opacity-50 text-white text-xs px-2 py-0.5 rounded cursor-pointer border-none"
643
+ >
644
+ ${gwLoading && !gwIsRunning ? "\u2026" : "Start"}
645
+ </button>
646
+ <button
647
+ onClick=${handleGwStop}
648
+ disabled=${gwLoading || gwIsStopped || gwIsUnknown}
649
+ class="bg-[#E64415] hover:bg-[#cc3d12] disabled:opacity-50 text-white text-xs px-2 py-0.5 rounded cursor-pointer border-none"
650
+ >
651
+ ${gwLoading && gwIsRunning ? "\u2026" : "Stop"}
652
+ </button>
641
653
  </div>
642
654
  </div>
643
- <div class="flex items-center gap-2">
644
- <button
645
- onClick=${handleGwStart}
646
- disabled=${gwLoading || gwIsRunning}
647
- class="bg-[#E64415] hover:bg-[#cc3d12] disabled:opacity-50 text-white text-sm px-3 py-1 rounded cursor-pointer border-none"
648
- >
649
- ${gwLoading && !gwIsRunning ? "\u2026" : "Start"}
650
- </button>
651
- <button
652
- onClick=${handleGwStop}
653
- disabled=${gwLoading || gwIsStopped || gwIsUnknown}
654
- class="bg-[#E64415] hover:bg-[#cc3d12] disabled:opacity-50 text-white text-sm px-3 py-1 rounded cursor-pointer border-none"
655
- >
656
- ${gwLoading && gwIsRunning ? "\u2026" : "Stop"}
657
- </button>
658
- </div>
659
- ${error ? html`<span class="text-xs text-red-400">${error}</span>` : null}
655
+ ${error ? html`<div class="text-xs text-red-400 mt-1">${error}</div>` : null}
660
656
  </div>
661
657
  `;
662
658
  }
@@ -754,23 +750,56 @@ export function DashboardView() {
754
750
  };
755
751
  }, []);
756
752
 
753
+ const [mobileTab, setMobileTab] = useState("chat");
757
754
  const agents = appState.agents.value;
758
755
  const status = appState.status.value;
759
756
 
760
757
  return html`
761
758
  <div class="flex flex-col h-full bg-[#0f0f0f] min-h-0">
762
759
  <${CoordinatorBar} />
763
- <div class="flex flex-1 min-h-0">
764
- <!-- Coordinator Chat (left, ~58%) -->
760
+ <!-- Mobile tab bar (hidden on md+) -->
761
+ <div class="flex border-b border-[#2a2a2a] bg-[#1a1a1a] shrink-0 md:hidden">
762
+ <button
763
+ onClick=${() => setMobileTab("chat")}
764
+ class=${
765
+ "flex-1 py-2 text-sm font-medium border-b-2 " +
766
+ (mobileTab === "chat"
767
+ ? "text-white border-[#E64415]"
768
+ : "text-[#888] border-transparent")
769
+ }
770
+ >
771
+ Chat
772
+ </button>
773
+ <button
774
+ onClick=${() => setMobileTab("status")}
775
+ class=${
776
+ "flex-1 py-2 text-sm font-medium border-b-2 " +
777
+ (mobileTab === "status"
778
+ ? "text-white border-[#E64415]"
779
+ : "text-[#888] border-transparent")
780
+ }
781
+ >
782
+ Status
783
+ </button>
784
+ </div>
785
+ <div class="flex flex-col md:flex-row flex-1 min-h-0">
786
+ <!-- Chat panel: full height on mobile (when chat tab active), left ~58% on md+ -->
765
787
  <div
766
- class="flex flex-col min-h-0 overflow-hidden border-r border-[#2a2a2a]"
767
- style="flex: 58 1 0%"
788
+ class=${
789
+ "flex-col min-h-0 overflow-hidden md:border-r border-[#2a2a2a] md:flex-[58_1_0%] flex-1 " +
790
+ (mobileTab === "chat" ? "flex" : "hidden md:flex")
791
+ }
768
792
  >
769
793
  <${GatewayChat} gwRunning=${gwRunning} />
770
794
  </div>
771
795
 
772
- <!-- Sidebar (right, ~42%): MetricsStrip + AgentRoster + MailFeed -->
773
- <div class="flex flex-col min-h-0 overflow-hidden" style="flex: 42 1 0%">
796
+ <!-- Sidebar: MetricsStrip + AgentRoster + MailFeed — right ~42% on md+ -->
797
+ <div
798
+ class=${
799
+ "flex-col min-h-0 overflow-hidden md:flex-[42_1_0%] flex-1 " +
800
+ (mobileTab === "status" ? "flex" : "hidden md:flex")
801
+ }
802
+ >
774
803
  <${MetricsStrip} agents=${agents} status=${status} />
775
804
  <${AgentRoster} agents=${agents} mail=${mail} events=${activityEvents} />
776
805
  <${MailFeed} mail=${mail} />