@agentprojectcontext/apx 1.39.1 → 1.40.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/package.json +1 -1
  2. package/src/core/agent/constants.js +7 -1
  3. package/src/core/agent/retry.js +9 -0
  4. package/src/core/agent/run-agent.js +56 -5
  5. package/src/core/agent/tools/pseudo-tools.js +13 -1
  6. package/src/core/channels/telegram/dispatch.js +23 -3
  7. package/src/core/engines/mock.js +33 -10
  8. package/src/core/i18n/en.js +2 -4
  9. package/src/core/i18n/es.js +1 -4
  10. package/src/core/i18n/index.js +5 -1
  11. package/src/core/i18n/pt.js +1 -3
  12. package/src/core/routines/runner.js +15 -3
  13. package/src/host/daemon/api/admin.js +29 -0
  14. package/src/interfaces/cli/commands/desktop.js +26 -0
  15. package/src/interfaces/cli/index.js +16 -3
  16. package/src/interfaces/desktop/main.js +7 -1
  17. package/src/interfaces/web/dist/assets/index-DW7j3cXB.js +646 -0
  18. package/src/interfaces/web/dist/assets/index-DW7j3cXB.js.map +1 -0
  19. package/src/interfaces/web/dist/assets/index-wrEbTJbc.css +1 -0
  20. package/src/interfaces/web/dist/index.html +2 -2
  21. package/src/interfaces/web/package-lock.json +188 -188
  22. package/src/interfaces/web/src/App.tsx +22 -11
  23. package/src/interfaces/web/src/components/AddProjectDialog.tsx +66 -34
  24. package/src/interfaces/web/src/components/ModelCombobox.tsx +6 -3
  25. package/src/interfaces/web/src/components/chat/MessageBubble.tsx +28 -25
  26. package/src/interfaces/web/src/components/chat/ModelPicker.tsx +19 -17
  27. package/src/interfaces/web/src/components/deck/WidgetRow.tsx +9 -7
  28. package/src/interfaces/web/src/components/inputs/VarTokenInput.tsx +21 -19
  29. package/src/interfaces/web/src/components/layout/ProjectSidebar.tsx +3 -2
  30. package/src/interfaces/web/src/components/routines/AvailableVarsCard.tsx +23 -0
  31. package/src/interfaces/web/src/components/routines/ExecutionsList.tsx +189 -0
  32. package/src/interfaces/web/src/components/routines/ReadOnlyBlock.tsx +14 -0
  33. package/src/interfaces/web/src/components/routines/RoutineDetail.tsx +86 -0
  34. package/src/interfaces/web/src/components/routines/RoutineEditor.tsx +263 -0
  35. package/src/interfaces/web/src/components/routines/RoutineList.tsx +59 -0
  36. package/src/interfaces/web/src/components/routines/VarTextarea.tsx +70 -0
  37. package/src/interfaces/web/src/components/routines/shared.ts +89 -0
  38. package/src/interfaces/web/src/components/settings/PairDeviceDialog.tsx +19 -16
  39. package/src/interfaces/web/src/components/settings/TelegramContactsPanel.tsx +10 -8
  40. package/src/interfaces/web/src/components/settings/providers/ProviderModal.tsx +7 -4
  41. package/src/interfaces/web/src/components/ui/chat-input.tsx +24 -21
  42. package/src/interfaces/web/src/components/ui/sidebar.tsx +20 -18
  43. package/src/interfaces/web/src/components/ui.tsx +4 -0
  44. package/src/interfaces/web/src/i18n/en.ts +34 -11
  45. package/src/interfaces/web/src/i18n/es.ts +34 -11
  46. package/src/interfaces/web/src/lib/api/filesystem.ts +6 -0
  47. package/src/interfaces/web/src/screens/ApxAdminScreen.tsx +11 -3
  48. package/src/interfaces/web/src/screens/modules/DeckScreen.tsx +6 -3
  49. package/src/interfaces/web/src/screens/project/AgentDetailScreen.tsx +8 -5
  50. package/src/interfaces/web/src/screens/project/McpsTab.tsx +16 -9
  51. package/src/interfaces/web/src/screens/project/RoutinesTab.tsx +126 -373
  52. package/src/interfaces/web/src/styles.css +5 -0
  53. package/src/interfaces/web/dist/assets/index-CAKEYko0.css +0 -1
  54. package/src/interfaces/web/dist/assets/index-UzqHxD0B.js +0 -639
  55. package/src/interfaces/web/dist/assets/index-UzqHxD0B.js.map +0 -1
