@agentprojectcontext/apx 1.57.0 → 1.59.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 (60) hide show
  1. package/package.json +1 -1
  2. package/src/core/agent/skills/index.js +9 -0
  3. package/src/core/agent/skills/inspector.js +7 -2
  4. package/src/core/agent/skills/policy.js +128 -0
  5. package/src/core/agent/super-agent.js +8 -1
  6. package/src/core/agent/tools/handlers/list-skills.js +6 -3
  7. package/src/core/agent/tools/handlers/load-skill.js +9 -2
  8. package/src/core/apc/paths.js +7 -0
  9. package/src/core/stores/organization.js +152 -0
  10. package/src/core/stores/project-files.js +199 -0
  11. package/src/core/stores/tasks.js +36 -3
  12. package/src/host/daemon/api/agents.js +22 -2
  13. package/src/host/daemon/api/files-project.js +99 -0
  14. package/src/host/daemon/api/organization.js +88 -0
  15. package/src/host/daemon/api/shared.js +7 -0
  16. package/src/host/daemon/api/skills.js +301 -17
  17. package/src/host/daemon/api/tasks.js +14 -0
  18. package/src/host/daemon/api.js +4 -0
  19. package/src/interfaces/cli/commands/org.js +77 -0
  20. package/src/interfaces/cli/commands/skills.js +3 -2
  21. package/src/interfaces/cli/index.js +48 -0
  22. package/src/interfaces/web/dist/assets/index-CnQb4N6C.js +731 -0
  23. package/src/interfaces/web/dist/assets/index-CnQb4N6C.js.map +1 -0
  24. package/src/interfaces/web/dist/assets/index-Dv3X-zpx.css +1 -0
  25. package/src/interfaces/web/dist/index.html +2 -2
  26. package/src/interfaces/web/src/components/agents/AgentFormFields.tsx +123 -0
  27. package/src/interfaces/web/src/components/common/ConfirmDialog.tsx +51 -0
  28. package/src/interfaces/web/src/components/files/FileBrowser.tsx +138 -0
  29. package/src/interfaces/web/src/components/files/FileTree.tsx +133 -0
  30. package/src/interfaces/web/src/components/files/FileViewer.tsx +167 -0
  31. package/src/interfaces/web/src/components/files/MarkdownEditor.tsx +48 -0
  32. package/src/interfaces/web/src/components/files/MarkdownPreview.tsx +146 -0
  33. package/src/interfaces/web/src/components/files/NewFileDialog.tsx +66 -0
  34. package/src/interfaces/web/src/components/settings/SkillsManager.tsx +465 -0
  35. package/src/interfaces/web/src/components/settings/SkillsSettings.tsx +49 -0
  36. package/src/interfaces/web/src/components/structure/StructureDialogs.tsx +172 -0
  37. package/src/interfaces/web/src/components/tasks/TaskDetailPanel.tsx +142 -0
  38. package/src/interfaces/web/src/components/tasks/taskStatus.tsx +57 -0
  39. package/src/interfaces/web/src/i18n/en.ts +171 -0
  40. package/src/interfaces/web/src/i18n/es.ts +171 -0
  41. package/src/interfaces/web/src/lib/api/organization.ts +18 -0
  42. package/src/interfaces/web/src/lib/api/projectFiles.ts +19 -0
  43. package/src/interfaces/web/src/lib/api/skills.ts +79 -8
  44. package/src/interfaces/web/src/lib/api/tasks.ts +16 -1
  45. package/src/interfaces/web/src/lib/api.ts +2 -0
  46. package/src/interfaces/web/src/lib/slug.ts +11 -0
  47. package/src/interfaces/web/src/screens/ProjectScreen.tsx +25 -2
  48. package/src/interfaces/web/src/screens/SettingsScreen.tsx +3 -3
  49. package/src/interfaces/web/src/screens/project/AgentDetailScreen.tsx +31 -11
  50. package/src/interfaces/web/src/screens/project/AgentsTab.tsx +24 -7
  51. package/src/interfaces/web/src/screens/project/DocsTab.tsx +13 -0
  52. package/src/interfaces/web/src/screens/project/FilesTab.tsx +12 -0
  53. package/src/interfaces/web/src/screens/project/Overview.tsx +122 -10
  54. package/src/interfaces/web/src/screens/project/SkillsTab.tsx +13 -0
  55. package/src/interfaces/web/src/screens/project/StructureTab.tsx +147 -0
  56. package/src/interfaces/web/src/screens/project/TasksTab.tsx +101 -62
  57. package/src/interfaces/web/src/types/daemon.ts +63 -0
  58. package/src/interfaces/web/dist/assets/index-CEI8DfVg.css +0 -1
  59. package/src/interfaces/web/dist/assets/index-DJ-ocXOR.js +0 -651
  60. package/src/interfaces/web/dist/assets/index-DJ-ocXOR.js.map +0 -1
