@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
@@ -1,6 +1,7 @@
1
1
  import { useEffect, useState } from "react";
2
+ import { useNavigate } from "react-router-dom";
2
3
  import { useSWRConfig } from "swr";
3
- import { FolderOpen, Home, Search } from "lucide-react";
4
+ import { FolderOpen, Home, Search, X } from "lucide-react";
4
5
  import { Filesystem, Projects } from "../lib/api";
5
6
  import { Button, Dialog, Empty, Field, Input, Loading } from "./ui";
6
7
  import { useToast } from "./Toast";
@@ -8,8 +9,10 @@ import { t } from "../i18n";
8
9
 
9
10
  export function AddProjectDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
10
11
  const { mutate } = useSWRConfig();
12
+ const navigate = useNavigate();
11
13
  const toast = useToast();
12
14
  const [path, setPath] = useState("");
15
+ const [browseOpen, setBrowseOpen] = useState(false);
13
16
  const [browsePath, setBrowsePath] = useState("");
14
17
  const [entries, setEntries] = useState<string[]>([]);
15
18
  const [parent, setParent] = useState<string | null>(null);
@@ -35,10 +38,34 @@ export function AddProjectDialog({ open, onClose }: { open: boolean; onClose: ()
35
38
  }
36
39
  };
37
40
 
41
+ // Reset everything when the dialog closes so reopening starts fresh.
38
42
  useEffect(() => {
39
- if (open && !browsePath) loadDirs(path || "~", true);
43
+ if (open) return;
44
+ setPath("");
45
+ setBrowseOpen(false);
46
+ setBrowsePath("");
47
+ setEntries([]);
48
+ setParent(null);
49
+ setBrowseError("");
40
50
  }, [open]);
41
51
 
