@agentprojectcontext/apx 1.39.0 → 1.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/package.json +1 -2
  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/web/dist/assets/index-Cg-uHCex.js +646 -0
  15. package/src/interfaces/web/dist/assets/index-Cg-uHCex.js.map +1 -0
  16. package/src/interfaces/web/dist/assets/index-wrEbTJbc.css +1 -0
  17. package/src/interfaces/web/dist/index.html +2 -2
  18. package/src/interfaces/web/package-lock.json +11 -11
  19. package/src/interfaces/web/src/App.tsx +22 -11
  20. package/src/interfaces/web/src/components/AddProjectDialog.tsx +66 -34
  21. package/src/interfaces/web/src/components/ModelCombobox.tsx +6 -3
  22. package/src/interfaces/web/src/components/chat/MessageBubble.tsx +28 -25
  23. package/src/interfaces/web/src/components/chat/ModelPicker.tsx +19 -17
  24. package/src/interfaces/web/src/components/deck/WidgetRow.tsx +9 -7
  25. package/src/interfaces/web/src/components/inputs/VarTokenInput.tsx +21 -19
  26. package/src/interfaces/web/src/components/layout/ProjectSidebar.tsx +3 -2
  27. package/src/interfaces/web/src/components/routines/AvailableVarsCard.tsx +23 -0
  28. package/src/interfaces/web/src/components/routines/ExecutionsList.tsx +189 -0
  29. package/src/interfaces/web/src/components/routines/ReadOnlyBlock.tsx +14 -0
  30. package/src/interfaces/web/src/components/routines/RoutineDetail.tsx +86 -0
  31. package/src/interfaces/web/src/components/routines/RoutineEditor.tsx +263 -0
  32. package/src/interfaces/web/src/components/routines/RoutineList.tsx +59 -0
  33. package/src/interfaces/web/src/components/routines/VarTextarea.tsx +70 -0
  34. package/src/interfaces/web/src/components/routines/shared.ts +89 -0
  35. package/src/interfaces/web/src/components/settings/PairDeviceDialog.tsx +19 -16
  36. package/src/interfaces/web/src/components/settings/TelegramContactsPanel.tsx +10 -8
  37. package/src/interfaces/web/src/components/settings/providers/ProviderModal.tsx +7 -4
  38. package/src/interfaces/web/src/components/ui/chat-input.tsx +24 -21
  39. package/src/interfaces/web/src/components/ui/sidebar.tsx +20 -18
  40. package/src/interfaces/web/src/components/ui.tsx +4 -0
  41. package/src/interfaces/web/src/i18n/en.ts +34 -11
  42. package/src/interfaces/web/src/i18n/es.ts +34 -11
  43. package/src/interfaces/web/src/lib/api/filesystem.ts +6 -0
  44. package/src/interfaces/web/src/screens/ApxAdminScreen.tsx +11 -3
  45. package/src/interfaces/web/src/screens/modules/DeckScreen.tsx +6 -3
  46. package/src/interfaces/web/src/screens/project/AgentDetailScreen.tsx +8 -5
  47. package/src/interfaces/web/src/screens/project/McpsTab.tsx +16 -9
  48. package/src/interfaces/web/src/screens/project/RoutinesTab.tsx +126 -373
  49. package/src/interfaces/web/src/styles.css +5 -0
  50. package/src/interfaces/web/dist/assets/index-CAKEYko0.css +0 -1
  51. package/src/interfaces/web/dist/assets/index-UzqHxD0B.js +0 -639
  52. package/src/interfaces/web/dist/assets/index-UzqHxD0B.js.map +0 -1