@@ -0,0 +1,66 @@
1
+ import { useState } from "react";
2
+ import { Dialog, Button, Field, Input } from "../ui";
3
+ import { useToast } from "../Toast";
4
+ import { ProjectFiles, type FileScope } from "../../lib/api/projectFiles";
5
+ import { t } from "../../i18n";
6
+
7
+ // Create a new document. The user types a path (folders allowed, like
8
+ // Appsi's work/<case>/… layout); we default a .md extension when none is given.
9
+ export function NewFileDialog({
10
+ open, onClose, pid, scope, onCreated,
11
+ }: {
12
+ open: boolean;
13
+ onClose: () => void;
14
+ pid: string;
15
+ scope: FileScope;
16
+ onCreated: (path: string) => void;
17
+ }) {
18
+ const toast = useToast();
19
+ const [path, setPath] = useState("");
20
+ const [saving, setSaving] = useState(false);
21
+
22
+ const create = async () => {
23
+ let rel = path.trim().replace(/^\/+/, "");
24
+ if (!rel) return;
25
+ if (!/\.[a-z0-9]+$/i.test(rel)) rel += ".md";
26
+ setSaving(true);
27
+ try {
28
+ await ProjectFiles.write(pid, rel, "", scope);
29
+ toast.success(t("files.created"));
30
+ setPath("");
31
+ onCreated(rel);
32
+ } catch (e) {
33
+ toast.error(e instanceof Error ? e.message : String(e));
34
+ } finally {
35
+ setSaving(false);
36
+ }
37
+ };
38
+
39
+ return (
40
+ <Dialog
41
+ open={open}
42
+ onClose={onClose}
43
+ title={t("files.new_doc")}
44
+ description={t("files.new_doc_hint")}
45
+ footer={
46
+ <>
47
+ <Button variant="ghost" onClick={onClose}>{t("common.cancel")}</Button>
48
+ <Button variant="primary" data-testid="new-file-create" onClick={() => void create()} loading={saving} disabled={!path.trim()}>
49
+ {t("files.create")}
50
+ </Button>
51
+ </>
52
+ }
53
+ >
54
+ <Field label={t("files.path_label")} hint={t("files.path_example")}>
55
+ <Input
56
+ autoFocus
57
+ data-testid="new-file-path"
58
+ value={path}
59
+ onChange={(e) => setPath(e.target.value)}
60
+ onKeyDown={(e) => { if (e.key === "Enter") void create(); }}
61
+ placeholder="cases/onboarding/spec.md"
62
+ />
63
+ </Field>
64
+ </Dialog>
65
+ );
66
+ }
@@ -0,0 +1,465 @@
1
+ import { useMemo, useRef, useState, type ReactNode } from "react";
2
+ import useSWR from "swr";
3
+ import {
4
+ ChevronDown, Code2, FileText, GitBranch, Lock, PencilLine,
5
+ Plus, RotateCcw, Trash2, Upload,
6
+ } from "lucide-react";
7
+ import { Button, Field, Input, Textarea, Switch, Badge, Loading, Tip, Dialog } from "../ui";
8
+ import { UiSelect } from "../UiSelect";
9
+ import {
10
+ DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem,
11
+ } from "../ui/dropdown-menu";
12
+ import { useToast } from "../Toast";
13
+ import { Skills, type SkillEntry } from "../../lib/api/skills";
14
+ import { Projects } from "../../lib/api/projects";
15
+ import { t } from "../../i18n";
16
+
17
+ // Claude-Desktop-style skills manager: a left list of installed skills + a right
18
+ // viewer that renders the selected SKILL.md, with per-scope on/off, delete, and
19
+ // an "Add" dropdown (create online / upload .zip / clone git repo).
20
+ //
21
+ // Scope: "default" = the super-agent baseline; a project path = that project.
22
+ // Reusable with a FIXED scope (embedded in a project screen) or a SELECTABLE
23
+ // scope (the global settings tab, which shows a scope picker).
24
+
25
+ const SUPER = "default";
26
+
27
+ function basename(p: string): string {
28
+ const parts = p.replace(/\/+$/, "").split("/");
29
+ return parts[parts.length - 1] || p;
30
+ }
31
+
32
+ function sourceBadge(source: string): { label: string; tone: "info" | "success" | "muted" } {
33
+ if (source === "builtin") return { label: t("skills_page.source_builtin"), tone: "info" };
34
+ if (source === "project") return { label: t("skills_page.source_project"), tone: "success" };
35
+ return { label: t("skills_page.source_global"), tone: "muted" };
36
+ }
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Minimal Markdown renderer (no dependency) — headings, lists, code, bold.
40
+ // ---------------------------------------------------------------------------
41
+
42
+ function inline(s: string): ReactNode {
43
+ const parts: ReactNode[] = [];
44
+ const re = /(\*\*[^*]+\*\*|`[^`]+`)/g;
45
+ let last = 0, m: RegExpExecArray | null, k = 0;
46
+ while ((m = re.exec(s))) {
47
+ if (m.index > last) parts.push(s.slice(last, m.index));
48
+ const tok = m[0];
49
+ if (tok.startsWith("**")) parts.push(<strong key={k++}>{tok.slice(2, -2)}</strong>);
50
+ else parts.push(<code key={k++} className="rounded bg-muted px-1 py-0.5 text-[0.85em]">{tok.slice(1, -1)}</code>);
51
+ last = m.index + tok.length;
52
+ }
53
+ if (last < s.length) parts.push(s.slice(last));
54
+ return parts;
55
+ }
56
+
57
+ function renderMarkdown(md: string): ReactNode {
58
+ const lines = md.split("\n");
59
+ const out: ReactNode[] = [];
60
+ let i = 0, key = 0;
61
+ while (i < lines.length) {
62
+ const line = lines[i];
63
+ if (line.trim().startsWith("```")) {
64
+ const buf: string[] = [];
65
+ i++;
66
+ while (i < lines.length && !lines[i].trim().startsWith("```")) { buf.push(lines[i]); i++; }
67
+ i++;
68
+ out.push(
69
+ <pre key={key++} className="my-2 overflow-x-auto rounded-md border border-border bg-muted/50 p-3 text-xs">
70
+ <code>{buf.join("\n")}</code>
71
+ </pre>,
72
+ );
73
+ continue;
74
+ }
75
+ const h = line.match(/^(#{1,4})\s+(.*)$/);
76
+ if (h) {
77
+ const lvl = h[1].length;
78
+ const cls = lvl === 1 ? "mt-4 mb-1 text-lg font-semibold"
79
+ : lvl === 2 ? "mt-3 mb-1 text-base font-semibold"
80
+ : "mt-2 mb-0.5 text-sm font-semibold";
81
+ out.push(<div key={key++} className={cls}>{inline(h[2])}</div>);
82
+ i++; continue;
83
+ }
84
+ if (/^\s*[-*]\s+/.test(line)) {
85
+ const items: ReactNode[] = [];
86
+ while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) {
87
+ items.push(<li key={items.length}>{inline(lines[i].replace(/^\s*[-*]\s+/, ""))}</li>);
88
+ i++;
89
+ }
90
+ out.push(<ul key={key++} className="my-1 list-disc space-y-0.5 pl-5 text-sm">{items}</ul>);
91
+ continue;
92
+ }
93
+ if (line.trim() === "") { i++; continue; }
94
+ const buf: string[] = [];
95
+ while (
96
+ i < lines.length && lines[i].trim() !== "" &&
97
+ !/^(#{1,4})\s/.test(lines[i]) && !/^\s*[-*]\s+/.test(lines[i]) &&
98
+ !lines[i].trim().startsWith("```")
99
+ ) { buf.push(lines[i]); i++; }
100
+ out.push(<p key={key++} className="my-1.5 text-sm leading-relaxed">{inline(buf.join(" "))}</p>);
101
+ }
102
+ return out;
103
+ }
104
+
105
+ function fileToBase64(file: File): Promise<string> {
106
+ return new Promise((resolve, reject) => {
107
+ const r = new FileReader();
108
+ r.onload = () => resolve(String(r.result).replace(/^data:.*;base64,/, ""));
109
+ r.onerror = () => reject(new Error("read failed"));
110
+ r.readAsDataURL(file);
111
+ });
112
+ }
113
+
114
+ // ---------------------------------------------------------------------------
115
+ // Main
116
+ // ---------------------------------------------------------------------------
117
+
118
+ export function SkillsManager({ scope: fixedScope, selectable = false }: { scope?: string; selectable?: boolean }) {
119
+ const toast = useToast();
120
+ const [scopeState, setScopeState] = useState<string>(fixedScope ?? SUPER);
121
+ const scope = fixedScope ?? scopeState;
122
+ const projectPath = scope === SUPER ? undefined : scope;
123
+
124
+ const [busy, setBusy] = useState(false);
125
+ const [picked, setPicked] = useState<string | null>(null);
126
+ const [view, setView] = useState<"preview" | "source">("preview");
127
+ const [createOpen, setCreateOpen] = useState(false);
128
+ const [repoOpen, setRepoOpen] = useState(false);
129
+ const fileRef = useRef<HTMLInputElement>(null);
130
+
131
+ const { data: projects } = useSWR(selectable ? "/projects" : null, () => Projects.list());
132
+ const scopeOptions = useMemo(() => [
133
+ { value: SUPER, label: t("skills_page.scope_super_agent") },
134
+ ...(projects ?? []).map((p) => ({ value: p.path, label: p.name || basename(p.path) })),
135
+ ], [projects]);
136
+
137
+ const { data, mutate, isLoading } = useSWR(["/skills", scope], () => Skills.list(projectPath));
138
+ const skills = useMemo(() => data?.skills ?? [], [data]);
139
+ const onCount = skills.filter((s) => s.enabled !== false).length;
140
+
141
+ // Derived selection: keep the picked slug if still present, else first.
142
+ const selected = picked && skills.some((s) => s.slug === picked) ? picked : (skills[0]?.slug ?? null);
143
+ const { data: detail } = useSWR(
144
+ selected ? ["/skill-detail", scope, selected] : null,
145
+ () => Skills.detail(selected!, projectPath),
146
+ );
147
+
148
+ const setEnabled = async (slug: string, enabled: boolean | null) => {
149
+ setBusy(true);
150
+ try { await Skills.setEnabled({ slug, enabled, scope }); await mutate(); }
151
+ catch (e) { toast.error(t("skills_page.toggle_failed", { msg: (e as Error).message })); }
152
+ finally { setBusy(false); }
153
+ };
154
+
155
+ const remove = async (slug: string) => {
156
+ if (!window.confirm(t("skills_page.delete_confirm", { slug }))) return;
157
+ setBusy(true);
158
+ try {
159
+ await Skills.remove(slug, projectPath);
160
+ toast.success(t("skills_page.deleted_ok", { slug }));
161
+ if (picked === slug) setPicked(null);
162
+ await mutate();
163
+ } catch (e) { toast.error(t("skills_page.delete_failed", { msg: (e as Error).message })); }
164
+ finally { setBusy(false); }
165
+ };
166
+
167
+ const afterAdd = async (slug: string, okMsg: string) => {
168
+ toast.success(okMsg);
169
+ setPicked(slug);
170
+ await mutate();
171
+ };
172
+
173
+ const createSkill = async (slug: string, description: string, body: string) => {
174
+ await Skills.create({ slug, description, body, project_path: projectPath });
175
+ await afterAdd(slug, t("skills_page.created_ok", { slug }));
176
+ };
177
+ const importRepo = async (url: string) => {
178
+ const r = await Skills.importRepo({ url, project_path: projectPath });
179
+ await afterAdd(r.slug, t("skills_page.imported_ok", { slug: r.slug }));
180
+ };
181
+ const onPickZip = async (file: File | undefined) => {
182
+ if (!file) return;
183
+ setBusy(true);
184
+ try {
185
+ const data64 = await fileToBase64(file);
186
+ const r = await Skills.importZip({ data: data64, project_path: projectPath });
187
+ await afterAdd(r.slug, t("skills_page.imported_ok", { slug: r.slug }));
188
+ } catch (e) { toast.error(t("skills_page.import_failed", { msg: (e as Error).message })); }
189
+ finally { setBusy(false); if (fileRef.current) fileRef.current.value = ""; }
190
+ };
191
+
192
+ return (
193
+ <div className="space-y-4">
194
+ {/* Header: scope + count + Add */}
195
+ <div className="flex flex-wrap items-center justify-between gap-3">
196
+ <div className="flex flex-wrap items-center gap-3">
197
+ {selectable ? (
198
+ <div className="w-64">
199
+ <UiSelect value={scope} onChange={(v) => { setScopeState(v); setPicked(null); }}
200
+ options={scopeOptions} placeholder={t("skills_page.scope_ph")} />
201
+ </div>
202
+ ) : null}
203
+ <Badge tone="muted">{t("skills_page.count_label", { n: skills.length, on: onCount })}</Badge>
204
+ </div>
205
+
206
+ <DropdownMenu>
207
+ <DropdownMenuTrigger
208
+ disabled={busy}
209
+ className="inline-flex h-9 items-center gap-1.5 rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50"
210
+ >
211
+ <Plus size={15} /> {t("skills_page.add_menu")} <ChevronDown size={14} />
212
+ </DropdownMenuTrigger>
213
+ <DropdownMenuContent align="end" sideOffset={6} className="w-72">
214
+ <DropdownMenuItem onClick={() => setCreateOpen(true)}>
215
+ <PencilLine size={15} className="text-muted-fg" />
216
+ <AddItemLabel title={t("skills_page.add_online")} hint={t("skills_page.add_online_hint")} />
217
+ </DropdownMenuItem>
218
+ <DropdownMenuItem onClick={() => fileRef.current?.click()}>
219
+ <Upload size={15} className="text-muted-fg" />
220
+ <AddItemLabel title={t("skills_page.add_zip")} hint={t("skills_page.add_zip_hint")} />
221
+ </DropdownMenuItem>
222
+ <DropdownMenuItem onClick={() => setRepoOpen(true)}>
223
+ <GitBranch size={15} className="text-muted-fg" />
224
+ <AddItemLabel title={t("skills_page.add_repo")} hint={t("skills_page.add_repo_hint")} />
225
+ </DropdownMenuItem>
226
+ </DropdownMenuContent>
227
+ </DropdownMenu>
228
+ <input ref={fileRef} type="file" accept=".zip" className="hidden"
229
+ onChange={(e) => onPickZip(e.target.files?.[0])} />
230
+ </div>
231
+
232
+ {/* Body: list + viewer */}
233
+ {isLoading || !data ? (
234
+ <Loading />
235
+ ) : (
236
+ <div className="grid min-h-[62vh] gap-4 lg:grid-cols-[20rem_1fr]">
237
+ {/* List */}
238
+ <div className="overflow-hidden rounded-xl border border-border bg-card">
239
+ <ul className="max-h-[62vh] divide-y divide-border overflow-y-auto">
240
+ {skills.length === 0 ? (
241
+ <li className="px-3 py-4 text-sm text-muted-fg">{t("skills_page.empty")}</li>
242
+ ) : skills.map((s) => (
243
+ <SkillRow key={s.slug} skill={s} active={s.slug === selected} busy={busy}
244
+ onSelect={() => setPicked(s.slug)}
245
+ onToggle={(v) => setEnabled(s.slug, v)} />
246
+ ))}
247
+ </ul>
248
+ </div>
249
+
250
+ {/* Viewer */}
251
+ <div className="min-w-0 overflow-hidden rounded-xl border border-border bg-card">
252
+ {!selected ? (
253
+ <div className="grid h-full place-items-center p-8 text-sm text-muted-fg">
254
+ {t("skills_page.select_a_skill")}
255
+ </div>
256
+ ) : !detail ? (
257
+ <div className="p-6"><Loading /></div>
258
+ ) : (
259
+ <div className="flex h-full max-h-[62vh] flex-col">
260
+ {/* Viewer header */}
261
+ <div className="flex items-start justify-between gap-3 border-b border-border px-5 py-4">
262
+ <div className="min-w-0">
263
+ <div className="flex flex-wrap items-center gap-2">
264
+ <code className="text-sm font-semibold">{detail.slug}</code>
265
+ {(() => { const b = sourceBadge(detail.source); return <Badge tone={b.tone}>{b.label}</Badge>; })()}
266
+ {detail.private && (
267
+ <span className="inline-flex items-center gap-1 text-xs text-muted-fg">
268
+ <Lock size={11} /> {t("skills_page.private_badge")}
269
+ </span>
270
+ )}
271
+ </div>
272
+ {detail.description && (
273
+ <p className="mt-1 text-sm text-muted-fg">{detail.description}</p>
274
+ )}
275
+ <div className="mt-2 flex flex-wrap gap-x-6 gap-y-1 text-xs text-muted-fg">
276
+ <span>{t("skills_page.added_by")}: <span className="text-foreground">
277
+ {detail.private ? t("skills_page.by_apx") : t("skills_page.by_you")}</span></span>
278
+ <span>{t("skills_page.activator")}: <span className="text-foreground">
279
+ {t("skills_page.activator_value")}</span></span>
280
+ </div>
281
+ </div>
282
+ <div className="flex shrink-0 items-center gap-2">
283
+ {(detail.source === "global" || detail.source === "project") && (
284
+ <Tip content={t("skills_page.delete_btn")}>
285
+ <Button variant="ghost" size="sm" disabled={busy}
286
+ onClick={() => remove(detail.slug)} aria-label={t("skills_page.delete_btn")}>
287
+ <Trash2 size={14} />
288
+ </Button>
289
+ </Tip>
290
+ )}
291
+ <Switch checked={detail.private ? true : detail.enabled}
292
+ disabled={busy || detail.private}
293
+ onChange={(v) => setEnabled(detail.slug, v)}
294
+ label={(detail.private ? true : detail.enabled) ? t("skills_page.on") : t("skills_page.off")} />
295
+ </div>
296
+ </div>
297
+
298
+ {/* View toggle */}
299
+ <div className="flex items-center gap-1 border-b border-border px-4 py-2">
300
+ <ViewTab active={view === "preview"} onClick={() => setView("preview")}
301
+ icon={FileText} label={t("skills_page.tab_preview")} />
302
+ <ViewTab active={view === "source"} onClick={() => setView("source")}
303
+ icon={Code2} label={t("skills_page.tab_source")} />
304
+ </div>
305
+
306
+ {/* Body */}
307
+ <div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
308
+ {view === "preview" ? (
309
+ <div className="prose-none">{renderMarkdown(detail.body || "")}</div>
310
+ ) : (
311
+ <pre className="overflow-x-auto whitespace-pre-wrap break-words text-xs leading-relaxed text-muted-fg">
312
+ {detail.body}
313
+ </pre>
314
+ )}
315
+ </div>
316
+ </div>
317
+ )}
318
+ </div>
319
+ </div>
320
+ )}
321
+
322
+ {/* Dialogs */}
323
+ <CreateDialog open={createOpen} onClose={() => setCreateOpen(false)}
324
+ onCreate={createSkill} />
325
+ <RepoDialog open={repoOpen} onClose={() => setRepoOpen(false)}
326
+ onImport={importRepo} />
327
+ </div>
328
+ );
329
+ }
330
+
331
+ // ---------------------------------------------------------------------------
332
+ // Sub-components
333
+ // ---------------------------------------------------------------------------
334
+
335
+ function AddItemLabel({ title, hint }: { title: string; hint: string }) {
336
+ return (
337
+ <span className="flex min-w-0 flex-col leading-tight">
338
+ <span className="font-medium">{title}</span>
339
+ <span className="text-[11px] text-muted-fg">{hint}</span>
340
+ </span>
341
+ );
342
+ }
343
+
344
+ function ViewTab({ active, onClick, icon: Icon, label }: {
345
+ active: boolean; onClick: () => void; icon: React.ElementType; label: string;
346
+ }) {
347
+ return (
348
+ <button type="button" onClick={onClick}
349
+ className={`inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition ${
350
+ active ? "bg-accent text-foreground" : "text-muted-fg hover:text-foreground"}`}>
351
+ <Icon size={13} /> {label}
352
+ </button>
353
+ );
354
+ }
355
+
356
+ function SkillRow({ skill, active, busy, onSelect, onToggle }: {
357
+ skill: SkillEntry; active: boolean; busy: boolean;
358
+ onSelect: () => void; onToggle: (v: boolean) => void;
359
+ }) {
360
+ const b = sourceBadge(skill.source);
361
+ const enabled = skill.private ? true : skill.enabled !== false;
362
+ return (
363
+ <li>
364
+ <div className={`flex items-center gap-2 px-3 py-2.5 ${active ? "bg-accent/50" : "hover:bg-accent/25"}`}>
365
+ <button type="button" onClick={onSelect} className="min-w-0 flex-1 text-left">
366
+ <div className="flex items-center gap-1.5">
367
+ <code className="truncate text-[13px] font-medium">{skill.slug}</code>
368
+ {skill.private && <Lock size={10} className="shrink-0 text-muted-fg" />}
369
+ </div>
370
+ <div className="mt-0.5 flex items-center gap-1.5">
371
+ <Badge tone={b.tone}>{b.label}</Badge>
372
+ {skill.overridden && <Badge tone="warning">{t("skills_page.overridden_badge")}</Badge>}
373
+ </div>
374
+ </button>
375
+ <Switch checked={enabled} disabled={busy || skill.private} onChange={onToggle} />
376
+ </div>
377
+ </li>
378
+ );
379
+ }
380
+
381
+ function CreateDialog({ open, onClose, onCreate }: {
382
+ open: boolean; onClose: () => void;
383
+ onCreate: (slug: string, description: string, body: string) => Promise<void>;
384
+ }) {
385
+ const toast = useToast();
386
+ const [slug, setSlug] = useState("");
387
+ const [description, setDescription] = useState("");
388
+ const [body, setBody] = useState("");
389
+ const [busy, setBusy] = useState(false);
390
+ const valid = /^[a-z0-9][a-z0-9-]*$/.test(slug);
391
+
392
+ const reset = () => { setSlug(""); setDescription(""); setBody(""); };
393
+ const submit = async () => {
394
+ if (!valid) return;
395
+ setBusy(true);
396
+ try { await onCreate(slug, description, body); reset(); onClose(); }
397
+ catch (e) { toast.error(t("skills_page.create_failed", { msg: (e as Error).message })); }
398
+ finally { setBusy(false); }
399
+ };
400
+
401
+ return (
402
+ <Dialog open={open} onClose={onClose} title={t("skills_page.create_dialog_title")}
403
+ description={t("skills_page.add_desc")} size="lg"
404
+ footer={
405
+ <>
406
+ <Button variant="ghost" onClick={onClose} disabled={busy}>{t("skills_page.cancel")}</Button>
407
+ <Button variant="primary" onClick={submit} disabled={busy || !valid} loading={busy}>
408
+ <Plus size={14} /> {t("skills_page.add_btn")}
409
+ </Button>
410
+ </>
411
+ }>
412
+ <div className="space-y-4">
413
+ <div className="grid gap-4 sm:grid-cols-2">
414
+ <Field label={t("skills_page.add_slug_label")}>
415
+ <Input value={slug} placeholder={t("skills_page.add_slug_ph")} disabled={busy}
416
+ onChange={(e) => setSlug(e.target.value.toLowerCase())} />
417
+ </Field>
418
+ <Field label={t("skills_page.add_desc_label")}>
419
+ <Input value={description} placeholder={t("skills_page.add_desc_ph")} disabled={busy}
420
+ onChange={(e) => setDescription(e.target.value)} />
421
+ </Field>
422
+ </div>
423
+ <Field label={t("skills_page.add_body_label")}>
424
+ <Textarea value={body} placeholder={t("skills_page.add_body_ph")} disabled={busy} rows={10}
425
+ onChange={(e) => setBody(e.target.value)} />
426
+ </Field>
427
+ </div>
428
+ </Dialog>
429
+ );
430
+ }
431
+
432
+ function RepoDialog({ open, onClose, onImport }: {
433
+ open: boolean; onClose: () => void; onImport: (url: string) => Promise<void>;
434
+ }) {
435
+ const toast = useToast();
436
+ const [url, setUrl] = useState("");
437
+ const [busy, setBusy] = useState(false);
438
+ const valid = /^(https?:\/\/|git@|ssh:\/\/|git:\/\/)\S+$/.test(url.trim());
439
+
440
+ const submit = async () => {
441
+ if (!valid) return;
442
+ setBusy(true);
443
+ try { await onImport(url.trim()); setUrl(""); onClose(); }
444
+ catch (e) { toast.error(t("skills_page.import_failed", { msg: (e as Error).message })); }
445
+ finally { setBusy(false); }
446
+ };
447
+
448
+ return (
449
+ <Dialog open={open} onClose={onClose} title={t("skills_page.repo_dialog_title")} size="md"
450
+ footer={
451
+ <>
452
+ <Button variant="ghost" onClick={onClose} disabled={busy}>{t("skills_page.cancel")}</Button>
453
+ <Button variant="primary" onClick={submit} disabled={busy || !valid} loading={busy}>
454
+ <GitBranch size={14} /> {t("skills_page.import_btn")}
455
+ </Button>
456
+ </>
457
+ }>
458
+ <Field label={t("skills_page.repo_url_label")} hint={t("skills_page.repo_url_hint")}>
459
+ <Input value={url} placeholder={t("skills_page.repo_url_ph")} disabled={busy}
460
+ onChange={(e) => setUrl(e.target.value)}
461
+ onKeyDown={(e) => { if (e.key === "Enter") submit(); }} />
462
+ </Field>
463
+ </Dialog>
464
+ );
465
+ }
@@ -0,0 +1,49 @@
1
+ import { useSearchParams } from "react-router-dom";
2
+ import { Sparkles, SlidersHorizontal } from "lucide-react";
3
+ import { SkillsManager } from "./SkillsManager";
4
+ import { SkillsInspectorPanel } from "./SkillsInspectorPanel";
5
+ import { t } from "../../i18n";
6
+
7
+ // One settings entry ("Skills") with two inner tabs, deep-linkable via ?tab=:
8
+ // ?tab=manager (default) → the scope-aware skills manager
9
+ // ?tab=rag → the Skill Inspector (per-turn RAG) config
10
+ type Tab = "manager" | "rag";
11
+
12
+ export function SkillsSettings() {
13
+ const [params, setParams] = useSearchParams();
14
+ const tab: Tab = params.get("tab") === "rag" ? "rag" : "manager";
15
+
16
+ const setTab = (v: Tab) => {
17
+ const next = new URLSearchParams(params);
18
+ next.set("tab", v);
19
+ setParams(next, { replace: true });
20
+ };
21
+
22
+ return (
23
+ <div className="space-y-5">
24
+ <div className="flex items-center gap-1 border-b border-border">
25
+ <SubTab active={tab === "manager"} onClick={() => setTab("manager")}
26
+ icon={Sparkles} label={t("skills_page.manager_tab")} />
27
+ <SubTab active={tab === "rag"} onClick={() => setTab("rag")}
28
+ icon={SlidersHorizontal} label={t("skills_page.rag_tab")} />
29
+ </div>
30
+
31
+ {tab === "manager" ? <SkillsManager selectable /> : <SkillsInspectorPanel />}
32
+ </div>
33
+ );
34
+ }
35
+
36
+ function SubTab({ active, onClick, icon: Icon, label }: {
37
+ active: boolean; onClick: () => void; icon: React.ElementType; label: string;
38
+ }) {
39
+ return (
40
+ <button type="button" onClick={onClick}
41
+ className={`-mb-px inline-flex items-center gap-1.5 border-b-2 px-3 py-2 text-sm font-medium transition ${
42
+ active
43
+ ? "border-foreground text-foreground"
44
+ : "border-transparent text-muted-fg hover:text-foreground"
45
+ }`}>
46
+ <Icon size={15} /> {label}
47
+ </button>
48
+ );
49
+ }