@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.
- package/package.json +1 -2
- package/src/core/agent/constants.js +7 -1
- package/src/core/agent/retry.js +9 -0
- package/src/core/agent/run-agent.js +56 -5
- package/src/core/agent/tools/pseudo-tools.js +13 -1
- package/src/core/channels/telegram/dispatch.js +23 -3
- package/src/core/engines/mock.js +33 -10
- package/src/core/i18n/en.js +2 -4
- package/src/core/i18n/es.js +1 -4
- package/src/core/i18n/index.js +5 -1
- package/src/core/i18n/pt.js +1 -3
- package/src/core/routines/runner.js +15 -3
- package/src/host/daemon/api/admin.js +29 -0
- package/src/interfaces/web/dist/assets/index-Cg-uHCex.js +646 -0
- package/src/interfaces/web/dist/assets/index-Cg-uHCex.js.map +1 -0
- package/src/interfaces/web/dist/assets/index-wrEbTJbc.css +1 -0
- package/src/interfaces/web/dist/index.html +2 -2
- package/src/interfaces/web/package-lock.json +11 -11
- package/src/interfaces/web/src/App.tsx +22 -11
- package/src/interfaces/web/src/components/AddProjectDialog.tsx +66 -34
- package/src/interfaces/web/src/components/ModelCombobox.tsx +6 -3
- package/src/interfaces/web/src/components/chat/MessageBubble.tsx +28 -25
- package/src/interfaces/web/src/components/chat/ModelPicker.tsx +19 -17
- package/src/interfaces/web/src/components/deck/WidgetRow.tsx +9 -7
- package/src/interfaces/web/src/components/inputs/VarTokenInput.tsx +21 -19
- package/src/interfaces/web/src/components/layout/ProjectSidebar.tsx +3 -2
- package/src/interfaces/web/src/components/routines/AvailableVarsCard.tsx +23 -0
- package/src/interfaces/web/src/components/routines/ExecutionsList.tsx +189 -0
- package/src/interfaces/web/src/components/routines/ReadOnlyBlock.tsx +14 -0
- package/src/interfaces/web/src/components/routines/RoutineDetail.tsx +86 -0
- package/src/interfaces/web/src/components/routines/RoutineEditor.tsx +263 -0
- package/src/interfaces/web/src/components/routines/RoutineList.tsx +59 -0
- package/src/interfaces/web/src/components/routines/VarTextarea.tsx +70 -0
- package/src/interfaces/web/src/components/routines/shared.ts +89 -0
- package/src/interfaces/web/src/components/settings/PairDeviceDialog.tsx +19 -16
- package/src/interfaces/web/src/components/settings/TelegramContactsPanel.tsx +10 -8
- package/src/interfaces/web/src/components/settings/providers/ProviderModal.tsx +7 -4
- package/src/interfaces/web/src/components/ui/chat-input.tsx +24 -21
- package/src/interfaces/web/src/components/ui/sidebar.tsx +20 -18
- package/src/interfaces/web/src/components/ui.tsx +4 -0
- package/src/interfaces/web/src/i18n/en.ts +34 -11
- package/src/interfaces/web/src/i18n/es.ts +34 -11
- package/src/interfaces/web/src/lib/api/filesystem.ts +6 -0
- package/src/interfaces/web/src/screens/ApxAdminScreen.tsx +11 -3
- package/src/interfaces/web/src/screens/modules/DeckScreen.tsx +6 -3
- package/src/interfaces/web/src/screens/project/AgentDetailScreen.tsx +8 -5
- package/src/interfaces/web/src/screens/project/McpsTab.tsx +16 -9
- package/src/interfaces/web/src/screens/project/RoutinesTab.tsx +126 -373
- package/src/interfaces/web/src/styles.css +5 -0
- package/src/interfaces/web/dist/assets/index-CAKEYko0.css +0 -1
- package/src/interfaces/web/dist/assets/index-UzqHxD0B.js +0 -639
- 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
|
|
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={
|
|
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
|
-
|
|
87
|
-
<div className="
|
|
88
|
-
<
|
|
89
|
-
|
|
90
|
-
<
|
|
91
|
-
<
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
87
|
-
<
|
|
88
|
-
|
|
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
|
-
<
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
<
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
<
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
<
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
<
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
e
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
+
}
|