52
+ const openBrowser = async () => {
53
+ // Try the OS-native folder picker first (osascript / zenity / PowerShell).
54
+ // If the daemon can't open one, fall back to the inline directory list.
55
+ setLoadingDirs(true);
56
+ try {
57
+ const out = await Filesystem.pickDir(t("add_project.picker_prompt"));
58
+ if ("cancelled" in out) return;
59
+ setPath(out.path);
60
+ return;
61
+ } catch {
62
+ setBrowseOpen(true);
63
+ await loadDirs(path || "~");
64
+ } finally {
65
+ setLoadingDirs(false);
66
+ }
67
+ };
68
+
42
69
  const submit = async () => {
43
70
  const trimmed = path.trim();
44
71
  if (!trimmed) { toast.error(t("add_project.path_required")); return; }
@@ -47,8 +74,8 @@ export function AddProjectDialog({ open, onClose }: { open: boolean; onClose: ()
47
74
  const out = await Projects.register(trimmed);
48
75
  toast.success(t("add_project.registered", { id: out.id }));
49
76
  await mutate("/projects");
50
- setPath("");
51
77
  onClose();
78
+ navigate(`/p/${out.id}`);
52
79
  } catch (e) {
53
80
  toast.error((e as Error).message);
54
81
  } finally { setBusy(false); }
@@ -77,44 +104,49 @@ export function AddProjectDialog({ open, onClose }: { open: boolean; onClose: ()
77
104
  onChange={(e) => setPath(e.target.value)}
78
105
  onKeyDown={(e) => { if (e.key === "Enter") submit(); }}
79
106
  />
80
- <Button onClick={() => loadDirs(path || "~")} disabled={loadingDirs}>
107
+ <Button onClick={openBrowser} disabled={loadingDirs}>
81
108
  <Search size={14} /> {t("add_project.search_btn")}
82
109
  </Button>
83
110
  </div>
84
111
  </Field>
85
112
 
86
- <div className="rounded-md border border-border bg-muted/20">
87
- <div className="flex items-center justify-between border-b border-border px-3 py-2">
88
- <span className="truncate font-mono text-xs text-muted-fg">{browsePath || path || "~"}</span>
89
- <div className="flex gap-1">
90
- <Button size="sm" variant="ghost" onClick={() => loadDirs("~")} disabled={loadingDirs}>
91
- <Home size={13} />
92
- </Button>
93
- <Button size="sm" variant="ghost" onClick={() => parent && loadDirs(parent)} disabled={!parent || loadingDirs}>
94
- ..
95
- </Button>
113
+ {browseOpen && (
114
+ <div className="rounded-md border border-border bg-muted/20">
115
+ <div className="flex items-center justify-between border-b border-border px-3 py-2">
116
+ <span className="truncate font-mono text-xs text-muted-fg">{browsePath || path || "~"}</span>
117
+ <div className="flex gap-1">
118
+ <Button size="sm" variant="ghost" onClick={() => loadDirs("~")} disabled={loadingDirs}>
119
+ <Home size={13} />
120
+ </Button>
121
+ <Button size="sm" variant="ghost" onClick={() => parent && loadDirs(parent)} disabled={!parent || loadingDirs}>
122
+ ..
123
+ </Button>
124
+ <Button size="sm" variant="ghost" onClick={() => setBrowseOpen(false)} disabled={loadingDirs}>
125
+ <X size={13} />
126
+ </Button>
127
+ </div>
128
+ </div>
129
+ <div className="max-h-64 overflow-y-auto p-2">
130
+ {loadingDirs && <Loading />}
131
+ {!loadingDirs && browseError && (
132
+ <Empty>{t("add_project.browser_unavailable")}</Empty>
133
+ )}
134
+ {!loadingDirs && !browseError && entries.length === 0 && <Empty>{t("add_project.no_folders")}</Empty>}
135
+ {!loadingDirs && !browseError && entries.map((entry) => (
136
+ <button
137
+ key={entry}
138
+ type="button"
139
+ onClick={() => loadDirs(entry)}
140
+ className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm hover:bg-accent"
141
+ >
142
+ <FolderOpen size={14} className="text-muted-fg" />
143
+ <span className="truncate">{entry.split("/").pop()}</span>
144
+ <span className="ml-auto truncate font-mono text-[10px] text-muted-fg">{entry}</span>
145
+ </button>
146
+ ))}
96
147
  </div>
97
148
  </div>
98
- <div className="max-h-64 overflow-y-auto p-2">
99
- {loadingDirs && <Loading />}
100
- {!loadingDirs && browseError && (
101
- <Empty>{t("add_project.browser_unavailable")}</Empty>
102
- )}
103
- {!loadingDirs && !browseError && entries.length === 0 && <Empty>{t("add_project.no_folders")}</Empty>}
104
- {!loadingDirs && !browseError && entries.map((entry) => (
105
- <button
106
- key={entry}
107
- type="button"
108
- onClick={() => loadDirs(entry)}
109
- className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm hover:bg-accent"
110
- >
111
- <FolderOpen size={14} className="text-muted-fg" />
112
- <span className="truncate">{entry.split("/").pop()}</span>
113
- <span className="ml-auto truncate font-mono text-[10px] text-muted-fg">{entry}</span>
114
- </button>
115
- ))}
116
- </div>
117
- </div>
149
+ )}
118
150
  </div>
119
151
  </Dialog>
120
152
  );
@@ -2,6 +2,7 @@ import { useEffect, useLayoutEffect, useRef, useState } from "react";
2
2
  import { createPortal } from "react-dom";
3
3
  import { AlertTriangle, ChevronDown } from "lucide-react";
4
4
  import { cn } from "../lib/cn";
5
+ import { Tip } from "./ui/tip";
5
6
  import { t } from "../i18n";
6
7
 
7
8
  // Editable combobox: type freely, matching options appear below; click one to
@@ -83,9 +84,11 @@ export function ModelCombobox({
83
84
  )}
84
85
  >
85
86
  {invalid && (
86
- <span title={invalidHint || t("models_ui.invalid_hint")}>
87
- <AlertTriangle className="size-3.5 shrink-0 text-amber-400" />
88
- </span>
87
+ <Tip content={invalidHint || t("models_ui.invalid_hint")}>
88
+ <span>
89
+ <AlertTriangle className="size-3.5 shrink-0 text-amber-400" />
90
+ </span>
91
+ </Tip>
89
92
  )}
90
93
  <input
91
94
  value={query}
@@ -4,6 +4,7 @@ import { ToolCall } from "./ToolCall";
4
4
  import { AskQuestionsCard } from "./AskQuestionsCard";
5
5
  import { AskAnswersCard, parseAskAnswerText } from "./AskAnswersCard";
6
6
  import { textOf, type ChatMsg } from "../../hooks/useChat";
7
+ import { Tip } from "../ui/tip";
7
8
  import { t } from "../../i18n";
8
9
 
9
10
  interface Props {
@@ -51,22 +52,23 @@ export function MessageBubble({ msg, isLast, isAskAnswer, onCopy }: Props) {
51
52
 
52
53
  {/* Skill Inspector: which skills the per-turn RAG injected for this turn. */}
53
54
  {!mine && msg.inspector && (msg.inspector.loaded?.length || msg.inspector.hinted?.length) ? (
54
- <div
55
- className="flex flex-wrap items-center gap-1 text-[10px] text-sky-400/90"
56
- title={t("shared_ui.skill_inspector_title", { embedder: msg.inspector.embedder || "RAG" })}
57
- >
58
- <Sparkles size={10} />
59
- {msg.inspector.loaded?.map((s) => (
60
- <span key={`l-${s}`} className="rounded bg-sky-500/15 px-1 py-0.5 font-mono">
61
- {s}
62
- </span>
63
- ))}
64
- {msg.inspector.hinted?.map((s) => (
65
- <span key={`h-${s}`} className="rounded border border-sky-500/30 px-1 py-0.5 font-mono opacity-70">
66
- {s}?
67
- </span>
68
- ))}
69
- </div>
55
+ <Tip content={t("shared_ui.skill_inspector_title", { embedder: msg.inspector.embedder || "RAG" })}>
56
+ <div
57
+ className="flex flex-wrap items-center gap-1 text-[10px] text-sky-400/90"
58
+ >
59
+ <Sparkles size={10} />
60
+ {msg.inspector.loaded?.map((s) => (
61
+ <span key={`l-${s}`} className="rounded bg-sky-500/15 px-1 py-0.5 font-mono">
62
+ {s}
63
+ </span>
64
+ ))}
65
+ {msg.inspector.hinted?.map((s) => (
66
+ <span key={`h-${s}`} className="rounded border border-sky-500/30 px-1 py-0.5 font-mono opacity-70">
67
+ {s}?
68
+ </span>
69
+ ))}
70
+ </div>
71
+ </Tip>
70
72
  ) : null}
71
73
 
72
74
  {/* Ordered parts: interleaved assistant text + tool calls. */}
@@ -110,15 +112,16 @@ export function MessageBubble({ msg, isLast, isAskAnswer, onCopy }: Props) {
110
112
  <span>· {t("shared_ui.tools_count", { n: msg.parts.filter((p) => p.kind === "tool").length })}</span>
111
113
  )}
112
114
  {onCopy && copyText && (
113
- <button
114
- type="button"
115
- onClick={() => onCopy(copyText)}
116
- className="inline-flex items-center gap-1 hover:text-foreground"
117
- title={t("chat_ui.copy")}
118
- aria-label={t("chat_ui.copy")}
119
- >
120
- <Copy size={10} /> {t("chat_ui.copy")}
121
- </button>
115
+ <Tip content={t("chat_ui.copy")}>
116
+ <button
117
+ type="button"
118
+ onClick={() => onCopy(copyText)}
119
+ className="inline-flex items-center gap-1 hover:text-foreground"
120
+ aria-label={t("chat_ui.copy")}
121
+ >
122
+ <Copy size={10} /> {t("chat_ui.copy")}
123
+ </button>
124
+ </Tip>
122
125
  )}
123
126
  </div>
124
127
  </div>
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react";
2
2
  import { Server, ChevronDown, X, Check } from "lucide-react";
3
3
  import { cn } from "../../lib/cn";
4
4
  import { Engines } from "../../lib/api";
5
+ import { Tip } from "../ui/tip";
5
6
  import { t } from "../../i18n";
6
7
 
7
8
  // Compact model picker for the chat composer (the panda.project pattern): a
@@ -70,23 +71,24 @@ export function ModelPicker({
70
71
 
71
72
  return (
72
73
  <div ref={wrapRef} className="relative">
73
- <button
74
- type="button"
75
- disabled={disabled}
76
- onClick={() => setOpen((v) => !v)}
77
- data-testid="chat-model-picker"
78
- className={cn(
79
- "flex max-w-[200px] items-center gap-1 rounded-md border border-transparent px-1.5 py-0.5 text-[11px] text-muted-foreground transition-colors",
80
- "hover:bg-accent/60 hover:text-foreground",
81
- value && "text-foreground",
82
- )}
83
- title={t("chat_ui.pick_model")}
84
- aria-label={t("chat_ui.pick_model")}
85
- >
86
- <Server className="size-3 shrink-0" />
87
- <span className="truncate font-mono">{label}</span>
88
- <ChevronDown className="size-3 shrink-0 opacity-60" />
89
- </button>
74
+ <Tip content={t("chat_ui.pick_model")}>
75
+ <button
76
+ type="button"
77
+ disabled={disabled}
78
+ onClick={() => setOpen((v) => !v)}
79
+ data-testid="chat-model-picker"
80
+ className={cn(
81
+ "flex max-w-[200px] items-center gap-1 rounded-md border border-transparent px-1.5 py-0.5 text-[11px] text-muted-foreground transition-colors",
82
+ "hover:bg-accent/60 hover:text-foreground",
83
+ value && "text-foreground",
84
+ )}
85
+ aria-label={t("chat_ui.pick_model")}
86
+ >
87
+ <Server className="size-3 shrink-0" />
88
+ <span className="truncate font-mono">{label}</span>
89
+ <ChevronDown className="size-3 shrink-0 opacity-60" />
90
+ </button>
91
+ </Tip>
90
92
 
91
93
  {open && (
92
94
  <div className="absolute bottom-full left-0 z-50 mb-1.5 w-64 rounded-lg border border-border bg-popover p-1.5 shadow-md ring-1 ring-foreground/10">
@@ -1,5 +1,6 @@
1
1
  import { useState } from "react";
2
2
  import { Badge, Switch } from "../ui";
3
+ import { Tip } from "../ui/tip";
3
4
  import { cn } from "../../lib/cn";
4
5
  import type { DeckWidget } from "../../lib/api/deck";
5
6
  import { t } from "../../i18n";
@@ -63,13 +64,14 @@ export function WidgetRow({ widget, onToggle }: WidgetRowProps) {
63
64
  )}
64
65
  >
65
66
  {/* Source dot */}
66
- <span
67
- title={widget.source === "apx" ? t("deck_screen.widget_native") : t("deck_screen.widget_external")}
68
- className={cn(
69
- "size-2 shrink-0 rounded-full",
70
- widget.source === "apx" ? "bg-emerald-500" : "bg-sky-400"
71
- )}
72
- />
67
+ <Tip content={widget.source === "apx" ? t("deck_screen.widget_native") : t("deck_screen.widget_external")}>
68
+ <span
69
+ className={cn(
70
+ "size-2 shrink-0 rounded-full",
71
+ widget.source === "apx" ? "bg-emerald-500" : "bg-sky-400"
72
+ )}
73
+ />
74
+ </Tip>
73
75
 
74
76
  {/* Title + desktop */}
75
77
  <div className="min-w-0 flex-1">
@@ -8,6 +8,7 @@ import {
8
8
  } from "react";
9
9
  import { Plus } from "lucide-react";
10
10
  import { cn } from "../../lib/cn";
11
+ import { Tip } from "../ui/tip";
11
12
  import { t } from "../../i18n";
12
13
 
13
14
  // VarTokenInput
@@ -285,25 +286,26 @@ export const VarTokenInput = forwardRef<VarTokenInputHandle, VarTokenInputProps>
285
286
  )}
286
287
  </div>
287
288
  <div className="relative flex">
288
- <button
289
- type="button"
290
- // onMouseDown prevents the editor from blurring before we capture
291
- // the saved range that's what kept the caret jumping to start.
292
- onMouseDown={(e) => {
293
- e.preventDefault();
294
- saveSelection();
295
- }}
296
- onClick={() => setPickerOpen((v) => !v)}
297
- aria-label={t("chat_ui.insert_variable")}
298
- title={t("chat_ui.insert_variable")}
299
- className={cn(
300
- "flex items-center justify-center px-2 min-w-8 border-l border-input text-muted-foreground rounded-r-lg",
301
- "hover:bg-muted/60 hover:text-foreground transition-colors",
302
- pickerOpen && "bg-muted/60 text-foreground",
303
- )}
304
- >
305
- <Plus size={14} />
306
- </button>
289
+ <Tip content={t("chat_ui.insert_variable")}>
290
+ <button
291
+ type="button"
292
+ // onMouseDown prevents the editor from blurring before we capture
293
+ // the saved range — that's what kept the caret jumping to start.
294
+ onMouseDown={(e) => {
295
+ e.preventDefault();
296
+ saveSelection();
297
+ }}
298
+ onClick={() => setPickerOpen((v) => !v)}
299
+ aria-label={t("chat_ui.insert_variable")}
300
+ className={cn(
301
+ "flex items-center justify-center px-2 min-w-8 border-l border-input text-muted-foreground rounded-r-lg",
302
+ "hover:bg-muted/60 hover:text-foreground transition-colors",
303
+ pickerOpen && "bg-muted/60 text-foreground",
304
+ )}
305
+ >
306
+ <Plus size={14} />
307
+ </button>
308
+ </Tip>
307
309
  {pickerOpen && (
308
310
  <VarPickerPopover
309
311
  query={pickerQuery}
@@ -31,6 +31,7 @@ import type { ProjectEntry } from "../../types/daemon";
31
31
  interface Props {
32
32
  onSelect: (href: string) => void;
33
33
  onOpenRoby: () => void;
34
+ onOpenAddProject?: () => void;
34
35
  }
35
36
 
36
37
  interface ModuleItem {
@@ -158,7 +159,7 @@ function RailProjectMenu({
158
159
  );
159
160
  }
160
161
 
161
- export function ProjectSidebar({ onSelect, onOpenRoby }: Props) {
162
+ export function ProjectSidebar({ onSelect, onOpenRoby, onOpenAddProject }: Props) {
162
163
  const { projects, isLoading } = useProjects();
163
164
  const location = useLocation();
164
165
  const MODULES = buildModules();
@@ -303,7 +304,7 @@ export function ProjectSidebar({ onSelect, onOpenRoby }: Props) {
303
304
  testId="nav-add-project"
304
305
  icon={<Plus size={18} />}
305
306
  active={false}
306
- onClick={() => onSelect("/?action=add-project")}
307
+ onClick={() => (onOpenAddProject ? onOpenAddProject() : onSelect("/?action=add-project"))}
307
308
  title={t("nav.add_project")}
308
309
  />
309
310
  </div>
@@ -0,0 +1,23 @@
1
+ import { Tip } from "../ui";
2
+ import { t } from "../../i18n";
3
+ import { routineVars } from "./shared";
4
+
5
+ // Reference card (lives under the options column): every variable with its
6
+ // context tag + a hover tooltip explaining it. The click-to-insert chips under
7
+ // each textarea are tooltip-free — the explanation lives here.
8
+ export function AvailableVarsCard() {
9
+ return (
10
+ <div className="rounded-lg border border-border bg-muted/10 p-3">
11
+ <div className="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-muted-fg">{t("project.routines.vars_title")}</div>
12
+ <div className="flex flex-wrap gap-1.5">
13
+ {routineVars().map((v) => (
14
+ <Tip key={v.v} content={<span className="block max-w-[240px] whitespace-normal leading-snug">{v.desc}</span>}>
15
+ <span className="inline-flex cursor-help items-center gap-1 rounded-md border border-border bg-card px-1.5 py-0.5 font-mono text-[10px]">
16
+ {v.v}<span className="not-italic text-muted-fg">· {v.where}</span>
17
+ </span>
18
+ </Tip>
19
+ ))}
20
+ </div>
21
+ </div>
22
+ );
23
+ }
@@ -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
+ }