@@ -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
+ }
@@ -0,0 +1,70 @@
1
+ import { useRef } from "react";
2
+ import { Textarea } from "../ui";
3
+ import { cn } from "../../lib/cn";
4
+
5
+ type Var = { v: string };
6
+
7
+ // A labelled textarea with its context's variables as click-to-insert chips
8
+ // underneath. The chips have NO tooltip (the explanations live in the
9
+ // "available variables" reference card); clicking one drops the token at the
10
+ // caret (or appends it). Label is a <div> (not <label>) so the chip buttons
11
+ // don't collide with native label→control focusing. The hint sits under the
12
+ // title, above the textarea.
13
+ export function VarTextarea({
14
+ label, hint, value, onChange, vars, rows = 3, mono, placeholder,
15
+ }: {
16
+ label: string;
17
+ hint?: string;
18
+ value: string;
19
+ onChange: (v: string) => void;
20
+ vars: Var[];
21
+ rows?: number;
22
+ mono?: boolean;
23
+ placeholder?: string;
24
+ }) {
25
+ const wrapRef = useRef<HTMLDivElement>(null);
26
+
27
+ const insert = (token: string) => {
28
+ const ta = wrapRef.current?.querySelector("textarea");
29
+ if (!ta) { onChange(value ? `${value}${token}` : token); return; }
30
+ const start = ta.selectionStart ?? value.length;
31
+ const end = ta.selectionEnd ?? value.length;
32
+ const next = value.slice(0, start) + token + value.slice(end);
33
+ onChange(next);
34
+ requestAnimationFrame(() => {
35
+ ta.focus();
36
+ const pos = start + token.length;
37
+ ta.setSelectionRange(pos, pos);
38
+ });
39
+ };
40
+
41
+ return (
42
+ <div className="space-y-1">
43
+ <div className="text-xs font-medium text-muted-foreground">{label}</div>
44
+ {hint && <div className="text-[11px] text-muted-foreground/70">{hint}</div>}
45
+ <div ref={wrapRef} className="space-y-1.5">
46
+ <Textarea
47
+ rows={rows}
48
+ className={cn(mono && "font-mono text-xs")}
49
+ value={value}
50
+ onChange={(e) => onChange(e.target.value)}
51
+ placeholder={placeholder}
52
+ />
53
+ {vars.length > 0 && (
54
+ <div className="flex flex-wrap gap-1">
55
+ {vars.map((v) => (
56
+ <button
57
+ key={v.v}
58
+ type="button"
59
+ onClick={() => insert(v.v)}
60
+ className="inline-flex items-center rounded-md border border-border bg-card px-1.5 py-0.5 font-mono text-[10px] text-muted-fg transition-colors hover:border-muted-fg/50 hover:text-foreground"
61
+ >
62
+ {v.v}
63
+ </button>
64
+ ))}
65
+ </div>
66
+ )}
67
+ </div>
68
+ </div>
69
+ );
70
+ }
@@ -0,0 +1,89 @@
1
+ // Shared helpers for the Routines screen + its components.
2
+ // Kept framework-free (pure functions + i18n) so list/detail/editor reuse them.
3
+ import { Bot, Crown, Heart, Send, Terminal } from "lucide-react";
4
+ import type { RoutineEntry } from "../../lib/api";
5
+ import { t } from "../../i18n";
6
+
7
+ export type Kind = RoutineEntry["kind"];
8
+
9
+ export function splitLines(v: string): string[] {
10
+ return v.split("\n").map((s) => s.trim()).filter(Boolean);
11
+ }
12
+
13
+ // Friendly action types (maps to routines.js kinds).
14
+ export function kindMeta(): Record<Kind, { label: string; desc: string; icon: typeof Bot }> {
15
+ return {
16
+ exec_agent: { label: t("agents_ui.kind_exec_agent"), desc: t("agents_ui.kind_exec_agent_desc"), icon: Bot },
17
+ super_agent: { label: t("agents_ui.kind_super_agent"), desc: t("agents_ui.kind_super_agent_desc"), icon: Crown },
18
+ telegram: { label: t("agents_ui.kind_telegram"), desc: t("agents_ui.kind_telegram_desc"), icon: Send },
19
+ shell: { label: t("agents_ui.kind_shell"), desc: t("agents_ui.kind_shell_desc"), icon: Terminal },
20
+ heartbeat: { label: t("agents_ui.kind_heartbeat"), desc: t("agents_ui.kind_heartbeat_desc"), icon: Heart },
21
+ };
22
+ }
23
+
24
+ export function kindOptions(includeKind?: Kind) {
25
+ const meta = kindMeta();
26
+ // Heartbeat is no longer offered for new routines (the runner already logs a
27
+ // per-run line — see AGENTS.md). Keep it only when editing an existing one.
28
+ return (Object.keys(meta) as Kind[])
29
+ .filter((k) => k !== "heartbeat" || includeKind === "heartbeat")
30
+ .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
+ export 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
+ export 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
+ export 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
+ // Which variables belong under which textarea:
80
+ // pre → vars usable in pre-commands ($APX_ROUTINE)
81
+ // prompt → template vars for the prompt / telegram text ({{pre_output}})
82
+ // post → env vars for post-commands ($APX_*)
83
+ export function varsFor(ctx: "pre" | "prompt" | "post") {
84
+ return routineVars().filter((v) => {
85
+ if (ctx === "pre") return v.where.includes("pre");
86
+ if (ctx === "prompt") return v.where === "prompt";
87
+ return v.where === "post" || v.where === "pre/post";
88
+ });
89
+ }
@@ -1,6 +1,7 @@
1
1
  import { useCallback, useEffect, useRef, useState } from "react";
2
2
  import { Copy } from "lucide-react";
3
3
  import { Dialog, Button, Spinner } from "../ui";
4
+ import { Tip } from "../ui/tip";
4
5
  import { Qr } from "../common/Qr";
5
6
  import { useToast } from "../Toast";
6
7
  import { Pair, HttpError } from "../../lib/api";
@@ -138,14 +139,15 @@ export function PairDeviceDialog({
138
139
  <code className="min-w-0 flex-1 break-all rounded-md bg-muted px-3 py-2 text-xs">
139
140
  {scanUrl}
140
141
  </code>
141
- <Button
142
- size="sm"
143
- variant="secondary"
144
- onClick={() => copy(scanUrl, t("settings.devices_pair_copied"))}
145
- title={t("settings.devices_pair_copy")}
146
- >
147
- <Copy size={14} />
148
- </Button>
142
+ <Tip content={t("settings.devices_pair_copy")}>
143
+ <Button
144
+ size="sm"
145
+ variant="secondary"
146
+ onClick={() => copy(scanUrl, t("settings.devices_pair_copied"))}
147
+ >
148
+ <Copy size={14} />
149
+ </Button>
150
+ </Tip>
149
151
  </div>
150
152
  </div>
151
153
 
@@ -155,14 +157,15 @@ export function PairDeviceDialog({
155
157
  <code className="min-w-0 flex-1 break-all rounded-md bg-muted px-3 py-2 text-center text-sm">
156
158
  {init.pairing_id}
157
159
  </code>
158
- <Button
159
- size="sm"
160
- variant="secondary"
161
- onClick={() => copy(init.pairing_id, t("settings.devices_pair_copied_code"))}
162
- title={t("settings.devices_pair_copy")}
163
- >
164
- <Copy size={14} />
165
- </Button>
160
+ <Tip content={t("settings.devices_pair_copy")}>
161
+ <Button
162
+ size="sm"
163
+ variant="secondary"
164
+ onClick={() => copy(init.pairing_id, t("settings.devices_pair_copied_code"))}
165
+ >
166
+ <Copy size={14} />
167
+ </Button>
168
+ </Tip>
166
169
  </div>
167
170
  </div>
168
171
  </div>
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { Section } from "../Section";
6
6
  import { Badge, Button, Empty, Loading, Select } from "../ui";
7
+ import { Tip } from "../ui/tip";
7
8
  import { useToast } from "../Toast";
8
9
  import { useTelegramContacts } from "../../hooks/useTelegram";
9
10
  import { Telegram } from "../../lib/api";
@@ -58,14 +59,15 @@ export function TelegramContactsPanel({ bare = false }: Props) {
58
59
  {isOwner && <Badge tone="success">{t("telegram_contacts.owner_badge")}</Badge>}
59
60
  </div>
60
61
  <div className="flex items-center gap-2">
61
- <Select
62
- value={effectiveRole}
63
- disabled={isOwner}
64
- onChange={(e) => setRole(c, e.target.value)}
65
- title={isOwner ? t("telegram_contacts.owner_hint") : t("telegram_contacts.assign_role")}
66
- >
67
- {roleNames.map((r) => <option key={r} value={r}>{r}</option>)}
68
- </Select>
62
+ <Tip content={isOwner ? t("telegram_contacts.owner_hint") : t("telegram_contacts.assign_role")}>
63
+ <Select
64
+ value={effectiveRole}
65
+ disabled={isOwner}
66
+ onChange={(e) => setRole(c, e.target.value)}
67
+ >
68
+ {roleNames.map((r) => <option key={r} value={r}>{r}</option>)}
69
+ </Select>
70
+ </Tip>
69
71
  <Button size="sm" variant="destructive" onClick={() => remove(c)}>{t("common.delete")}</Button>
70
72
  </div>
71
73
  </div>