@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
@@ -0,0 +1,189 @@
1
+ import { useState, type ReactNode } from "react";
2
+ import useSWR from "swr";
3
+ import { Ban, Check, X } from "lucide-react";
4
+ import { Messages } from "../../lib/api";
5
+ import type { MessageEntry } from "../../types/daemon";
6
+ import { Loading, Spinner } from "../ui";
7
+ import { cn } from "../../lib/cn";
8
+ import { t } from "../../i18n";
9
+
10
+ // Execution history is derived from the ROUTINE-channel messages the runner
11
+ // logs at the end of each run (src/core/routines/runner.js) — there is no
12
+ // dedicated runs table. One system message per run carries meta.routine +
13
+ // meta.status + meta.skipped + meta.result.
14
+
15
+ type RunSt = "ok" | "error" | "skipped";
16
+
17
+ function runStatus(m: MessageEntry): RunSt {
18
+ const meta = (m.meta || {}) as Record<string, unknown>;
19
+ if (meta.skipped) return "skipped";
20
+ if (meta.status === "error") return "error";
21
+ return "ok";
22
+ }
23
+
24
+ function fmtTs(ts: string): string {
25
+ const d = new Date(ts);
26
+ if (Number.isNaN(d.getTime())) return ts;
27
+ return d.toLocaleString(undefined, {
28
+ month: "short", day: "2-digit", hour: "2-digit", minute: "2-digit",
29
+ });
30
+ }
31
+
32
+ function StatusIcon({ st }: { st: RunSt }) {
33
+ if (st === "ok") return <Check size={13} className="shrink-0 text-emerald-500" />;
34
+ if (st === "error") return <X size={13} className="shrink-0 text-destructive" />;
35
+ return <Ban size={13} className="shrink-0 text-amber-500" />;
36
+ }
37
+
38
+ function statusLabel(st: RunSt): string {
39
+ return st === "ok" ? t("project.routines.status_ok") : st === "error" ? t("project.routines.status_error") : t("project.routines.status_skipped");
40
+ }
41
+
42
+ function FlowBlock({ title, children }: { title: string; children: ReactNode }) {
43
+ return (
44
+ <div className="space-y-1">
45
+ <div className="text-[10px] font-semibold uppercase tracking-wide text-muted-fg">{title}</div>
46
+ {children}
47
+ </div>
48
+ );
49
+ }
50
+
51
+ type RunFlow = {
52
+ pre?: { output?: string; exit?: number } | null;
53
+ post?: Array<{ cmd: string; exit: number; stdout: string; stderr: string }> | null;
54
+ };
55
+
56
+ const PRE_CLS = "whitespace-pre-wrap break-words rounded-lg border border-border bg-muted/20 px-3 py-2 font-mono text-[11px]";
57
+
58
+ /** Side panel: the full flow of the clicked run — pre → action → post. Phases
59
+ * that did not run are hidden; older runs (no saved flow) show just the output. */
60
+ function RunDetailPanel({ m, onClose }: { m: MessageEntry; onClose: () => void }) {
61
+ const st = runStatus(m);
62
+ const meta = (m.meta || {}) as Record<string, any>;
63
+ const result = (meta.result || {}) as Record<string, any>;
64
+ const flow = (meta.flow || null) as RunFlow | null;
65
+ const output = String(result.reply ?? result.text ?? result.stdout ?? "");
66
+ const err = String(result.error ?? result.stderr ?? "");
67
+ const note = String(result.note ?? "");
68
+ const empty = <span className="text-muted-fg">{t("project.routines.block_empty")}</span>;
69
+
70
+ return (
71
+ <div className="flex min-h-0 flex-col border-l border-border">
72
+ <div className="flex shrink-0 items-center justify-between gap-2 px-4 py-2">
73
+ <div className="flex items-center gap-2 text-xs">
74
+ <StatusIcon st={st} />
75
+ <span className={cn("font-medium", st === "ok" && "text-emerald-500", st === "error" && "text-destructive", st === "skipped" && "text-amber-500")}>{statusLabel(st)}</span>
76
+ <span className="font-mono text-muted-fg">{fmtTs(m.ts)}</span>
77
+ </div>
78
+ <button type="button" onClick={onClose} aria-label={t("project.routines.runs_close")}
79
+ className="rounded-md p-1 text-muted-fg hover:bg-muted hover:text-foreground">
80
+ <X size={14} />
81
+ </button>
82
+ </div>
83
+ <div className="min-h-0 flex-1 space-y-3 overflow-y-auto px-4 pb-4 text-xs">
84
+ {m.body && <div className="text-muted-fg">{m.body}</div>}
85
+
86
+ {/* Pre-commands */}
87
+ {flow?.pre && (
88
+ <FlowBlock title={t("project.routines.block_pre")}>
89
+ {flow.pre.output?.trim() ? <pre className={PRE_CLS}>{flow.pre.output}</pre> : empty}
90
+ </FlowBlock>
91
+ )}
92
+
93
+ {/* Action output (agent reply / telegram message / shell stdout) */}
94
+ <FlowBlock title={t("project.routines.runs_output")}>
95
+ {output ? <pre className={PRE_CLS}>{output}</pre>
96
+ : err ? <pre className="whitespace-pre-wrap break-words rounded-lg bg-destructive/10 px-3 py-2 font-mono text-[11px] text-destructive">{err}</pre>
97
+ : note ? <div className="text-muted-fg">{note}</div>
98
+ : empty}
99
+ </FlowBlock>
100
+
101
+ {/* Post-commands */}
102
+ {flow?.post && flow.post.length > 0 && (
103
+ <FlowBlock title={t("project.routines.block_post")}>
104
+ <div className="space-y-1.5">
105
+ {flow.post.map((p, i) => (
106
+ <div key={i} className="space-y-1">
107
+ <div className="font-mono text-[10px] text-muted-fg">$ {p.cmd} <span className="opacity-70">· exit {p.exit}</span></div>
108
+ {(p.stdout || p.stderr) && <pre className={PRE_CLS}>{p.stdout || p.stderr}</pre>}
109
+ </div>
110
+ ))}
111
+ </div>
112
+ </FlowBlock>
113
+ )}
114
+ </div>
115
+ </div>
116
+ );
117
+ }
118
+
119
+ /** Bottom pane of the detail view: scrollable list of past runs; clicking one
120
+ * opens a side grid column with that run's details. */
121
+ export function ExecutionsList({ pid, name, running }: { pid: string; name: string; running?: boolean }) {
122
+ const runs = useSWR(
123
+ `/projects/${pid}/routines/${name}/runs`,
124
+ async () => {
125
+ const msgs = await Messages.project(pid, { channel: "routine", limit: 200 });
126
+ // Keep one row per run: the runner's end-of-run system summary.
127
+ return msgs.filter((m) =>
128
+ (m.meta as Record<string, unknown>)?.routine === name &&
129
+ (m.actor_id === "apx:routine" || m.type === "system"),
130
+ );
131
+ },
132
+ );
133
+ const rows = (runs.data || []).slice(0, 50);
134
+ const [selTs, setSelTs] = useState<string | null>(null);
135
+ const selected = selTs ? rows.find((m) => m.ts === selTs) || null : null;
136
+
137
+ return (
138
+ <div className="flex min-h-0 flex-1 flex-col border-t border-border">
139
+ <div className="shrink-0 px-4 pb-1.5 pt-3 text-[11px] font-semibold uppercase tracking-wide text-muted-fg">
140
+ {t("project.routines.runs_title")}
141
+ </div>
142
+ <div className={cn("grid min-h-0 flex-1 overflow-hidden", selected ? "grid-cols-[minmax(0,1fr)_minmax(0,1.1fr)]" : "grid-cols-1")}>
143
+ {/* list */}
144
+ <div className="min-h-0 overflow-y-auto px-4 pb-4">
145
+ {runs.isLoading && <Loading />}
146
+ {!runs.isLoading && rows.length === 0 && (
147
+ <div className="text-xs text-muted-fg">{t("project.routines.runs_empty")}</div>
148
+ )}
149
+ <ul className="space-y-1">
150
+ {running && (
151
+ <li>
152
+ <div className="flex w-full items-center gap-2 rounded-md border border-primary/40 bg-primary/5 px-3 py-1.5 text-xs">
153
+ <Spinner size={12} />
154
+ <span className="text-muted-fg">{t("project.routines.running")}</span>
155
+ </div>
156
+ </li>
157
+ )}
158
+ {rows.map((m, i) => {
159
+ const st = runStatus(m);
160
+ const active = selTs === m.ts;
161
+ return (
162
+ <li key={`${m.ts}-${i}`}>
163
+ <button
164
+ type="button"
165
+ onClick={() => setSelTs(active ? null : m.ts)}
166
+ aria-current={active}
167
+ className={cn(
168
+ "flex w-full items-center gap-2 rounded-md border px-3 py-1.5 text-left text-xs transition-colors",
169
+ active ? "border-primary/50 bg-primary/10" : "border-border bg-muted/30 hover:border-muted-fg/40",
170
+ )}
171
+ >
172
+ <StatusIcon st={st} />
173
+ <span className="font-mono text-muted-fg">{fmtTs(m.ts)}</span>
174
+ <span className={cn("font-medium", st === "ok" && "text-emerald-500", st === "error" && "text-destructive", st === "skipped" && "text-amber-500")}>
175
+ {statusLabel(st)}
176
+ </span>
177
+ </button>
178
+ </li>
179
+ );
180
+ })}
181
+ </ul>
182
+ </div>
183
+
184
+ {/* run detail (opens as a side grid column) */}
185
+ {selected && <RunDetailPanel m={selected} onClose={() => setSelTs(null)} />}
186
+ </div>
187
+ </div>
188
+ );
189
+ }
@@ -0,0 +1,14 @@
1
+ import { cn } from "../../lib/cn";
2
+ import { t } from "../../i18n";
3
+
4
+ /** A titled, read-only, scrollable block — the detail-view stand-in for an editor textarea. */
5
+ export function ReadOnlyBlock({ title, body, mono }: { title: string; body: string; mono?: boolean }) {
6
+ return (
7
+ <div className="space-y-1">
8
+ <div className="text-[11px] font-semibold uppercase tracking-wide text-muted-fg">{title}</div>
9
+ <div className={cn("max-h-44 overflow-auto whitespace-pre-wrap break-words rounded-lg border border-border bg-muted/20 px-3 py-2 text-xs", mono && "font-mono")}>
10
+ {body.trim() ? body : <span className="text-muted-fg">{t("project.routines.block_empty")}</span>}
11
+ </div>
12
+ </div>
13
+ );
14
+ }
@@ -0,0 +1,86 @@
1
+ import { Pencil, Play, Trash2, Zap } from "lucide-react";
2
+ import type { RoutineEntry } from "../../lib/api";
3
+ import { Badge, Button, Switch, Tip } from "../ui";
4
+ import { cn } from "../../lib/cn";
5
+ import { t } from "../../i18n";
6
+ import { kindMeta, scheduleHuman } from "./shared";
7
+ import { ReadOnlyBlock } from "./ReadOnlyBlock";
8
+ import { ExecutionsList } from "./ExecutionsList";
9
+
10
+ // Right column: read-only detail of the selected routine. Two stacked areas —
11
+ // the data (header + meta + content blocks) takes the space it needs, and the
12
+ // executions list below fills the rest and scrolls. Editing is behind a button.
13
+ export function RoutineDetail({
14
+ pid, routine, onEdit, onRun, onToggle, onDelete, running,
15
+ }: {
16
+ pid: string;
17
+ routine: RoutineEntry;
18
+ onEdit: () => void;
19
+ onRun: () => void;
20
+ onToggle: () => void;
21
+ onDelete: () => void;
22
+ running?: boolean;
23
+ }) {
24
+ const meta = kindMeta()[routine.kind];
25
+ const Icon = meta?.icon || Zap;
26
+ const spec = (routine.spec || {}) as Record<string, any>;
27
+ const pre = routine.pre_commands || [];
28
+ const post = routine.post_commands || [];
29
+
30
+ // Read-only content blocks, in pipeline order (pre → action → post).
31
+ const blocks: { title: string; body: string; mono?: boolean }[] = [];
32
+ if (pre.length) blocks.push({ title: t("project.routines.block_pre"), body: pre.join("\n"), mono: true });
33
+ if (routine.kind === "exec_agent" || routine.kind === "super_agent") {
34
+ blocks.push({ title: t("project.routines.block_prompt"), body: String(spec.prompt || "") });
35
+ } else if (routine.kind === "telegram") {
36
+ blocks.push({ title: t("project.routines.block_text"), body: String(spec.text || "") });
37
+ } else if (routine.kind === "shell") {
38
+ blocks.push({ title: t("project.routines.block_command"), body: String(spec.command || ""), mono: true });
39
+ } else if (routine.kind === "heartbeat") {
40
+ blocks.push({ title: t("project.routines.block_text"), body: String(spec.message || "") });
41
+ }
42
+ if (post.length) blocks.push({ title: t("project.routines.block_post"), body: post.join("\n"), mono: true });
43
+
44
+ return (
45
+ <div className="flex h-full min-h-0 flex-col">
46
+ {/* DATA — takes the space it needs; scrolls only if it overflows */}
47
+ <div className="min-h-0 shrink space-y-4 overflow-y-auto p-4">
48
+ {/* header: name + actions (edit behind a button) */}
49
+ <div className="flex items-start justify-between gap-3">
50
+ <div className="flex min-w-0 items-center gap-2">
51
+ <span className={cn("flex size-7 shrink-0 items-center justify-center rounded-lg", routine.enabled ? "bg-emerald-500/15 text-emerald-400" : "bg-muted text-muted-fg")}>
52
+ <Icon size={14} />
53
+ </span>
54
+ <h3 className="truncate text-base font-semibold">{routine.name}</h3>
55
+ <Badge tone={routine.kind === "shell" ? "warning" : "info"}>{meta?.label || routine.kind}</Badge>
56
+ </div>
57
+ <div className="flex shrink-0 items-center gap-2">
58
+ <Switch checked={routine.enabled} onChange={onToggle} />
59
+ <Tip content={t("common.run")}><Button size="sm" variant="secondary" onClick={onRun} loading={running}><Play size={13} /></Button></Tip>
60
+ <Tip content={t("project.routines.edit_hint")}><Button size="sm" variant="secondary" onClick={onEdit}><Pencil size={13} /> {t("project.routines.edit_btn")}</Button></Tip>
61
+ <Tip content={t("common.delete")}><Button size="sm" variant="destructive" onClick={onDelete}><Trash2 size={13} /></Button></Tip>
62
+ </div>
63
+ </div>
64
+
65
+ {/* compact meta: schedule / next / last */}
66
+ <div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-fg">
67
+ <span>⏱ {scheduleHuman(routine.schedule)}</span>
68
+ {routine.next_run_at && <span>{t("project.routines.next_run")} {new Date(routine.next_run_at).toLocaleString()}</span>}
69
+ {routine.last_run_at && <span>{t("project.routines.last_run")} {new Date(routine.last_run_at).toLocaleString()}</span>}
70
+ <span className={cn(routine.last_status === "ok" && "text-emerald-500", routine.last_status === "error" && "text-destructive")}>
71
+ {t("agents_ui.last_label")} {routine.last_status || "—"}
72
+ </span>
73
+ </div>
74
+ {routine.last_error && <div className="rounded-md bg-destructive/10 px-2 py-1 text-xs text-destructive">{routine.last_error}</div>}
75
+
76
+ {/* read-only content blocks */}
77
+ <div className="space-y-3">
78
+ {blocks.map((b) => <ReadOnlyBlock key={b.title} title={b.title} body={b.body} mono={b.mono} />)}
79
+ </div>
80
+ </div>
81
+
82
+ {/* EXECUTIONS — fills the remaining height and scrolls */}
83
+ <ExecutionsList pid={pid} name={routine.name} running={running} />
84
+ </div>
85
+ );
86
+ }
@@ -0,0 +1,263 @@
1
+ import { useEffect, useState } from "react";
2
+ import useSWR from "swr";
3
+ import { ArrowRight, Terminal } from "lucide-react";
4
+ import { Routines, Agents, Telegram, type RoutineEntry } from "../../lib/api";
5
+ import { Button, Dialog, Field, Input, Switch } from "../ui";
6
+ import { UiSelect } from "../UiSelect";
7
+ import { useToast } from "../Toast";
8
+ import { cn } from "../../lib/cn";
9
+ import { t } from "../../i18n";
10
+ import { type Kind, kindMeta, kindOptions, schedPresets, scheduleHuman, splitLines, varsFor } from "./shared";
11
+ import { VarTextarea } from "./VarTextarea";
12
+ import { AvailableVarsCard } from "./AvailableVarsCard";
13
+
14
+ export function RoutineEditor({
15
+ draft, onClose, onSaved, pid,
16
+ }: { draft: Partial<RoutineEntry> | null; onClose: () => void; onSaved: () => void; pid: string }) {
17
+ const toast = useToast();
18
+ const agentsList = useSWR(draft ? `/projects/${pid}/agents` : null, () => Agents.list(pid));
19
+ const [busy, setBusy] = useState(false);
20
+
21
+ const [name, setName] = useState("");
22
+ const [kind, setKind] = useState<Kind>("super_agent");
23
+ const [schedule, setSchedule] = useState("every:10m");
24
+ const [enabled, setEnabled] = useState(true);
25
+ // Per-kind fields
26
+ const [agent, setAgent] = useState("");
27
+ const [prompt, setPrompt] = useState("");
28
+ const [tgChannel, setTgChannel] = useState("default");
29
+ const [tgChatId, setTgChatId] = useState("");
30
+ const [tgText, setTgText] = useState("");
31
+ const [command, setCommand] = useState("");
32
+ const [hbChannel, setHbChannel] = useState("heartbeat");
33
+ const [hbMessage, setHbMessage] = useState("");
34
+ const [pre, setPre] = useState("");
35
+ const [post, setPost] = useState("");
36
+
37
+ // Telegram channels for the selector (default + any project/global channels).
38
+ const tgChannels = useSWR(draft && kind === "telegram" ? "/telegram/channels" : null, () => Telegram.channels.list());
39
+
40
+ // Load draft → fields.
41
+ useEffect(() => {
42
+ if (!draft) return;
43
+ const spec = (draft.spec && typeof draft.spec === "object" ? draft.spec : {}) as Record<string, any>;
44
+ setName(draft.name || "");
45
+ setKind((draft.kind as Kind) || "super_agent");
46
+ setSchedule(draft.schedule || "every:10m");
47
+ setEnabled(draft.enabled ?? true);
48
+ setAgent(spec.agent || "");
49
+ setPrompt(spec.prompt || "");
50
+ setTgChannel(spec.channel || "default");
51
+ setTgChatId(spec.chat_id ? String(spec.chat_id) : "");
52
+ setTgText(spec.text || "");
53
+ setCommand(spec.command || "");
54
+ setHbChannel(spec.channel || "heartbeat");
55
+ setHbMessage(spec.message || "");
56
+ setPre((draft.pre_commands || []).join("\n"));
57
+ setPost((draft.post_commands || []).join("\n"));
58
+ }, [draft]);
59
+
60
+ // Pre/post shell wrap the LLM kinds AND telegram (pre can fetch data, the text
61
+ // can use {{pre_output}}, post can react to the result).
62
+ const usesPrePost = kind === "exec_agent" || kind === "super_agent" || kind === "telegram";
63
+
64
+ const buildSpec = (): Record<string, unknown> => {
65
+ switch (kind) {
66
+ case "exec_agent": return { agent, prompt };
67
+ case "super_agent": return { prompt };
68
+ case "telegram": return { channel: tgChannel, ...(tgChatId ? { chat_id: tgChatId } : {}), text: tgText };
69
+ case "shell": return { command };
70
+ case "heartbeat": return { channel: hbChannel, message: hbMessage };
71
+ }
72
+ };
73
+
74
+ const submit = async () => {
75
+ if (!name) { toast.error(t("project.routines.name_required")); return; }
76
+ setBusy(true);
77
+ try {
78
+ await Routines.upsert(pid, {
79
+ name, kind, schedule, enabled,
80
+ spec: buildSpec(),
81
+ pre_commands: usesPrePost ? splitLines(pre) : [],
82
+ post_commands: usesPrePost ? splitLines(post) : [],
83
+ });
84
+ toast.success(t("project.routines.saved"));
85
+ onSaved();
86
+ } catch (e: any) { toast.error(e?.message || t("project.routines.save_error")); }
87
+ finally { setBusy(false); }
88
+ };
89
+
90
+ // Channel options: "default" + configured channels + the current value.
91
+ const channelOptions = (() => {
92
+ const list = tgChannels.data?.channels || [];
93
+ const names = ["default", ...list.map((c) => c.name)];
94
+ if (tgChannel && !names.includes(tgChannel)) names.push(tgChannel);
95
+ const seen = new Set<string>();
96
+ return names
97
+ .filter((n) => (seen.has(n) ? false : (seen.add(n), true)))
98
+ .map((n) => {
99
+ const ch = list.find((c) => c.name === n);
100
+ const description = ch?.project ? `proyecto ${ch.project}` : ch?.chat_id ? `chat ${ch.chat_id}` : undefined;
101
+ return { value: n, label: n, description };
102
+ });
103
+ })();
104
+
105
+ // Timeline steps (pre → action → post).
106
+ const preSteps = usesPrePost ? splitLines(pre) : [];
107
+ const postSteps = usesPrePost ? splitLines(post) : [];
108
+ const actionLabel = (() => {
109
+ switch (kind) {
110
+ case "exec_agent": return agent ? t("agents_ui.action_agent_answers", { agent }) : t("agents_ui.action_agent_pick_answers");
111
+ case "super_agent": return t("agents_ui.action_super_answers");
112
+ case "telegram": return t("agents_ui.action_telegram_channel", { channel: tgChannel });
113
+ case "shell": return command ? t("agents_ui.summary_runs_cmd", { cmd: command.slice(0, 48) }) : t("agents_ui.action_runs_shell");
114
+ case "heartbeat": return t("agents_ui.summary_heartbeat");
115
+ }
116
+ })();
117
+ const actionDetail =
118
+ kind === "telegram" ? tgText :
119
+ kind === "shell" ? command :
120
+ kind === "heartbeat" ? hbMessage :
121
+ prompt;
122
+
123
+ const ActionIcon = kindMeta()[kind].icon;
124
+ const steps = [
125
+ ...preSteps.map((c, i) => ({ id: `pre-${i}`, icon: Terminal, label: t("agents_ui.step_pre"), detail: c, action: false })),
126
+ { id: "action", icon: ActionIcon, label: actionLabel, detail: actionDetail ? actionDetail.slice(0, 90) : t("project.routines.block_empty"), action: true },
127
+ ...postSteps.map((c, i) => ({ id: `post-${i}`, icon: Terminal, label: t("agents_ui.step_post"), detail: c, action: false })),
128
+ ];
129
+
130
+ return (
131
+ <Dialog
132
+ open={!!draft}
133
+ onClose={onClose}
134
+ title={draft?.name ? t("project.routines.edit_title", { name: draft.name }) : t("project.routines.new_title")}
135
+ description={t("project.routines.dialog_desc")}
136
+ size="xl"
137
+ footer={
138
+ <>
139
+ <Button variant="ghost" onClick={onClose} disabled={busy}>{t("common.cancel")}</Button>
140
+ <Button variant="primary" onClick={submit} loading={busy}>{t("common.save")}</Button>
141
+ </>
142
+ }
143
+ >
144
+ <div className="space-y-4">
145
+ <div className="grid gap-6 md:grid-cols-2">
146
+ {/* LEFT — opciones + enabled + variables disponibles */}
147
+ <div className="space-y-3">
148
+ <div className="flex items-center justify-between rounded-lg border border-border bg-muted/20 px-3 py-2">
149
+ <Switch checked={enabled} onChange={setEnabled} label={t("project.routines.enabled_label")} />
150
+ <span className="text-[11px] text-muted-fg">{enabled ? t("project.routines.enabled_hint") : t("project.routines.disabled_hint")}</span>
151
+ </div>
152
+ <Field label={t("project.routines.name_field")} hint={draft?.name ? t("project.routines.name_no_edit") : undefined}>
153
+ <Input value={name} disabled={!!draft?.name} onChange={(e) => setName(e.target.value)} placeholder="resumen-diario" />
154
+ </Field>
155
+ <Field label={t("project.routines.kind_field")}>
156
+ <UiSelect value={kind} onChange={(v) => setKind(v as Kind)} options={kindOptions(kind)} />
157
+ </Field>
158
+ <p className="-mt-1 text-[11px] text-muted-fg">{kindMeta()[kind].desc}</p>
159
+ {kind === "exec_agent" && (
160
+ <Field label={t("project.routines.agent_field")} hint={t("project.routines.agent_hint")}>
161
+ <UiSelect value={agent} onChange={setAgent} placeholder={agentsList.isLoading ? t("project.routines.agent_loading") : t("project.routines.agent_pick")}
162
+ options={(agentsList.data || []).map((a) => ({ value: a.slug, label: a.slug, description: [a.role, a.model].filter(Boolean).join(" · ") || undefined }))} />
163
+ </Field>
164
+ )}
165
+ <Field label={t("project.routines.schedule_field")} hint={t("project.routines.schedule_hint")}>
166
+ <div className="space-y-2">
167
+ <div className="flex flex-wrap gap-1">
168
+ {schedPresets().map((s) => (
169
+ <button key={s.value} type="button" onClick={() => setSchedule(s.value)}
170
+ 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")}>
171
+ {s.label}
172
+ </button>
173
+ ))}
174
+ <button type="button" onClick={() => setSchedule("manual")}
175
+ 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")}>
176
+ {t("agents_ui.preset_manual")}
177
+ </button>
178
+ </div>
179
+ <Input value={schedule} onChange={(e) => setSchedule(e.target.value)} placeholder="every:10m · cron 0 9 * * 1-5 · once:ISO · manual" />
180
+ </div>
181
+ </Field>
182
+ <AvailableVarsCard />
183
+ </div>
184
+
185
+ {/* RIGHT — lo que ejecuta, según el tipo. Cada textarea trae sus
186
+ variables como chips clickeables debajo. */}
187
+ <div className="space-y-3">
188
+ {/* pre → action → post (las variables aparecen donde se usan) */}
189
+ {usesPrePost && (
190
+ <VarTextarea
191
+ label={t("project.routines.pre_field")} hint={t("project.routines.pre_hint")}
192
+ rows={2} mono value={pre} onChange={setPre} vars={varsFor("pre")}
193
+ placeholder="curl -s https://wttr.in/Bariloche"
194
+ />
195
+ )}
196
+ {kind === "exec_agent" && (
197
+ <VarTextarea label={t("project.routines.prompt_exec")} rows={4} value={prompt} onChange={setPrompt}
198
+ vars={varsFor("prompt")} placeholder={t("project.routines.prompt_exec_ph")} />
199
+ )}
200
+ {kind === "super_agent" && (
201
+ <VarTextarea label={t("project.routines.prompt_super")} rows={4} value={prompt} onChange={setPrompt}
202
+ vars={varsFor("prompt")} placeholder={t("project.routines.prompt_super_ph")} />
203
+ )}
204
+
205
+ {/* Telegram: channel selector + chat id + texto (con {{pre_output}}) */}
206
+ {kind === "telegram" && (
207
+ <>
208
+ <div className="grid grid-cols-2 gap-3">
209
+ <Field label={t("project.routines.tg_channel")}>
210
+ <UiSelect value={tgChannel} onChange={setTgChannel} options={channelOptions} />
211
+ </Field>
212
+ <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>
213
+ </div>
214
+ <VarTextarea label={t("project.routines.tg_text")} hint={t("project.routines.tg_text_hint")}
215
+ rows={6} value={tgText} onChange={setTgText} vars={varsFor("prompt")} placeholder={t("agents_ui.tg_text_ph")} />
216
+ </>
217
+ )}
218
+
219
+ {/* Shell: un comando, sin variables */}
220
+ {kind === "shell" && (
221
+ <VarTextarea label={t("project.routines.shell_field")} hint={t("project.routines.shell_hint")}
222
+ rows={11} mono value={command} onChange={setCommand} vars={[]} placeholder="cd /repo && git pull && npm test" />
223
+ )}
224
+
225
+ {/* Heartbeat: solo loguea (no se ofrece para rutinas nuevas) */}
226
+ {kind === "heartbeat" && (
227
+ <div className="grid grid-cols-2 gap-3">
228
+ <Field label={t("project.routines.hb_channel")}><Input value={hbChannel} onChange={(e) => setHbChannel(e.target.value)} placeholder="heartbeat" /></Field>
229
+ <Field label={t("project.routines.hb_message")}><Input value={hbMessage} onChange={(e) => setHbMessage(e.target.value)} placeholder={t("agents_ui.hb_message_ph")} /></Field>
230
+ </div>
231
+ )}
232
+
233
+ {usesPrePost && (
234
+ <VarTextarea
235
+ label={t("project.routines.post_field")} hint={t("project.routines.post_hint")}
236
+ rows={2} mono value={post} onChange={setPost} vars={varsFor("post")}
237
+ placeholder={'apx telegram send "$APX_LLM_OUTPUT"'}
238
+ />
239
+ )}
240
+ </div>
241
+ </div>
242
+
243
+ {/* Qué va a pasar — full width */}
244
+ <div className="rounded-lg border border-border bg-muted/20 p-3">
245
+ <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>
246
+ <div className="flex flex-wrap items-stretch gap-2">
247
+ {steps.map((s, i) => (
248
+ <div key={s.id} className="flex items-stretch gap-2">
249
+ <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")}>
250
+ <div className={cn("flex items-center gap-1.5 text-[11px] font-medium", s.action ? "text-emerald-400" : "text-muted-fg")}>
251
+ <s.icon size={12} /> {s.label}
252
+ </div>
253
+ {s.detail && <div className="line-clamp-2 font-mono text-[10px] text-muted-fg">{s.detail}</div>}
254
+ </div>
255
+ {i < steps.length - 1 && <ArrowRight size={14} className="shrink-0 self-center text-muted-fg" />}
256
+ </div>
257
+ ))}
258
+ </div>
259
+ </div>
260
+ </div>
261
+ </Dialog>
262
+ );
263
+ }
@@ -0,0 +1,59 @@
1
+ import { Zap } from "lucide-react";
2
+ import type { RoutineEntry } from "../../lib/api";
3
+ import { StatusDot } from "../Section";
4
+ import { cn } from "../../lib/cn";
5
+ import { t } from "../../i18n";
6
+ import { kindMeta, scheduleHuman } from "./shared";
7
+
8
+ // Left column: scrollable list of routines. Click selects (the divider is the
9
+ // single border-r line); the detail lives in the sibling column.
10
+ export function RoutineList({
11
+ routines, selectedName, onSelect,
12
+ }: {
13
+ routines: RoutineEntry[];
14
+ selectedName: string | null;
15
+ onSelect: (name: string) => void;
16
+ }) {
17
+ return (
18
+ <aside className="flex h-full min-h-0 flex-col border-r border-border">
19
+ <div className="shrink-0 px-3 py-2.5 text-[11px] font-semibold uppercase tracking-wide text-muted-fg">
20
+ {t("project.routines.list_title")}
21
+ </div>
22
+ <ul className="min-h-0 flex-1 space-y-1 overflow-y-auto p-2 pt-0">
23
+ {routines.map((r) => {
24
+ const meta = kindMeta()[r.kind];
25
+ const Icon = meta?.icon || Zap;
26
+ const active = r.name === selectedName;
27
+ return (
28
+ <li key={r.name}>
29
+ <button
30
+ type="button"
31
+ onClick={() => onSelect(r.name)}
32
+ aria-current={active}
33
+ className={cn(
34
+ "w-full rounded-lg border px-2.5 py-2 text-left transition-colors",
35
+ active
36
+ ? "border-primary/50 bg-primary/10"
37
+ : "border-transparent hover:border-border hover:bg-accent/40",
38
+ )}
39
+ >
40
+ <div className="flex items-center gap-2">
41
+ <span className={cn("flex size-6 shrink-0 items-center justify-center rounded-md", r.enabled ? "bg-emerald-500/15 text-emerald-400" : "bg-muted text-muted-fg")}>
42
+ <Icon size={13} />
43
+ </span>
44
+ <span className="min-w-0 flex-1 truncate text-sm font-medium">{r.name}</span>
45
+ {!r.enabled && <span className="shrink-0 text-[10px] text-muted-fg">{t("project.routines.paused")}</span>}
46
+ <StatusDot ok={r.last_status === "ok" ? true : r.last_status === "error" ? false : null} />
47
+ </div>
48
+ <div className="mt-1 flex items-center justify-between gap-2 pl-8 text-[10px] text-muted-fg">
49
+ <span className="truncate">{meta?.label || r.kind}</span>
50
+ <span className="shrink-0">⏱ {scheduleHuman(r.schedule)}</span>
51
+ </div>
52
+ </button>
53
+ </li>
54
+ );
55
+ })}
56
+ </ul>
57
+ </aside>
58
+ );
59
+ }