@@ -15,6 +15,7 @@ import { RobyBubble } from "./components/RobyBubble";
15
15
  import { Roby, RobyEmpty, type RobyMood } from "./components/Roby";
16
16
  import { ToastProvider } from "./components/Toast";
17
17
  import { Button } from "./components/ui/button";
18
+ import { Tip } from "./components/ui/tip";
18
19
  import { TooltipProvider } from "./components/ui/tooltip";
19
20
  import { useTheme } from "./hooks/useTheme";
20
21
  import { useProjects } from "./hooks/useProjects";
@@ -51,7 +52,7 @@ export function App() {
51
52
 
52
53
  return (
53
54
  <ToastProvider>
54
- <TooltipProvider delay={300}>
55
+ <TooltipProvider delay={0}>
55
56
  <Shell />
56
57
  </TooltipProvider>
57
58
  </ToastProvider>
@@ -71,11 +72,20 @@ function Shell() {
71
72
  next.delete("action");
72
73
  setParams(next, { replace: true });
73
74
  };
75
+ const openAdd = () => {
76
+ const next = new URLSearchParams(params);
77
+ next.set("action", "add-project");
78
+ setParams(next);
79
+ };
74
80
 
75
81
  return (
76
82
  <NavCollapseProvider>
77
83
  <div className="flex h-screen w-screen overflow-hidden bg-background text-foreground" data-testid="app-shell">
78
- <ProjectSidebar onSelect={(href) => navigate(href)} onOpenRoby={() => setRobyOpen(true)} />
84
+ <ProjectSidebar
85
+ onSelect={(href) => navigate(href)}
86
+ onOpenRoby={() => setRobyOpen(true)}
87
+ onOpenAddProject={openAdd}
88
+ />
79
89
  <main className="m-2 ml-0 flex min-w-0 flex-1 flex-col overflow-hidden rounded-xl border border-border bg-card shadow-sm">
80
90
  <TopBar onToggleTheme={toggle} isDark={theme === "dark"} pathname={location.pathname} />
81
91
  <div className="flex-1 overflow-y-auto">
@@ -151,15 +161,16 @@ function TopBar({
151
161
  </span>
152
162
  {pageActions}
153
163
  <LanguageMenu />
154
- <button
155
- type="button"
156
- data-testid="theme-toggle"
157
- onClick={onToggleTheme}
158
- title={isDark ? t("topbar.light") : t("topbar.dark")}
159
- className="shrink-0 rounded-md p-1.5 text-muted-fg hover:bg-accent hover:text-accent-fg"
160
- >
161
- {isDark ? <Sun size={14} /> : <Moon size={14} />}
162
- </button>
164
+ <Tip content={isDark ? t("topbar.light") : t("topbar.dark")}>
165
+ <button
166
+ type="button"
167
+ data-testid="theme-toggle"
168
+ onClick={onToggleTheme}
169
+ className="shrink-0 rounded-md p-1.5 text-muted-fg hover:bg-accent hover:text-accent-fg"
170
+ >
171
+ {isDark ? <Sun size={14} /> : <Moon size={14} />}
172
+ </button>
173
+ </Tip>
163
174
  </header>
164
175
  );
165
176
  }
@@ -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
+ }