thepopebot 1.2.76-beta.2 → 1.2.76-beta.21

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 (128) hide show
  1. package/README.md +3 -3
  2. package/api/CLAUDE.md +11 -4
  3. package/api/index.js +56 -18
  4. package/bin/CLAUDE.md +7 -4
  5. package/bin/cli.js +25 -45
  6. package/config/CLAUDE.md +23 -4
  7. package/drizzle/0021_coding_agent_workspace.sql +1 -0
  8. package/drizzle/0022_organic_apocalypse.sql +16 -0
  9. package/drizzle/0023_needy_ender_wiggin.sql +1 -0
  10. package/drizzle/meta/0021_snapshot.json +639 -0
  11. package/drizzle/meta/0022_snapshot.json +743 -0
  12. package/drizzle/meta/0023_snapshot.json +750 -0
  13. package/drizzle/meta/_journal.json +21 -0
  14. package/lib/CLAUDE.md +2 -2
  15. package/lib/actions.js +9 -1
  16. package/lib/ai/CLAUDE.md +72 -57
  17. package/lib/ai/helper-llm.js +108 -0
  18. package/lib/ai/index.js +308 -438
  19. package/lib/ai/line-mappers.js +42 -24
  20. package/lib/ai/scope.js +26 -0
  21. package/lib/ai/sdk-adapters/CLAUDE.md +114 -0
  22. package/lib/ai/sdk-adapters/claude-code.js +120 -8
  23. package/lib/ai/system-prompt.js +34 -0
  24. package/lib/ai/workspace-setup.js +19 -35
  25. package/lib/channels/CLAUDE.md +14 -4
  26. package/lib/channels/base.js +6 -2
  27. package/lib/channels/commands/index.js +42 -0
  28. package/lib/channels/commands/session.js +53 -0
  29. package/lib/channels/commands/verify.js +18 -0
  30. package/lib/channels/telegram.js +79 -28
  31. package/lib/chat/CLAUDE.md +4 -4
  32. package/lib/chat/actions.js +270 -49
  33. package/lib/chat/api.js +185 -31
  34. package/lib/chat/components/CLAUDE.md +6 -2
  35. package/lib/chat/components/chat-input.js +77 -47
  36. package/lib/chat/components/chat-input.jsx +77 -40
  37. package/lib/chat/components/chat-page.js +2 -0
  38. package/lib/chat/components/chat-page.jsx +3 -0
  39. package/lib/chat/components/chat.js +62 -14
  40. package/lib/chat/components/chat.jsx +68 -10
  41. package/lib/chat/components/code-mode-toggle.js +141 -22
  42. package/lib/chat/components/code-mode-toggle.jsx +129 -20
  43. package/lib/chat/components/containers-page.js +58 -40
  44. package/lib/chat/components/containers-page.jsx +64 -25
  45. package/lib/chat/components/crons-page.js +17 -3
  46. package/lib/chat/components/crons-page.jsx +34 -6
  47. package/lib/chat/components/index.js +2 -2
  48. package/lib/chat/components/message.js +18 -3
  49. package/lib/chat/components/message.jsx +18 -3
  50. package/lib/chat/components/profile-page.js +182 -4
  51. package/lib/chat/components/profile-page.jsx +196 -1
  52. package/lib/chat/components/scope-picker.js +21 -0
  53. package/lib/chat/components/scope-picker.jsx +27 -0
  54. package/lib/chat/components/settings-chat-page.js +11 -11
  55. package/lib/chat/components/settings-chat-page.jsx +14 -18
  56. package/lib/chat/components/settings-coding-agents-page.js +110 -16
  57. package/lib/chat/components/settings-coding-agents-page.jsx +87 -3
  58. package/lib/chat/components/settings-github-page.js +5 -0
  59. package/lib/chat/components/settings-github-page.jsx +5 -0
  60. package/lib/chat/components/settings-layout.js +3 -3
  61. package/lib/chat/components/settings-layout.jsx +3 -3
  62. package/lib/chat/components/settings-secrets-layout.js +1 -2
  63. package/lib/chat/components/settings-secrets-layout.jsx +1 -2
  64. package/lib/chat/components/settings-secrets-page.js +180 -75
  65. package/lib/chat/components/settings-secrets-page.jsx +212 -66
  66. package/lib/chat/components/triggers-page.js +17 -3
  67. package/lib/chat/components/triggers-page.jsx +34 -6
  68. package/lib/chat/components/ui/combobox.js +18 -2
  69. package/lib/chat/components/ui/combobox.jsx +17 -1
  70. package/lib/chat/components/ui/dropdown-menu.js +23 -2
  71. package/lib/chat/components/ui/dropdown-menu.jsx +27 -2
  72. package/lib/chat/telegram-profile.js +33 -0
  73. package/lib/cluster/CLAUDE.md +9 -3
  74. package/lib/code/CLAUDE.md +11 -3
  75. package/lib/code/actions.js +47 -8
  76. package/lib/code/terminal-view.js +31 -21
  77. package/lib/code/terminal-view.jsx +32 -23
  78. package/lib/config.js +15 -4
  79. package/lib/containers/CLAUDE.md +16 -6
  80. package/lib/db/CLAUDE.md +5 -2
  81. package/lib/db/chats.js +9 -17
  82. package/lib/db/code-workspaces.js +8 -3
  83. package/lib/db/config.js +0 -1
  84. package/lib/db/index.js +12 -0
  85. package/lib/db/schema.js +24 -1
  86. package/lib/db/user-channels.js +129 -0
  87. package/lib/llm-providers.js +8 -0
  88. package/lib/maintenance.js +31 -21
  89. package/lib/tools/CLAUDE.md +12 -3
  90. package/lib/tools/assemblyai.js +17 -0
  91. package/lib/tools/create-agent-job.js +12 -8
  92. package/lib/tools/docker.js +34 -10
  93. package/lib/tools/github.js +34 -0
  94. package/lib/tools/telegram.js +106 -0
  95. package/lib/utils/render-md.js +44 -18
  96. package/package.json +8 -8
  97. package/setup/CLAUDE.md +11 -5
  98. package/setup/lib/providers.mjs +2 -1
  99. package/setup/lib/targets.mjs +13 -16
  100. package/setup/lib/telegram.mjs +8 -69
  101. package/templates/.env.example +0 -7
  102. package/templates/.github/workflows/rebuild-event-handler.yml +1 -1
  103. package/templates/.gitignore.template +1 -3
  104. package/templates/CLAUDE.md +1 -1
  105. package/templates/CLAUDE.md.template +29 -7
  106. package/templates/agent-job/CLAUDE.md.template +5 -3
  107. package/templates/agent-job/CRONS.json +16 -0
  108. package/templates/agent-job/SYSTEM.md +16 -11
  109. package/templates/agents/CLAUDE.md.template +17 -17
  110. package/templates/coding-workspace/CLAUDE.md.template +7 -0
  111. package/templates/data/CLAUDE.md.template +1 -1
  112. package/templates/docker-compose.custom.yml +1 -0
  113. package/templates/docker-compose.yml +1 -0
  114. package/templates/event-handler/CLAUDE.md.template +79 -0
  115. package/templates/event-handler/TRIGGERS.json +18 -2
  116. package/templates/skills/CLAUDE.md.template +20 -22
  117. package/templates/skills/{library/agent-job-secrets → agent-job-secrets}/SKILL.md +2 -2
  118. package/lib/ai/agent.js +0 -65
  119. package/lib/ai/async-channel.js +0 -51
  120. package/lib/ai/model.js +0 -130
  121. package/lib/ai/tools.js +0 -164
  122. package/lib/tools/openai.js +0 -37
  123. package/setup/lib/telegram-verify.mjs +0 -63
  124. package/setup/setup-telegram.mjs +0 -260
  125. package/templates/agent-job/SOUL.md +0 -17
  126. /package/templates/{skills/active/.gitkeep → coding-workspace/SYSTEM.md} +0 -0
  127. /package/templates/skills/{library/agent-job-secrets → agent-job-secrets}/agent-job-secrets.js +0 -0
  128. /package/templates/skills/{library/playwright-cli → playwright-cli}/SKILL.md +0 -0
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { useState, useEffect, useCallback, useRef } from 'react';
4
4
  import { createPortal } from 'react-dom';
