@agentprojectcontext/apx 1.39.1 → 1.40.1

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 (55) hide show
  1. package/package.json +1 -1
  2. package/src/core/agent/constants.js +7 -1
  3. package/src/core/agent/retry.js +9 -0
  4. package/src/core/agent/run-agent.js +56 -5
  5. package/src/core/agent/tools/pseudo-tools.js +13 -1
  6. package/src/core/channels/telegram/dispatch.js +23 -3
  7. package/src/core/engines/mock.js +33 -10
  8. package/src/core/i18n/en.js +2 -4
  9. package/src/core/i18n/es.js +1 -4
  10. package/src/core/i18n/index.js +5 -1
  11. package/src/core/i18n/pt.js +1 -3
  12. package/src/core/routines/runner.js +15 -3
  13. package/src/host/daemon/api/admin.js +29 -0
  14. package/src/interfaces/cli/commands/desktop.js +26 -0
  15. package/src/interfaces/cli/index.js +16 -3
  16. package/src/interfaces/desktop/main.js +7 -1
  17. package/src/interfaces/web/dist/assets/index-DW7j3cXB.js +646 -0
  18. package/src/interfaces/web/dist/assets/index-DW7j3cXB.js.map +1 -0
  19. package/src/interfaces/web/dist/assets/index-wrEbTJbc.css +1 -0
  20. package/src/interfaces/web/dist/index.html +2 -2
  21. package/src/interfaces/web/package-lock.json +188 -188
  22. package/src/interfaces/web/src/App.tsx +22 -11
  23. package/src/interfaces/web/src/components/AddProjectDialog.tsx +66 -34
  24. package/src/interfaces/web/src/components/ModelCombobox.tsx +6 -3
  25. package/src/interfaces/web/src/components/chat/MessageBubble.tsx +28 -25
  26. package/src/interfaces/web/src/components/chat/ModelPicker.tsx +19 -17
  27. package/src/interfaces/web/src/components/deck/WidgetRow.tsx +9 -7
  28. package/src/interfaces/web/src/components/inputs/VarTokenInput.tsx +21 -19
  29. package/src/interfaces/web/src/components/layout/ProjectSidebar.tsx +3 -2
  30. package/src/interfaces/web/src/components/routines/AvailableVarsCard.tsx +23 -0
  31. package/src/interfaces/web/src/components/routines/ExecutionsList.tsx +189 -0
  32. package/src/interfaces/web/src/components/routines/ReadOnlyBlock.tsx +14 -0
  33. package/src/interfaces/web/src/components/routines/RoutineDetail.tsx +86 -0
  34. package/src/interfaces/web/src/components/routines/RoutineEditor.tsx +263 -0
  35. package/src/interfaces/web/src/components/routines/RoutineList.tsx +59 -0
  36. package/src/interfaces/web/src/components/routines/VarTextarea.tsx +70 -0
  37. package/src/interfaces/web/src/components/routines/shared.ts +89 -0
  38. package/src/interfaces/web/src/components/settings/PairDeviceDialog.tsx +19 -16
  39. package/src/interfaces/web/src/components/settings/TelegramContactsPanel.tsx +10 -8
  40. package/src/interfaces/web/src/components/settings/providers/ProviderModal.tsx +7 -4
  41. package/src/interfaces/web/src/components/ui/chat-input.tsx +24 -21
  42. package/src/interfaces/web/src/components/ui/sidebar.tsx +20 -18
  43. package/src/interfaces/web/src/components/ui.tsx +4 -0
  44. package/src/interfaces/web/src/i18n/en.ts +34 -11
  45. package/src/interfaces/web/src/i18n/es.ts +34 -11
  46. package/src/interfaces/web/src/lib/api/filesystem.ts +6 -0
  47. package/src/interfaces/web/src/screens/ApxAdminScreen.tsx +11 -3
  48. package/src/interfaces/web/src/screens/modules/DeckScreen.tsx +6 -3
  49. package/src/interfaces/web/src/screens/project/AgentDetailScreen.tsx +8 -5
  50. package/src/interfaces/web/src/screens/project/McpsTab.tsx +16 -9
  51. package/src/interfaces/web/src/screens/project/RoutinesTab.tsx +126 -373
  52. package/src/interfaces/web/src/styles.css +5 -0
  53. package/src/interfaces/web/dist/assets/index-CAKEYko0.css +0 -1
  54. package/src/interfaces/web/dist/assets/index-UzqHxD0B.js +0 -639
  55. package/src/interfaces/web/dist/assets/index-UzqHxD0B.js.map +0 -1
@@ -3,6 +3,7 @@ import { RefreshCw } from "lucide-react";
3
3
  import { Deck } from "../../lib/api/deck";
4
4
  import { Section } from "../../components/Section";
5
5
  import { Button, Empty, Loading } from "../../components/ui";
6
+ import { Tip } from "../../components/ui/tip";
6
7
  import { useToast } from "../../components/Toast";
7
8
  import { DaemonCard } from "../../components/deck/DaemonCard";
8
9
  import { DesktopGroup } from "../../components/deck/DesktopGroup";
@@ -91,9 +92,11 @@ export function DeckScreen() {
91
92
  : t("modules_ui.deck_widgets_summary", { count: widgets.length, enabled: enabledCount })
92
93
  }
93
94
  action={
94
- <Button size="sm" variant="ghost" onClick={() => mutate()} disabled={isLoading} title={t("deck_screen.reload_manifest")} aria-label={t("deck_screen.reload_manifest")}>
95
- <RefreshCw size={14} className={isLoading ? "animate-spin" : ""} />
96
- </Button>
95
+ <Tip content={t("deck_screen.reload_manifest")}>
96
+ <Button size="sm" variant="ghost" onClick={() => mutate()} disabled={isLoading} aria-label={t("deck_screen.reload_manifest")}>
97
+ <RefreshCw size={14} className={isLoading ? "animate-spin" : ""} />
98
+ </Button>
99
+ </Tip>
97
100
  }
98
101
  >
99
102
  {isLoading && <Loading label={t("modules_ui.deck_loading_manifest_full")} />}
@@ -9,6 +9,7 @@ import { Agents, Conversations, Messages, Routines, Tasks, Tools } from "../../l
9
9
  import type { AgentDetail, AgentEntry, MessageEntry, RoutineEntry } from "../../types/daemon";
10
10
  import { Section } from "../../components/Section";
11
11
  import { Badge, Button, Field, Input, Loading, Switch, Textarea } from "../../components/ui";
12
+ import { Tip } from "../../components/ui/tip";
12
13
  import { UiSelect } from "../../components/UiSelect";
13
14
  import { useToast } from "../../components/Toast";
14
15
  import { cn } from "../../lib/cn";