5
- import { GitBranchIcon, ChevronDownIcon, SpinnerIcon, XIcon } from './icons.js';
5
+ import { GitBranchIcon, ChevronDownIcon, SpinnerIcon, XIcon, PlusIcon } from './icons.js';
6
6
  import { Combobox } from './ui/combobox.js';
7
7
  import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator } from './ui/dropdown-menu.js';
8
8
  import { cn } from '../utils.js';
@@ -30,12 +30,14 @@ export function RepoBranchPicker({
30
30
  onBranchChange,
31
31
  getRepositories,
32
32
  getBranches,
33
+ createRepository,
33
34
  }) {
34
35
  const [repos, setRepos] = useState([]);
35
36
  const [branches, setBranches] = useState([]);
36
37
  const [loadingRepos, setLoadingRepos] = useState(false);
37
38
  const [loadingBranches, setLoadingBranches] = useState(false);
38
39
  const [reposLoaded, setReposLoaded] = useState(false);
40
+ const [showCreateDialog, setShowCreateDialog] = useState(false);
39
41
 
40
42
  // Load repos eagerly on mount
41
43
  useEffect(() => {
@@ -67,6 +69,11 @@ export function RepoBranchPicker({
67
69
  }).catch(() => setLoadingBranches(false));
68
70
  }, [repo]);
69
71
 
72
+ const handleRepoCreated = useCallback((fullName) => {
73
+ setRepos((prev) => [...prev, { full_name: fullName, default_branch: 'main' }]);
74
+ onRepoChange(fullName);
75
+ }, [onRepoChange]);
76
+
70
77
  const repoOptions = repos.map((r) => ({ value: r.full_name, label: r.full_name }));
71
78
  const branchOptions = branches.map((b) => ({ value: b.name, label: b.name }));
72
79
 
@@ -80,6 +87,11 @@ export function RepoBranchPicker({
80
87
  placeholder="Select repository..."
81
88
  loading={loadingRepos}
82
89
  highlight={!repo && !loadingRepos}
90
+ footerAction={createRepository ? {
91
+ icon: <PlusIcon size={14} />,
92
+ label: 'Create new repository...',
93
+ onClick: () => setShowCreateDialog(true),
94
+ } : undefined}
83
95
  />
84
96
  </div>
85
97
  <div className={cn("w-full sm:w-auto sm:min-w-[200px] sm:max-w-[200px]", !repo && "opacity-50 pointer-events-none")}>
@@ -92,10 +104,99 @@ export function RepoBranchPicker({
92
104
  highlight={!!repo && !branch && !loadingBranches}
93
105
  />
94
106
  </div>
107
+ {showCreateDialog && (
108
+ <CreateRepoDialog
109
+ onClose={() => setShowCreateDialog(false)}
110
+ onCreate={handleRepoCreated}
111
+ createRepository={createRepository}
112
+ />
113
+ )}
95
114
  </div>
96
115
  );
97
116
  }
98
117
 
118
+ function CreateRepoDialog({ onClose, onCreate, createRepository }) {
119
+ const [name, setName] = useState('');
120
+ const [creating, setCreating] = useState(false);
121
+ const [error, setError] = useState(null);
122
+ const inputRef = useRef(null);
123
+
124
+ useEffect(() => {
125
+ setTimeout(() => inputRef.current?.focus(), 0);
126
+ }, []);
127
+
128
+ useEffect(() => {
129
+ const handleEsc = (e) => {
130
+ if (e.key === 'Escape') onClose();
131
+ };
132
+ document.addEventListener('keydown', handleEsc);
133
+ return () => document.removeEventListener('keydown', handleEsc);
134
+ }, [onClose]);
135
+
136
+ const handleSubmit = async (e) => {
137
+ e.preventDefault();
138
+ const trimmed = name.trim();
139
+ if (!trimmed || creating) return;
140
+ setCreating(true);
141
+ setError(null);
142
+ try {
143
+ const repo = await createRepository(trimmed);
144
+ onCreate(repo.full_name);
145
+ onClose();
146
+ } catch (err) {
147
+ setError(err.message || 'Failed to create repository');
148
+ setCreating(false);
149
+ }
150
+ };
151
+
152
+ return createPortal(
153
+ <div className="fixed inset-0 z-50 flex items-center justify-center">
154
+ <div className="fixed inset-0 bg-black/50" onClick={onClose} />
155
+ <div
156
+ className="relative z-50 w-full max-w-md mx-4 rounded-lg border border-border bg-background p-6 shadow-lg"
157
+ onClick={(e) => e.stopPropagation()}
158
+ >
159
+ <div className="flex items-center justify-between mb-4">
160
+ <h3 className="text-base font-semibold">Create Repository</h3>
161
+ <button onClick={onClose} className="text-muted-foreground hover:text-foreground transition-colors">
162
+ <XIcon size={16} />
163
+ </button>
164
+ </div>
165
+
166
+ <form onSubmit={handleSubmit}>
167
+ <label className="block text-sm text-muted-foreground mb-1.5">Repository name</label>
168
+ <input
169
+ ref={inputRef}
170
+ type="text"
171
+ value={name}
172
+ onChange={(e) => setName(e.target.value)}
173
+ placeholder="my-project"
174
+ className="w-full rounded-md border border-border bg-muted px-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary"
175
+ />
176
+ {error && <p className="text-xs text-destructive mt-2">{error}</p>}
177
+ <div className="mt-5 flex justify-end gap-2">
178
+ <button
179
+ type="button"
180
+ onClick={onClose}
181
+ className="px-3 py-1.5 text-sm border border-border text-muted-foreground hover:text-foreground rounded-md transition-colors"
182
+ >
183
+ Cancel
184
+ </button>
185
+ <button
186
+ type="submit"
187
+ disabled={!name.trim() || creating}
188
+ className="px-3 py-1.5 text-sm font-medium bg-foreground text-background hover:bg-foreground/90 disabled:opacity-50 rounded-md transition-colors"
189
+ >
190
+ {creating ? 'Creating...' : 'Create'}
191
+ </button>
192
+ </div>
193
+ </form>
194
+ </div>
195
+ </div>,
196
+ document.body
197
+ );
198
+ }
199
+
99
200
  /**
100
201
  * Workspace toolbar bar with branch info, diff stats, and command buttons.
101
202
  * Only rendered when a workspace exists (after first message creates one).
@@ -109,6 +210,7 @@ export function WorkspaceBar({
109
210
  diffStats,
110
211
  onDiffStatsRefresh,
111
212
  onShowDiff,
213
+ chatMode = 'agent',
112
214
  }) {
113
215
  const [branches, setBranches] = useState([]);
114
216
  const [loadingBranches, setLoadingBranches] = useState(false);
@@ -124,7 +226,7 @@ export function WorkspaceBar({
124
226
  {branch && (
125
227
  <>
126
228
  <span className="shrink-0 text-muted-foreground/30 hidden md:inline">/</span>
127
- <div className="shrink-0 max-w-[120px]">
229
+ <div className="min-w-0">
128
230
  <Combobox
129
231
  options={branches.map((b) => ({ value: b.name, label: b.name }))}
130
232
  value={branch}
@@ -141,20 +243,20 @@ export function WorkspaceBar({
141
243
  }).finally(() => setLoadingBranches(false));
142
244
  }
143
245
  }}
144
- triggerClassName="font-medium text-foreground hover:text-primary hover:bg-accent transition-colors cursor-pointer truncate text-xs rounded px-1 -mx-1"
145
- triggerLabel={<span className="truncate" title={branch}>{branch}</span>}
246
+ triggerClassName="inline-block max-w-[70px] md:max-w-[160px] text-left font-medium text-foreground hover:text-primary hover:bg-accent transition-colors cursor-pointer truncate text-xs rounded px-1 -mx-1 align-middle"
247
+ triggerLabel={<span title={branch}>{branch}</span>}
146
248
  />
147
249
  </div>
148
250
  </>
149
251
  )}
150
- {featureBranch && (
252
+ {featureBranch && featureBranch !== branch && (
151
253
  <>
152
254
  <span className="shrink-0 text-muted-foreground/50">&larr;</span>
153
255
  <span className="text-primary truncate min-w-0 cursor-default" title={featureBranch}>{featureBranch}</span>
154
256
  </>
155
257
  )}
156
258
  </div>
157
- {workspace?.id && <WorkspaceCommandButton workspaceId={workspace.id} diffStats={diffStats} onDiffStatsRefresh={onDiffStatsRefresh} onShowDiff={onShowDiff} />}
259
+ {workspace?.id && <WorkspaceCommandButton workspaceId={workspace.id} diffStats={diffStats} onDiffStatsRefresh={onDiffStatsRefresh} onShowDiff={onShowDiff} chatMode={chatMode} />}
158
260
  </div>
159
261
  );
160
262
  }
@@ -243,16 +345,33 @@ export function CommandOutputDialog({ title, logs, exitCode, running, onClose })
243
345
  }
244
346
 
245
347
  const STORAGE_KEY = 'thepopebot-workspace-command';
348
+ const FALLBACK_BY_MODE = { agent: 'push', code: 'create-pr' };
246
349
 
247
- function WorkspaceCommandButton({ workspaceId, diffStats, onDiffStatsRefresh, onShowDiff }) {
350
+ function WorkspaceCommandButton({ workspaceId, diffStats, onDiffStatsRefresh, onShowDiff, chatMode = 'agent' }) {
351
+ const storageKey = `${STORAGE_KEY}:${chatMode}`;
248
352
  const [selectedCommand, setSelectedCommandState] = useState(() => {
249
- try { return localStorage.getItem(STORAGE_KEY) || 'create-pr'; } catch { return 'create-pr'; }
250
-
353
+ try { return localStorage.getItem(storageKey) || FALLBACK_BY_MODE[chatMode] || 'create-pr'; }
354
+ catch { return FALLBACK_BY_MODE[chatMode] || 'create-pr'; }
251
355
  });
252
356
  const setSelectedCommand = (cmd) => {
253
357
  setSelectedCommandState(cmd);
254
- try { localStorage.setItem(STORAGE_KEY, cmd); } catch {}
358
+ try { localStorage.setItem(storageKey, cmd); } catch {}
255
359
  };
360
+
361
+ // If user hasn't picked anything for this mode yet, seed from admin default.
362
+ useEffect(() => {
363
+ let stored = null;
364
+ try { stored = localStorage.getItem(storageKey); } catch {}
365
+ if (stored) return;
366
+ let cancelled = false;
367
+ import('../actions.js').then(({ getModeGitActionDefault }) => {
368
+ getModeGitActionDefault(chatMode).then((val) => {
369
+ if (cancelled || !val) return;
370
+ setSelectedCommandState(val);
371
+ }).catch(() => {});
372
+ }).catch(() => {});
373
+ return () => { cancelled = true; };
374
+ }, [chatMode, storageKey]);
256
375
  const [commandRunning, setCommandRunning] = useState(false);
257
376
  const [dialogOpen, setDialogOpen] = useState(false);
258
377
  const [commandLogs, setCommandLogs] = useState([]);
@@ -269,16 +388,6 @@ function WorkspaceCommandButton({ workspaceId, diffStats, onDiffStatsRefresh, on
269
388
  const handleRun = useCallback(async () => {
270
389
  if (commandRunning) return;
271
390
 
272
- // Refresh diff stats and check for changes before running
273
- const fresh = await onDiffStatsRefresh?.();
274
- const stats = fresh || diffStats;
275
- if (!(stats?.insertions || 0) && !(stats?.deletions || 0)) {
276
- setDialogOpen(true);
277
- setCommandLogs([{ stream: 'stderr', raw: 'You have no changes.', parsed: [{ type: 'text', text: 'You have no changes.' }] }]);
278
- setCommandExitCode(1);
279
- return;
280
- }
281
-
282
391
  setCommandRunning(true);
283
392
  setDialogOpen(true);
284
393
  setCommandLogs([]);
@@ -3,7 +3,7 @@ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
3
  import { useState, useEffect, useCallback, useRef } from "react";
4
4
  import { createPortal } from "react-dom";
5
5
  import { PageLayout } from "./page-layout.js";
6
- import { SpinnerIcon, RefreshIcon, StopIcon, PlayIcon, TrashIcon, XIcon } from "./icons.js";
6
+ import { SpinnerIcon, RefreshIcon, StopIcon, PlayIcon, TrashIcon, XIcon, FileTextIcon } from "./icons.js";
7
7
  import { ConfirmDialog } from "./ui/confirm-dialog.js";
8
8
  import { CodeLogView } from "./code-log-view.js";
9
9
  import {
@@ -195,81 +195,99 @@ function ContainerRow({ container, onRequestStop, onShowLogs, isStopping, isStar
195
195
  /* @__PURE__ */ jsx("td", { className: "py-2.5 pr-3 text-xs text-right hidden md:table-cell whitespace-nowrap", children: isRunning && container.stats ? /* @__PURE__ */ jsx("span", { children: formatBytes(container.stats.memUsage) }) : /* @__PURE__ */ jsx("span", { className: "text-muted-foreground", children: "\u2014" }) }),
196
196
  /* @__PURE__ */ jsx("td", { className: "py-2.5 pr-3 text-xs text-muted-foreground hidden lg:table-cell whitespace-nowrap", children: container.status }),
197
197
  /* @__PURE__ */ jsx("td", { className: "py-2.5 text-right whitespace-nowrap", children: /* @__PURE__ */ jsxs("div", { className: "inline-flex items-center gap-1.5", children: [
198
- isRunning && (isStopping ? /* @__PURE__ */ jsxs(
198
+ /* @__PURE__ */ jsx(
199
+ "button",
200
+ {
201
+ onClick: () => onShowLogs(container.name),
202
+ title: "Logs",
203
+ "aria-label": "Logs",
204
+ className: "inline-flex items-center justify-center rounded-md p-1.5 border border-border text-muted-foreground hover:bg-accent hover:text-foreground transition-colors",
205
+ children: /* @__PURE__ */ jsx(FileTextIcon, { size: 14 })
206
+ }
207
+ ),
208
+ isRunning && (isStopping ? /* @__PURE__ */ jsx(
199
209
  "button",
200
210
  {
201
211
  disabled: true,
202
- className: "inline-flex items-center gap-1 rounded-md px-2.5 py-1.5 text-xs font-medium border border-border text-muted-foreground disabled:opacity-50 disabled:pointer-events-none transition-colors",
203
- children: [
204
- /* @__PURE__ */ jsx(SpinnerIcon, { size: 12 }),
205
- "Stopping..."
206
- ]
212
+ title: "Stopping...",
213
+ "aria-label": "Stopping",
214
+ className: "inline-flex items-center justify-center rounded-md p-1.5 border border-border text-muted-foreground disabled:opacity-50 disabled:pointer-events-none transition-colors",
215
+ children: /* @__PURE__ */ jsx(SpinnerIcon, { size: 14 })
207
216
  }
208
- ) : /* @__PURE__ */ jsxs(
217
+ ) : /* @__PURE__ */ jsx(
209
218
  "button",
210
219
  {
211
220
  onClick: () => onRequestStop(container.name),
212
- className: "inline-flex items-center gap-1 rounded-md px-2.5 py-1.5 text-xs font-medium border border-border text-muted-foreground hover:bg-accent hover:text-foreground transition-colors",
213
- children: [
214
- /* @__PURE__ */ jsx(StopIcon, { size: 12 }),
215
- "Stop"
216
- ]
221
+ title: "Stop",
222
+ "aria-label": "Stop",
223
+ className: "inline-flex items-center justify-center rounded-md p-1.5 border border-border text-muted-foreground hover:bg-accent hover:text-foreground transition-colors",
224
+ children: /* @__PURE__ */ jsx(StopIcon, { size: 14 })
217
225
  }
218
226
  )),
219
- isStopped && (isStarting ? /* @__PURE__ */ jsxs(
227
+ isStopped && (isStarting ? /* @__PURE__ */ jsx(
220
228
  "button",
221
229
  {
222
230
  disabled: true,
223
- className: "inline-flex items-center gap-1 rounded-md px-2.5 py-1.5 text-xs font-medium border border-border text-muted-foreground disabled:opacity-50 disabled:pointer-events-none transition-colors",
224
- children: [
225
- /* @__PURE__ */ jsx(SpinnerIcon, { size: 12 }),
226
- "Starting..."
227
- ]
231
+ title: "Starting...",
232
+ "aria-label": "Starting",
233
+ className: "inline-flex items-center justify-center rounded-md p-1.5 border border-border text-muted-foreground disabled:opacity-50 disabled:pointer-events-none transition-colors",
234
+ children: /* @__PURE__ */ jsx(SpinnerIcon, { size: 14 })
228
235
  }
229
- ) : /* @__PURE__ */ jsxs(
236
+ ) : /* @__PURE__ */ jsx(
230
237
  "button",
231
238
  {
232
239
  onClick: () => onRequestStop(container.name, "start"),
233
- className: "inline-flex items-center gap-1 rounded-md px-2.5 py-1.5 text-xs font-medium border border-border text-muted-foreground hover:bg-accent hover:text-foreground transition-colors",
234
- children: [
235
- /* @__PURE__ */ jsx(PlayIcon, { size: 12 }),
236
- "Start"
237
- ]
240
+ title: "Start",
241
+ "aria-label": "Start",
242
+ className: "inline-flex items-center justify-center rounded-md p-1.5 border border-border text-muted-foreground hover:bg-accent hover:text-foreground transition-colors",
243
+ children: /* @__PURE__ */ jsx(PlayIcon, { size: 14 })
238
244
  }
239
245
  )),
240
246
  /* @__PURE__ */ jsx(
241
- "button",
242
- {
243
- onClick: () => onShowLogs(container.name),
244
- className: "inline-flex items-center gap-1 rounded-md px-2.5 py-1.5 text-xs font-medium border border-border text-muted-foreground hover:bg-accent hover:text-foreground transition-colors",
245
- children: "Logs"
246
- }
247
- ),
248
- /* @__PURE__ */ jsxs(
249
247
  "button",
250
248
  {
251
249
  onClick: () => handleAction("remove"),
252
250
  disabled: removingContainer,
253
- className: `inline-flex items-center gap-1 rounded-md px-2.5 py-1.5 text-xs font-medium border transition-colors disabled:opacity-50 disabled:pointer-events-none ${confirmingRemove ? "border-destructive text-destructive hover:bg-destructive/10" : "border-border text-muted-foreground hover:bg-accent hover:text-foreground"}`,
254
- children: [
255
- removingContainer ? /* @__PURE__ */ jsx(SpinnerIcon, { size: 12 }) : /* @__PURE__ */ jsx(TrashIcon, { size: 12 }),
256
- confirmingRemove ? "Confirm" : "Remove"
257
- ]
251
+ title: confirmingRemove ? "Confirm remove" : "Remove",
252
+ "aria-label": confirmingRemove ? "Confirm remove" : "Remove",
253
+ className: `inline-flex items-center justify-center rounded-md p-1.5 border transition-colors disabled:opacity-50 disabled:pointer-events-none ${confirmingRemove ? "border-destructive text-destructive hover:bg-destructive/10" : "border-border text-muted-foreground hover:bg-accent hover:text-foreground"}`,
254
+ children: removingContainer ? /* @__PURE__ */ jsx(SpinnerIcon, { size: 14 }) : /* @__PURE__ */ jsx(TrashIcon, { size: 14 })
258
255
  }
259
256
  )
260
257
  ] }) })
261
258
  ] });
262
259
  }
263
260
  function DockerContainersSection({ containers, loading, onRequestStop, onShowLogs, pendingStop, pendingStart }) {
261
+ const [activeTab, setActiveTab] = useState("running");
264
262
  if (loading) {
265
263
  return /* @__PURE__ */ jsxs("div", { className: "space-y-4", children: [
266
264
  /* @__PURE__ */ jsx("h2", { className: "text-base font-medium", children: "Docker Containers" }),
267
265
  /* @__PURE__ */ jsx("div", { className: "flex flex-col gap-3", children: [...Array(3)].map((_, i) => /* @__PURE__ */ jsx("div", { className: "h-14 animate-pulse rounded-md bg-border/50" }, i)) })
268
266
  ] });
269
267
  }
268
+ const runningContainers = containers.filter((c) => c.state === "running");
269
+ const exitedContainers = containers.filter((c) => c.state !== "running");
270
+ const visibleContainers = activeTab === "running" ? runningContainers : exitedContainers;
271
+ const tabs = [
272
+ { id: "running", label: `Running (${runningContainers.length})` },
273
+ { id: "exited", label: `Exited (${exitedContainers.length})` }
274
+ ];
270
275
  return /* @__PURE__ */ jsxs("div", { className: "space-y-4", children: [
271
276
  /* @__PURE__ */ jsx("h2", { className: "text-base font-medium", children: "Docker Containers" }),
272
- containers.length === 0 ? /* @__PURE__ */ jsx("div", { className: "rounded-lg border border-dashed border-border p-8 text-center text-sm text-muted-foreground", children: "No containers found on the compose network." }) : /* @__PURE__ */ jsx("div", { className: "overflow-x-auto", children: /* @__PURE__ */ jsxs("table", { className: "w-full text-left", children: [
277
+ /* @__PURE__ */ jsx("div", { className: "flex gap-1.5 overflow-x-auto scrollbar-hide max-w-full", children: tabs.map((tab) => {
278
+ const isActive = activeTab === tab.id;
279
+ return /* @__PURE__ */ jsx(
280
+ "button",
281
+ {
282
+ type: "button",
283
+ onClick: () => setActiveTab(tab.id),
284
+ className: `rounded-full px-3 py-1.5 min-h-[36px] inline-flex items-center text-xs font-medium transition-colors shrink-0 whitespace-nowrap ${isActive ? "bg-foreground text-background" : "text-muted-foreground hover:text-foreground hover:bg-accent"}`,
285
+ children: tab.label
286
+ },
287
+ tab.id
288
+ );
289
+ }) }),
290
+ visibleContainers.length === 0 ? /* @__PURE__ */ jsx("div", { className: "rounded-lg border border-dashed border-border p-8 text-center text-sm text-muted-foreground", children: activeTab === "running" ? "No running containers." : "No exited containers." }) : /* @__PURE__ */ jsx("div", { className: "overflow-x-auto", children: /* @__PURE__ */ jsxs("table", { className: "w-full text-left", children: [
273
291
  /* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsxs("tr", { className: "text-xs text-muted-foreground", children: [
274
292
  /* @__PURE__ */ jsx("th", { className: "pb-2 pr-3 font-medium", children: "State" }),
275
293
  /* @__PURE__ */ jsx("th", { className: "pb-2 pr-3 font-medium", children: "Container" }),
@@ -278,7 +296,7 @@ function DockerContainersSection({ containers, loading, onRequestStop, onShowLog
278
296
  /* @__PURE__ */ jsx("th", { className: "pb-2 pr-3 font-medium hidden lg:table-cell", children: "Status" }),
279
297
  /* @__PURE__ */ jsx("th", { className: "pb-2 font-medium text-right", children: "Actions" })
280
298
  ] }) }),
281
- /* @__PURE__ */ jsx("tbody", { children: containers.map((c) => /* @__PURE__ */ jsx(
299
+ /* @__PURE__ */ jsx("tbody", { children: visibleContainers.map((c) => /* @__PURE__ */ jsx(
282
300
  ContainerRow,
283
301
  {
284
302
  container: c,
@@ -3,7 +3,7 @@
3
3
  import { useState, useEffect, useCallback, useRef } from 'react';
4
4
  import { createPortal } from 'react-dom';
5
5
  import { PageLayout } from './page-layout.js';
6
- import { SpinnerIcon, RefreshIcon, StopIcon, PlayIcon, TrashIcon, XIcon } from './icons.js';
6
+ import { SpinnerIcon, RefreshIcon, StopIcon, PlayIcon, TrashIcon, XIcon, FileTextIcon } from './icons.js';
7
7
  import { ConfirmDialog } from './ui/confirm-dialog.js';
8
8
  import { CodeLogView } from './code-log-view.js';
9
9
  import {
@@ -263,22 +263,32 @@ function ContainerRow({ container, onRequestStop, onShowLogs, isStopping, isStar
263
263
  </td>
264
264
  <td className="py-2.5 text-right whitespace-nowrap">
265
265
  <div className="inline-flex items-center gap-1.5">
266
+ <button
267
+ onClick={() => onShowLogs(container.name)}
268
+ title="Logs"
269
+ aria-label="Logs"
270
+ className="inline-flex items-center justify-center rounded-md p-1.5 border border-border text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
271
+ >
272
+ <FileTextIcon size={14} />
273
+ </button>
266
274
  {isRunning && (
267
275
  isStopping ? (
268
276
  <button
269
277
  disabled
270
- className="inline-flex items-center gap-1 rounded-md px-2.5 py-1.5 text-xs font-medium border border-border text-muted-foreground disabled:opacity-50 disabled:pointer-events-none transition-colors"
278
+ title="Stopping..."
279
+ aria-label="Stopping"
280
+ className="inline-flex items-center justify-center rounded-md p-1.5 border border-border text-muted-foreground disabled:opacity-50 disabled:pointer-events-none transition-colors"
271
281
  >
272
- <SpinnerIcon size={12} />
273
- Stopping...
282
+ <SpinnerIcon size={14} />
274
283
  </button>
275
284
  ) : (
276
285
  <button
277
286
  onClick={() => onRequestStop(container.name)}
278
- className="inline-flex items-center gap-1 rounded-md px-2.5 py-1.5 text-xs font-medium border border-border text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
287
+ title="Stop"
288
+ aria-label="Stop"
289
+ className="inline-flex items-center justify-center rounded-md p-1.5 border border-border text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
279
290
  >
280
- <StopIcon size={12} />
281
- Stop
291
+ <StopIcon size={14} />
282
292
  </button>
283
293
  )
284
294
  )}
@@ -286,38 +296,35 @@ function ContainerRow({ container, onRequestStop, onShowLogs, isStopping, isStar
286
296
  isStarting ? (
287
297
  <button
288
298
  disabled
289
- className="inline-flex items-center gap-1 rounded-md px-2.5 py-1.5 text-xs font-medium border border-border text-muted-foreground disabled:opacity-50 disabled:pointer-events-none transition-colors"
299
+ title="Starting..."
300
+ aria-label="Starting"
301
+ className="inline-flex items-center justify-center rounded-md p-1.5 border border-border text-muted-foreground disabled:opacity-50 disabled:pointer-events-none transition-colors"
290
302
  >
291
- <SpinnerIcon size={12} />
292
- Starting...
303
+ <SpinnerIcon size={14} />
293
304
  </button>
294
305
  ) : (
295
306
  <button
296
307
  onClick={() => onRequestStop(container.name, 'start')}
297
- className="inline-flex items-center gap-1 rounded-md px-2.5 py-1.5 text-xs font-medium border border-border text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
308
+ title="Start"
309
+ aria-label="Start"
310
+ className="inline-flex items-center justify-center rounded-md p-1.5 border border-border text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
298
311
  >
299
- <PlayIcon size={12} />
300
- Start
312
+ <PlayIcon size={14} />
301
313
  </button>
302
314
  )
303
315
  )}
304
- <button
305
- onClick={() => onShowLogs(container.name)}
306
- className="inline-flex items-center gap-1 rounded-md px-2.5 py-1.5 text-xs font-medium border border-border text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
307
- >
308
- Logs
309
- </button>
310
316
  <button
311
317
  onClick={() => handleAction('remove')}
312
318
  disabled={removingContainer}
313
- className={`inline-flex items-center gap-1 rounded-md px-2.5 py-1.5 text-xs font-medium border transition-colors disabled:opacity-50 disabled:pointer-events-none ${
319
+ title={confirmingRemove ? 'Confirm remove' : 'Remove'}
320
+ aria-label={confirmingRemove ? 'Confirm remove' : 'Remove'}
321
+ className={`inline-flex items-center justify-center rounded-md p-1.5 border transition-colors disabled:opacity-50 disabled:pointer-events-none ${
314
322
  confirmingRemove
315
323
  ? 'border-destructive text-destructive hover:bg-destructive/10'
316
324
  : 'border-border text-muted-foreground hover:bg-accent hover:text-foreground'
317
325
  }`}
318
326
  >
319
- {removingContainer ? <SpinnerIcon size={12} /> : <TrashIcon size={12} />}
320
- {confirmingRemove ? 'Confirm' : 'Remove'}
327
+ {removingContainer ? <SpinnerIcon size={14} /> : <TrashIcon size={14} />}
321
328
  </button>
322
329
  </div>
323
330
  </td>
@@ -330,6 +337,8 @@ function ContainerRow({ container, onRequestStop, onShowLogs, isStopping, isStar
330
337
  // ─────────────────────────────────────────────────────────────────────────────
331
338
 
332
339
  function DockerContainersSection({ containers, loading, onRequestStop, onShowLogs, pendingStop, pendingStart }) {
340
+ const [activeTab, setActiveTab] = useState('running');
341
+
333
342
  if (loading) {
334
343
  return (
335
344
  <div className="space-y-4">
@@ -343,12 +352,42 @@ function DockerContainersSection({ containers, loading, onRequestStop, onShowLog
343
352
  );
344
353
  }
345
354
 
355
+ const runningContainers = containers.filter((c) => c.state === 'running');
356
+ const exitedContainers = containers.filter((c) => c.state !== 'running');
357
+ const visibleContainers = activeTab === 'running' ? runningContainers : exitedContainers;
358
+
359
+ const tabs = [
360
+ { id: 'running', label: `Running (${runningContainers.length})` },
361
+ { id: 'exited', label: `Exited (${exitedContainers.length})` },
362
+ ];
363
+
346
364
  return (
347
365
  <div className="space-y-4">
348
366
  <h2 className="text-base font-medium">Docker Containers</h2>
349
- {containers.length === 0 ? (
367
+
368
+ <div className="flex gap-1.5 overflow-x-auto scrollbar-hide max-w-full">
369
+ {tabs.map((tab) => {
370
+ const isActive = activeTab === tab.id;
371
+ return (
372
+ <button
373
+ key={tab.id}
374
+ type="button"
375
+ onClick={() => setActiveTab(tab.id)}
376
+ className={`rounded-full px-3 py-1.5 min-h-[36px] inline-flex items-center text-xs font-medium transition-colors shrink-0 whitespace-nowrap ${
377
+ isActive
378
+ ? 'bg-foreground text-background'
379
+ : 'text-muted-foreground hover:text-foreground hover:bg-accent'
380
+ }`}
381
+ >
382
+ {tab.label}
383
+ </button>
384
+ );
385
+ })}
386
+ </div>
387
+
388
+ {visibleContainers.length === 0 ? (
350
389
  <div className="rounded-lg border border-dashed border-border p-8 text-center text-sm text-muted-foreground">
351
- No containers found on the compose network.
390
+ {activeTab === 'running' ? 'No running containers.' : 'No exited containers.'}
352
391
  </div>
353
392
  ) : (
354
393
  <div className="overflow-x-auto">
@@ -364,7 +403,7 @@ function DockerContainersSection({ containers, loading, onRequestStop, onShowLog
364
403
  </tr>
365
404
  </thead>
366
405
  <tbody>
367
- {containers.map((c) => (
406
+ {visibleContainers.map((c) => (
368
407
  <ContainerRow
369
408
  key={c.id}
370
409
  container={c}
@@ -100,9 +100,19 @@ function CronCard({ cron }) {
100
100
  type === "agent" && cron.job && /* @__PURE__ */ jsxs("div", { children: [
101
101
  /* @__PURE__ */ jsx("p", { className: "text-xs font-medium text-muted-foreground mb-1.5", children: "Job prompt" }),
102
102
  /* @__PURE__ */ jsx("pre", { className: "text-xs bg-muted rounded-md p-3 whitespace-pre-wrap break-words font-mono overflow-auto max-h-48", children: cron.job }),
103
- (cron.llm_provider || cron.llm_model) && /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 mt-2", children: [
104
- /* @__PURE__ */ jsx("span", { className: "text-xs font-medium text-muted-foreground", children: "LLM:" }),
105
- /* @__PURE__ */ jsx("span", { className: "inline-flex items-center rounded-full bg-purple-500/10 text-purple-500 px-2 py-0.5 text-[10px] font-medium", children: [cron.llm_provider, cron.llm_model].filter(Boolean).join(" / ") })
103
+ (cron.agent_backend || cron.llm_model || cron.scope) && /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 mt-2 flex-wrap", children: [
104
+ cron.agent_backend && /* @__PURE__ */ jsxs(Fragment, { children: [
105
+ /* @__PURE__ */ jsx("span", { className: "text-xs font-medium text-muted-foreground", children: "Agent:" }),
106
+ /* @__PURE__ */ jsx("span", { className: "inline-flex items-center rounded-full bg-purple-500/10 text-purple-500 px-2 py-0.5 text-[10px] font-medium", children: cron.agent_backend })
107
+ ] }),
108
+ cron.llm_model && /* @__PURE__ */ jsxs(Fragment, { children: [
109
+ /* @__PURE__ */ jsx("span", { className: "text-xs font-medium text-muted-foreground", children: "Model:" }),
110
+ /* @__PURE__ */ jsx("span", { className: "inline-flex items-center rounded-full bg-purple-500/10 text-purple-500 px-2 py-0.5 text-[10px] font-medium", children: cron.llm_model })
111
+ ] }),
112
+ cron.scope && /* @__PURE__ */ jsxs(Fragment, { children: [
113
+ /* @__PURE__ */ jsx("span", { className: "text-xs font-medium text-muted-foreground", children: "Scope:" }),
114
+ /* @__PURE__ */ jsx("span", { className: "inline-flex items-center rounded-full bg-purple-500/10 text-purple-500 px-2 py-0.5 text-[10px] font-mono", children: cron.scope })
115
+ ] })
106
116
  ] })
107
117
  ] }),
108
118
  type === "command" && cron.command && /* @__PURE__ */ jsxs("div", { children: [
@@ -120,6 +130,10 @@ function CronCard({ cron }) {
120
130
  cron.vars && Object.keys(cron.vars).length > 0 && /* @__PURE__ */ jsxs("div", { children: [
121
131
  /* @__PURE__ */ jsx("p", { className: "text-xs font-medium text-muted-foreground mb-1.5", children: "Variables" }),
122
132
  /* @__PURE__ */ jsx("pre", { className: "text-xs bg-muted rounded-md p-3 whitespace-pre-wrap break-words font-mono overflow-auto max-h-48", children: JSON.stringify(cron.vars, null, 2) })
133
+ ] }),
134
+ cron.headers && Object.keys(cron.headers).length > 0 && /* @__PURE__ */ jsxs("div", { children: [
135
+ /* @__PURE__ */ jsx("p", { className: "text-xs font-medium text-muted-foreground mb-1.5", children: "Headers" }),
136
+ /* @__PURE__ */ jsx("pre", { className: "text-xs bg-muted rounded-md p-3 whitespace-pre-wrap break-words font-mono overflow-auto max-h-48", children: JSON.stringify(cron.headers, null, 2) })
123
137
  ] })
124
138
  ] })
125
139
  ] })