@@ -405,11 +406,13 @@ function ToolsPicker({ value, onChange }: { value: string; onChange: (v: string)
405
406
  {catalog.map((tool) => {
406
407
  const on = selected.includes(tool.name);
407
408
  return (
408
- <button key={tool.name} type="button" title={tool.description || tool.name} onClick={() => toggle(tool.name)}
409
- className={cn("rounded-md border px-2 py-0.5 font-mono text-[11px] transition-colors",
410
- on ? "border-emerald-500/50 bg-emerald-500/10 text-emerald-400" : "border-border text-muted-fg hover:text-foreground")}>
411
- {tool.name}
412
- </button>
409
+ <Tip key={tool.name} content={tool.description || tool.name}>
410
+ <button type="button" onClick={() => toggle(tool.name)}
411
+ className={cn("rounded-md border px-2 py-0.5 font-mono text-[11px] transition-colors",
412
+ on ? "border-emerald-500/50 bg-emerald-500/10 text-emerald-400" : "border-border text-muted-fg hover:text-foreground")}>
413
+ {tool.name}
414
+ </button>
415
+ </Tip>
413
416
  );
414
417
  })}
415
418
  {custom.map((s) => (
@@ -7,6 +7,7 @@ import { Mcps, Vars, type McpAddBody, type McpScope, type McpTestResult, type Mc
7
7
  import type { McpEntry } from "../../types/daemon";
8
8
  import { Section } from "../../components/Section";
9
9
  import { Badge, Button, Dialog, Empty, Field, Input, Loading, Switch } from "../../components/ui";
10
+ import { Tip } from "../../components/ui/tip";
10
11
  import { UiSelect } from "../../components/UiSelect";
11
12
  import { VarTokenInput } from "../../components/inputs/VarTokenInput";
12
13
  import { KeyValueList, recordFromRows, rowsFromRecord, type KvRow } from "../../components/inputs/KeyValueList";
@@ -122,16 +123,22 @@ export function McpsTab({ pid }: { pid: string }) {
122
123
  label=""
123
124
  />
124
125
  </div>
125
- <Button size="sm" variant="ghost" onClick={(e) => { e.stopPropagation(); runTest(m.name); }} aria-label={t("project.mcps.test_btn")} title={t("project.mcps.test_btn")}>
126
- <FlaskConical size={13} />
127
- </Button>
128
- <Button size="sm" variant="ghost" onClick={(e) => { e.stopPropagation(); setActiveMcp(m.name); }} aria-label={t("project.mcps.logs_btn")} title={t("project.mcps.logs_btn")}>
129
- <ScrollText size={13} />
130
- </Button>
131
- {writable && (
132
- <Button size="sm" variant="ghost" onClick={(e) => { e.stopPropagation(); setDialog({ kind: "edit", entry: m }); }} aria-label={t("project.mcps.edit_btn")} title={t("project.mcps.edit_btn")}>
133
- <Pencil size={13} />
126
+ <Tip content={t("project.mcps.test_btn")}>
127
+ <Button size="sm" variant="ghost" onClick={(e) => { e.stopPropagation(); runTest(m.name); }} aria-label={t("project.mcps.test_btn")}>
128
+ <FlaskConical size={13} />
129
+ </Button>
130
+ </Tip>
131
+ <Tip content={t("project.mcps.logs_btn")}>
132
+ <Button size="sm" variant="ghost" onClick={(e) => { e.stopPropagation(); setActiveMcp(m.name); }} aria-label={t("project.mcps.logs_btn")}>
133
+ <ScrollText size={13} />
134
134
  </Button>
135
+ </Tip>
136
+ {writable && (
137
+ <Tip content={t("project.mcps.edit_btn")}>
138
+ <Button size="sm" variant="ghost" onClick={(e) => { e.stopPropagation(); setDialog({ kind: "edit", entry: m }); }} aria-label={t("project.mcps.edit_btn")}>
139
+ <Pencil size={13} />
140
+ </Button>
141
+ </Tip>
135
142
  )}
136
143
  {writable && (
137
144
  <Button size="sm" variant="destructive" onClick={(e) => { e.stopPropagation(); remove(m.name, scopeForRemove); }}>
@@ -1,159 +1,116 @@
1
- import { useEffect, useMemo, useState } from "react";
2
- import useSWR from "swr";
3
- import { ArrowRight, Bot, Crown, Heart, Play, Plus, Send, Terminal, Trash2, Zap } from "lucide-react";
4
- import { Routines, Agents, type RoutineEntry } from "../../lib/api";
5
- import { Section } from "../../components/Section";
6
- import { Badge, Button, Dialog, Empty, Field, Input, Loading, Switch, Textarea } from "../../components/ui";
7
- import { UiSelect } from "../../components/UiSelect";
1
+ import { useEffect, useState } from "react";
2
+ import { useSearchParams } from "react-router-dom";
3
+ import useSWR, { mutate as globalMutate } from "swr";
4
+ import { Plus } from "lucide-react";
5
+ import { Routines, type RoutineEntry } from "../../lib/api";
6
+ import { Button, Dialog, Empty, Loading } from "../../components/ui";
8
7
  import { useToast } from "../../components/Toast";
9
- import { cn } from "../../lib/cn";
10
8
  import { t } from "../../i18n";
9
+ import { RoutineList } from "../../components/routines/RoutineList";
10
+ import { RoutineDetail } from "../../components/routines/RoutineDetail";
11
+ import { RoutineEditor } from "../../components/routines/RoutineEditor";
11
12
 
12
- function splitLines(v: string): string[] {
13
- return v.split("\n").map((s) => s.trim()).filter(Boolean);
14
- }
15
-
16
- type Kind = RoutineEntry["kind"];
17
-
18
- // Friendly action types (maps to routines.js kinds).
19
- function kindMeta(): Record<Kind, { label: string; desc: string; icon: typeof Bot }> {
20
- return {
21
- exec_agent: { label: t("agents_ui.kind_exec_agent"), desc: t("agents_ui.kind_exec_agent_desc"), icon: Bot },
22
- super_agent: { label: t("agents_ui.kind_super_agent"), desc: t("agents_ui.kind_super_agent_desc"), icon: Crown },
23
- telegram: { label: t("agents_ui.kind_telegram"), desc: t("agents_ui.kind_telegram_desc"), icon: Send },
24
- shell: { label: t("agents_ui.kind_shell"), desc: t("agents_ui.kind_shell_desc"), icon: Terminal },
25
- heartbeat: { label: t("agents_ui.kind_heartbeat"), desc: t("agents_ui.kind_heartbeat_desc"), icon: Heart },
26
- };
27
- }
28
- function kindOptions() {
29
- const meta = kindMeta();
30
- return (Object.keys(meta) as Kind[]).map((k) => ({ value: k, label: meta[k].label, description: meta[k].desc, icon: meta[k].icon }));
31
- }
32
-
33
- // "every:10m" → "cada 10 minutos", cron/once → legible.
34
- function scheduleHuman(s?: string): string {
35
- if (!s) return "—";
36
- if (s.startsWith("every:")) {
37
- const v = s.slice(6);
38
- const m = v.match(/^(\d+)(s|m|h|d)$/);
39
- if (m) {
40
- const n = m[1];
41
- const unit = {
42
- s: t("agents_ui.unit_seconds"),
43
- m: t("agents_ui.unit_minutes"),
44
- h: t("agents_ui.unit_hours"),
45
- d: t("agents_ui.unit_days"),
46
- }[m[2]] || m[2];
47
- return t("agents_ui.every_n_unit", { n, unit });
48
- }
49
- return t("agents_ui.every_v", { v });
50
- }
51
- if (s.startsWith("once:")) return `once · ${new Date(s.slice(5)).toLocaleString()}`;
52
- if (s.startsWith("cron ")) return `cron · ${s.slice(5)}`;
53
- return s;
54
- }
55
-
56
- function schedPresets() {
57
- return [
58
- { label: t("agents_ui.preset_every_10m"), value: "every:10m" },
59
- { label: t("agents_ui.preset_hourly"), value: "every:1h" },
60
- { label: t("agents_ui.preset_daily_9am"), value: "cron 0 9 * * *" },
61
- { label: t("agents_ui.preset_weekdays_9am"), value: "cron 0 9 * * 1-5" },
62
- ];
63
- }
64
-
65
- // Template/env vars the routine runner exposes (src/core/routines/runner.js).
66
- function routineVars() {
67
- return [
68
- { v: "{{pre_output}}", where: "prompt", desc: t("agents_ui.var_pre_output_prompt") },
69
- { v: "$APX_LLM_OUTPUT", where: "post", desc: t("agents_ui.var_llm_output") },
70
- { v: "$APX_STATUS", where: "post", desc: t("agents_ui.var_status") },
71
- { v: "$APX_SKIPPED", where: "post", desc: t("agents_ui.var_skipped") },
72
- { v: "$APX_PRE_OUTPUT", where: "post", desc: t("agents_ui.var_pre_output") },
73
- { v: "$APX_PRE_OUTPUT_FILE", where: "post", desc: t("agents_ui.var_pre_output_file") },
74
- { v: "$APX_PRE_EXIT", where: "post", desc: t("agents_ui.var_pre_exit") },
75
- { v: "$APX_ROUTINE", where: "pre/post", desc: t("agents_ui.var_routine") },
76
- ];
77
- }
78
-
79
- function actionSummary(kind: Kind, spec: Record<string, unknown>): string {
80
- switch (kind) {
81
- case "exec_agent": return spec.agent ? t("agents_ui.summary_runs_agent", { agent: String(spec.agent) }) : t("agents_ui.summary_runs_agent_none");
82
- case "super_agent": return t("agents_ui.summary_super_agent");
83
- case "telegram": return t("agents_ui.summary_telegram", { channel: String(spec.channel || "default") });
84
- case "shell": return spec.command ? t("agents_ui.summary_runs_cmd", { cmd: String(spec.command).slice(0, 40) }) : t("agents_ui.summary_shell");
85
- case "heartbeat": return t("agents_ui.summary_heartbeat");
86
- }
87
- }
88
-
13
+ // Full-height master-detail (like the Chat screen): scrollable routine list on
14
+ // the left, read-only detail on the right. Selection lives in the URL (?r_id),
15
+ // editing is behind a button, delete uses the shared <Dialog>.
89
16
  export function RoutinesTab({ pid }: { pid: string }) {
90
17
  const toast = useToast();
91
18
  const list = useSWR(`/projects/${pid}/routines`, () => Routines.list(pid));
19
+ const [params, setParams] = useSearchParams();
92
20
  const [editing, setEditing] = useState<Partial<RoutineEntry> | null>(null);
21
+ const [confirmDelete, setConfirmDelete] = useState<RoutineEntry | null>(null);
22
+ const [deleting, setDeleting] = useState(false);
23
+ const [confirmRun, setConfirmRun] = useState<RoutineEntry | null>(null);
24
+ const [running, setRunning] = useState<string | null>(null);
25
+
26
+ const rows = list.data || [];
27
+ const selectedName = params.get("r_id");
28
+ const selected = rows.find((r) => r.name === selectedName) || null;
29
+
30
+ const selectRoutine = (name: string | null) =>
31
+ setParams((prev) => {
32
+ const next = new URLSearchParams(prev);
33
+ if (name) next.set("r_id", name); else next.delete("r_id");
34
+ return next;
35
+ }, { replace: true });
36
+
37
+ // Keep the first routine selected by default, and heal a stale ?r_id.
38
+ useEffect(() => {
39
+ if (rows.length === 0) return;
40
+ if (selectedName && rows.some((r) => r.name === selectedName)) return;
41
+ selectRoutine(rows[0].name);
42
+ }, [rows, selectedName]); // eslint-disable-line react-hooks/exhaustive-deps
93
43
 
94
44
  const toggle = async (r: RoutineEntry) => {
95
45
  try { await (r.enabled ? Routines.disable : Routines.enable)(pid, r.name); list.mutate(); }
96
46
  catch (e: any) { toast.error(e?.message || t("project.routines.toggle_error")); }
97
47
  };
98
- const runNow = async (r: RoutineEntry) => {
99
- try { await Routines.run(pid, r.name); toast.success(t("project.routines.run_success", { name: r.name })); }
100
- catch (e: any) { toast.error(e?.message || t("project.routines.run_error")); }
48
+ const doRun = async () => {
49
+ if (!confirmRun) return;
50
+ const r = confirmRun;
51
+ setConfirmRun(null);
52
+ setRunning(r.name);
53
+ try {
54
+ await Routines.run(pid, r.name);
55
+ toast.success(t("project.routines.run_success", { name: r.name }));
56
+ // Refresh the routine list (last status) and its executions list.
57
+ await Promise.all([
58
+ list.mutate(),
59
+ globalMutate(`/projects/${pid}/routines/${r.name}/runs`),
60
+ ]);
61
+ } catch (e: any) { toast.error(e?.message || t("project.routines.run_error")); }
62
+ finally { setRunning(null); }
101
63
  };
102
- const remove = async (r: RoutineEntry) => {
103
- if (!confirm(t("project.routines.delete_confirm", { name: r.name }))) return;
104
- try { await Routines.remove(pid, r.name); toast.success(t("project.routines.delete_success")); list.mutate(); }
105
- catch (e: any) { toast.error(e?.message || t("project.routines.delete_error")); }
64
+ const doDelete = async () => {
65
+ if (!confirmDelete) return;
66
+ setDeleting(true);
67
+ try {
68
+ await Routines.remove(pid, confirmDelete.name);
69
+ toast.success(t("project.routines.delete_success"));
70
+ if (selectedName === confirmDelete.name) selectRoutine(null);
71
+ setConfirmDelete(null);
72
+ list.mutate();
73
+ } catch (e: any) { toast.error(e?.message || t("project.routines.delete_error")); }
74
+ finally { setDeleting(false); }
106
75
  };
107
76
 
108
77
  return (
109
- <Section
110
- title={t("project.routines.title")}
111
- description={t("project.routines.subtitle")}
112
- action={<Button size="sm" variant="primary" onClick={() => setEditing({ kind: "super_agent", schedule: "every:10m", enabled: true })}>
113
- <Plus size={14} /> {t("project.routines.new_btn")}
114
- </Button>}
115
- >
78
+ <div className="flex h-full min-h-0 flex-col gap-3">
79
+ {/* header (title + new) */}
80
+ <div className="flex shrink-0 items-start justify-between gap-4">
81
+ <div>
82
+ <h2 className="text-lg font-semibold tracking-tight">{t("project.routines.title")}</h2>
83
+ <p className="mt-0.5 text-sm text-muted-fg">{t("project.routines.subtitle")}</p>
84
+ </div>
85
+ <Button size="sm" variant="primary" onClick={() => setEditing({ kind: "super_agent", schedule: "every:10m", enabled: true })}>
86
+ <Plus size={14} /> {t("project.routines.new_btn")}
87
+ </Button>
88
+ </div>
89
+
116
90
  {list.isLoading && <Loading />}
117
- {!list.isLoading && (list.data?.length ?? 0) === 0 && <Empty>{t("project.routines.empty")}</Empty>}
118
- <ul className="space-y-2 text-sm">
119
- {(list.data || []).map((row) => {
120
- const meta = kindMeta()[row.kind];
121
- const Icon = meta?.icon || Zap;
122
- const err = row.last_status === "error";
123
- return (
124
- <li
125
- key={row.name}
126
- className="cursor-pointer rounded-xl border border-border bg-muted/30 p-3 hover:border-muted-fg/50"
127
- onClick={() => setEditing({ ...row })}
128
- >
129
- <div className="flex items-center justify-between gap-3">
130
- <div className="flex items-center gap-2">
131
- <span className={cn("flex size-7 items-center justify-center rounded-lg", row.enabled ? "bg-emerald-500/15 text-emerald-400" : "bg-muted text-muted-fg")}>
132
- <Icon size={14} />
133
- </span>
134
- <span className="font-medium">{row.name}</span>
135
- <Badge tone={row.kind === "shell" ? "warning" : "info"}>{meta?.label || row.kind}</Badge>
136
- {!row.enabled && <Badge tone="muted">{t("project.routines.paused")}</Badge>}
137
- </div>
138
- <div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
139
- <Switch checked={row.enabled} onChange={() => toggle(row)} />
140
- <Button size="sm" variant="secondary" onClick={() => runNow(row)}><Play size={13} /> {t("common.run")}</Button>
141
- <Button size="sm" variant="destructive" onClick={() => remove(row)}><Trash2 size={13} /></Button>
142
- </div>
143
- </div>
144
- <div className="mt-1.5 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-fg">
145
- <span>⏱ {scheduleHuman(row.schedule)}</span>
146
- <span>{actionSummary(row.kind, row.spec || {})}</span>
147
- {row.next_run_at && <span>{t("project.routines.next_run")} {new Date(row.next_run_at).toLocaleString()}</span>}
148
- <span className={cn(row.last_status === "ok" && "text-emerald-500", err && "text-destructive")}>
149
- {t("agents_ui.last_label")} {row.last_status || "—"}
150
- </span>
151
- </div>
152
- {row.last_error && <div className="mt-2 rounded-md bg-destructive/10 px-2 py-1 text-xs text-destructive">{row.last_error}</div>}
153
- </li>
154
- );
155
- })}
156
- </ul>
91
+ {!list.isLoading && rows.length === 0 && <Empty>{t("project.routines.empty")}</Empty>}
92
+
93
+ {rows.length > 0 && (
94
+ <div className="grid min-h-0 flex-1 grid-rows-[minmax(0,1fr)] grid-cols-[minmax(200px,260px)_1fr] overflow-hidden rounded-xl border border-border bg-card/40">
95
+ <RoutineList routines={rows} selectedName={selected?.name ?? null} onSelect={selectRoutine} />
96
+ <div className="min-h-0 min-w-0 overflow-hidden">
97
+ {selected
98
+ ? <RoutineDetail
99
+ key={selected.name}
100
+ pid={pid}
101
+ routine={selected}
102
+ onEdit={() => setEditing({ ...selected })}
103
+ onRun={() => setConfirmRun(selected)}
104
+ onToggle={() => toggle(selected)}
105
+ onDelete={() => setConfirmDelete(selected)}
106
+ running={running === selected.name}
107
+ />
108
+ : <div className="flex h-full items-center justify-center p-8">
109
+ <p className="text-sm text-muted-fg">{t("project.routines.detail_empty")}</p>
110
+ </div>}
111
+ </div>
112
+ </div>
113
+ )}
157
114
 
158
115
  <RoutineEditor
159
116
  draft={editing}
@@ -161,240 +118,36 @@ export function RoutinesTab({ pid }: { pid: string }) {
161
118
  onSaved={() => { setEditing(null); list.mutate(); }}
162
119
  pid={pid}
163
120
  />
164
- </Section>
165
- );
166
- }
167
-
168
- function RoutineEditor({
169
- draft, onClose, onSaved, pid,
170
- }: { draft: Partial<RoutineEntry> | null; onClose: () => void; onSaved: () => void; pid: string }) {
171
- const toast = useToast();
172
- const agentsList = useSWR(draft ? `/projects/${pid}/agents` : null, () => Agents.list(pid));
173
- const [busy, setBusy] = useState(false);
174
-
175
- const [name, setName] = useState("");
176
- const [kind, setKind] = useState<Kind>("super_agent");
177
- const [schedule, setSchedule] = useState("every:10m");
178
- const [enabled, setEnabled] = useState(true);
179
- // Per-kind fields
180
- const [agent, setAgent] = useState("");
181
- const [prompt, setPrompt] = useState("");
182
- const [tgChannel, setTgChannel] = useState("default");
183
- const [tgChatId, setTgChatId] = useState("");
184
- const [tgText, setTgText] = useState("");
185
- const [command, setCommand] = useState("");
186
- const [hbChannel, setHbChannel] = useState("heartbeat");
187
- const [hbMessage, setHbMessage] = useState("");
188
- const [pre, setPre] = useState("");
189
- const [post, setPost] = useState("");
190
121
 
191
- // Load draft → fields.
192
- useEffect(() => {
193
- if (!draft) return;
194
- const spec = (draft.spec && typeof draft.spec === "object" ? draft.spec : {}) as Record<string, any>;
195
- setName(draft.name || "");
196
- setKind((draft.kind as Kind) || "super_agent");
197
- setSchedule(draft.schedule || "every:10m");
198
- setEnabled(draft.enabled ?? true);
199
- setAgent(spec.agent || "");
200
- setPrompt(spec.prompt || "");
201
- setTgChannel(spec.channel || "default");
202
- setTgChatId(spec.chat_id ? String(spec.chat_id) : "");
203
- setTgText(spec.text || "");
204
- setCommand(spec.command || "");
205
- setHbChannel(spec.channel || "heartbeat");
206
- setHbMessage(spec.message || "");
207
- setPre((draft.pre_commands || []).join("\n"));
208
- setPost((draft.post_commands || []).join("\n"));
209
- }, [draft]);
210
-
211
- const buildSpec = (): Record<string, unknown> => {
212
- switch (kind) {
213
- case "exec_agent": return { agent, prompt };
214
- case "super_agent": return { prompt };
215
- case "telegram": return { channel: tgChannel, ...(tgChatId ? { chat_id: tgChatId } : {}), text: tgText };
216
- case "shell": return { command };
217
- case "heartbeat": return { channel: hbChannel, message: hbMessage };
218
- }
219
- };
220
-
221
- const submit = async () => {
222
- if (!name) { toast.error(t("project.routines.name_required")); return; }
223
- setBusy(true);
224
- try {
225
- const usePP = kind === "exec_agent" || kind === "super_agent";
226
- await Routines.upsert(pid, {
227
- name, kind, schedule, enabled,
228
- spec: buildSpec(),
229
- pre_commands: usePP ? splitLines(pre) : [],
230
- post_commands: usePP ? splitLines(post) : [],
231
- });
232
- toast.success(t("project.routines.saved"));
233
- onSaved();
234
- } catch (e: any) { toast.error(e?.message || t("project.routines.save_error")); }
235
- finally { setBusy(false); }
236
- };
237
-
238
- // Only the LLM kinds wrap the action with pre/post shell commands.
239
- const usesPrePost = kind === "exec_agent" || kind === "super_agent";
240
- // Timeline steps (pre → action → post).
241
- const preSteps = usesPrePost ? splitLines(pre) : [];
242
- const postSteps = usesPrePost ? splitLines(post) : [];
243
- const actionLabel = (() => {
244
- switch (kind) {
245
- case "exec_agent": return agent ? t("agents_ui.action_agent_answers", { agent }) : t("agents_ui.action_agent_pick_answers");
246
- case "super_agent": return t("agents_ui.action_super_answers");
247
- case "telegram": return t("agents_ui.action_telegram_channel", { channel: tgChannel });
248
- case "shell": return command ? t("agents_ui.summary_runs_cmd", { cmd: command.slice(0, 48) }) : t("agents_ui.action_runs_shell");
249
- case "heartbeat": return t("agents_ui.summary_heartbeat");
250
- }
251
- })();
252
- const usesPrompt = usesPrePost;
253
-
254
- const ActionIcon = kindMeta()[kind].icon;
255
- const steps = [
256
- ...preSteps.map((c, i) => ({ id: `pre-${i}`, icon: Terminal, label: t("agents_ui.step_pre"), detail: c, action: false })),
257
- { id: "action", icon: ActionIcon, label: actionLabel, detail: usesPrompt && prompt ? prompt.slice(0, 90) : undefined, action: true },
258
- ...postSteps.map((c, i) => ({ id: `post-${i}`, icon: Terminal, label: t("agents_ui.step_post"), detail: c, action: false })),
259
- ];
260
-
261
- return (
262
- <Dialog
263
- open={!!draft}
264
- onClose={onClose}
265
- title={draft?.name ? t("project.routines.edit_title", { name: draft.name }) : t("project.routines.new_title")}
266
- description={t("project.routines.dialog_desc")}
267
- size="xl"
268
- footer={
269
- <>
270
- <Button variant="ghost" onClick={onClose} disabled={busy}>{t("common.cancel")}</Button>
271
- <Button variant="primary" onClick={submit} loading={busy}>{t("common.save")}</Button>
272
- </>
273
- }
274
- >
275
- <div className="space-y-4">
276
- {/* status */}
277
- <div className="flex items-center justify-between rounded-lg border border-border bg-muted/20 px-3 py-2">
278
- <Switch checked={enabled} onChange={setEnabled} label={t("project.routines.enabled_label")} />
279
- <span className="text-[11px] text-muted-fg">{enabled ? t("project.routines.enabled_hint") : t("project.routines.disabled_hint")}</span>
280
- </div>
281
-
282
- <div className="grid gap-6 md:grid-cols-2">
283
- {/* LEFT — qué y cuándo */}
284
- <div className="space-y-3">
285
- <Field label={t("project.routines.name_field")} hint={draft?.name ? t("project.routines.name_no_edit") : undefined}>
286
- <Input value={name} disabled={!!draft?.name} onChange={(e) => setName(e.target.value)} placeholder="resumen-diario" />
287
- </Field>
288
- <Field label={t("project.routines.kind_field")}>
289
- <UiSelect value={kind} onChange={(v) => setKind(v as Kind)} options={kindOptions()} />
290
- </Field>
291
- <p className="-mt-1 text-[11px] text-muted-fg">{kindMeta()[kind].desc}</p>
292
- {kind === "exec_agent" && (
293
- <Field label={t("project.routines.agent_field")} hint={t("project.routines.agent_hint")}>
294
- <UiSelect value={agent} onChange={setAgent} placeholder={agentsList.isLoading ? t("project.routines.agent_loading") : t("project.routines.agent_pick")}
295
- options={(agentsList.data || []).map((a) => ({ value: a.slug, label: a.slug, description: [a.role, a.model].filter(Boolean).join(" · ") || undefined }))} />
296
- </Field>
297
- )}
298
- <Field label={t("project.routines.schedule_field")} hint={t("project.routines.schedule_hint")}>
299
- <div className="space-y-2">
300
- <div className="flex flex-wrap gap-1">
301
- {schedPresets().map((s) => (
302
- <button key={s.value} type="button" onClick={() => setSchedule(s.value)}
303
- className={cn("rounded-md border px-2 py-0.5 text-[11px]", schedule === s.value ? "border-emerald-500/50 text-emerald-400" : "border-border text-muted-fg hover:text-foreground")}>
304
- {s.label}
305
- </button>
306
- ))}
307
- <button type="button" onClick={() => setSchedule("manual")}
308
- className={cn("rounded-md border px-2 py-0.5 text-[11px]", schedule === "manual" ? "border-emerald-500/50 text-emerald-400" : "border-border text-muted-fg hover:text-foreground")}>
309
- {t("agents_ui.preset_manual")}
310
- </button>
311
- </div>
312
- <Input value={schedule} onChange={(e) => setSchedule(e.target.value)} placeholder="every:10m · cron 0 9 * * 1-5 · once:ISO · manual" />
313
- </div>
314
- </Field>
315
- </div>
316
-
317
- {/* RIGHT — lo que ejecuta, según el tipo */}
318
- <div className="space-y-3">
319
- {/* LLM: pre → prompt → post */}
320
- {usesPrePost && (
321
- <Field label={t("project.routines.pre_field")} hint={t("project.routines.pre_hint")}>
322
- <Textarea rows={2} className="font-mono text-xs" value={pre} onChange={(e) => setPre(e.target.value)} placeholder="curl -s https://wttr.in/Bariloche" />
323
- </Field>
324
- )}
325
- {kind === "exec_agent" && (
326
- <Field label={t("project.routines.prompt_exec")}><Textarea rows={4} value={prompt} onChange={(e) => setPrompt(e.target.value)} placeholder={t("project.routines.prompt_exec_ph")} /></Field>
327
- )}
328
- {kind === "super_agent" && (
329
- <Field label={t("project.routines.prompt_super")}><Textarea rows={4} value={prompt} onChange={(e) => setPrompt(e.target.value)} placeholder={t("project.routines.prompt_super_ph")} /></Field>
330
- )}
331
-
332
- {/* Telegram: solo manda un mensaje (sin LLM) */}
333
- {kind === "telegram" && (
334
- <>
335
- <div className="grid grid-cols-2 gap-3">
336
- <Field label={t("project.routines.tg_channel")}><Input value={tgChannel} onChange={(e) => setTgChannel(e.target.value)} placeholder="default" /></Field>
337
- <Field label={t("project.routines.tg_chat_id")}><Input value={tgChatId} onChange={(e) => setTgChatId(e.target.value)} placeholder={t("agents_ui.tg_chat_id_ph")} /></Field>
338
- </div>
339
- <Field label={t("project.routines.tg_text")} hint={t("project.routines.tg_text_hint")}>
340
- <Textarea rows={8} value={tgText} onChange={(e) => setTgText(e.target.value)} placeholder={t("agents_ui.tg_text_ph")} />
341
- </Field>
342
- </>
343
- )}
344
-
345
- {/* Shell: un comando, ocupando todo */}
346
- {kind === "shell" && (
347
- <Field label={t("project.routines.shell_field")} hint={t("project.routines.shell_hint")}>
348
- <Textarea rows={11} className="font-mono text-xs" value={command} onChange={(e) => setCommand(e.target.value)} placeholder="cd /repo && git pull && npm test" />
349
- </Field>
350
- )}
351
-
352
- {/* Heartbeat: solo loguea */}
353
- {kind === "heartbeat" && (
354
- <div className="grid grid-cols-2 gap-3">
355
- <Field label={t("project.routines.hb_channel")}><Input value={hbChannel} onChange={(e) => setHbChannel(e.target.value)} placeholder="heartbeat" /></Field>
356
- <Field label={t("project.routines.hb_message")}><Input value={hbMessage} onChange={(e) => setHbMessage(e.target.value)} placeholder={t("agents_ui.hb_message_ph")} /></Field>
357
- </div>
358
- )}
359
-
360
- {usesPrePost && (
361
- <Field label={t("project.routines.post_field")} hint={t("project.routines.post_hint")}>
362
- <Textarea rows={2} className="font-mono text-xs" value={post} onChange={(e) => setPost(e.target.value)} placeholder={'apx telegram send "$APX_LLM_OUTPUT"'} />
363
- </Field>
364
- )}
365
- </div>
366
- </div>
367
-
368
- {/* Variables disponibles */}
369
- <div className="rounded-lg border border-border bg-muted/10 p-3">
370
- <div className="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-muted-fg">{t("project.routines.vars_title")}</div>
371
- <div className="flex flex-wrap gap-1.5">
372
- {routineVars().map((v) => (
373
- <span key={v.v} title={v.desc} className="inline-flex items-center gap-1 rounded-md border border-border bg-card px-1.5 py-0.5 font-mono text-[10px]">
374
- {v.v}<span className="not-italic text-muted-fg">· {v.where}</span>
375
- </span>
376
- ))}
377
- </div>
378
- </div>
379
-
380
- {/* Qué va a pasar — full width */}
381
- <div className="rounded-lg border border-border bg-muted/20 p-3">
382
- <div className="mb-2 text-xs font-semibold text-muted-fg">{t("project.routines.what_happens")} <span className="font-normal text-muted-fg">· ⏱ {scheduleHuman(schedule)}</span></div>
383
- <div className="flex flex-wrap items-stretch gap-2">
384
- {steps.map((s, i) => (
385
- <div key={s.id} className="flex items-stretch gap-2">
386
- <div className={cn("flex max-w-[240px] flex-col gap-1 rounded-lg border px-2.5 py-2", s.action ? "border-emerald-500/40 bg-emerald-500/5" : "border-border bg-card")}>
387
- <div className={cn("flex items-center gap-1.5 text-[11px] font-medium", s.action ? "text-emerald-400" : "text-muted-fg")}>
388
- <s.icon size={12} /> {s.label}
389
- </div>
390
- {s.detail && <div className="line-clamp-2 font-mono text-[10px] text-muted-fg">{s.detail}</div>}
391
- </div>
392
- {i < steps.length - 1 && <ArrowRight size={14} className="shrink-0 self-center text-muted-fg" />}
393
- </div>
394
- ))}
395
- </div>
396
- </div>
397
- </div>
398
- </Dialog>
122
+ <Dialog
123
+ open={!!confirmDelete}
124
+ onClose={() => (deleting ? null : setConfirmDelete(null))}
125
+ title={t("project.routines.delete_confirm", { name: confirmDelete?.name || "" })}
126
+ size="sm"
127
+ footer={
128
+ <>
129
+ <Button variant="ghost" onClick={() => setConfirmDelete(null)} disabled={deleting}>{t("common.cancel")}</Button>
130
+ <Button variant="destructive" onClick={doDelete} loading={deleting}>{t("common.delete")}</Button>
131
+ </>
132
+ }
133
+ >
134
+ <p className="text-sm text-muted-fg">{t("project.routines.delete_confirm_body")}</p>
135
+ </Dialog>
136
+
137
+ <Dialog
138
+ open={!!confirmRun}
139
+ onClose={() => setConfirmRun(null)}
140
+ title={t("project.routines.run_confirm", { name: confirmRun?.name || "" })}
141
+ size="sm"
142
+ footer={
143
+ <>
144
+ <Button variant="ghost" onClick={() => setConfirmRun(null)}>{t("common.cancel")}</Button>
145
+ <Button variant="primary" onClick={doRun}>{t("common.run")}</Button>
146
+ </>
147
+ }
148
+ >
149
+ <p className="text-sm text-muted-fg">{t("project.routines.run_confirm_body")}</p>
150
+ </Dialog>
151
+ </div>
399
152
  );
400
153
  }
@@ -120,6 +120,11 @@
120
120
  * {
121
121
  @apply border-border outline-ring/50;
122
122
  }
123
+ /* Tailwind v4 Preflight resets buttons to cursor:default — restore the hand. */
124
+ button:not(:disabled),
125
+ [role="button"]:not(:disabled) {
126
+ cursor: pointer;
127
+ }
123
128
  }
124
129
 
125
130
  /* Subtle scrollbar that doesn't fight the dark theme. */