claude-smart 0.2.22 → 0.2.24

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 (113) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/README.md +69 -27
  3. package/bin/claude-smart.js +296 -11
  4. package/package.json +11 -1
  5. package/plugin/.claude-plugin/plugin.json +17 -0
  6. package/plugin/.codex-plugin/plugin.json +35 -0
  7. package/plugin/LICENSE +202 -0
  8. package/plugin/README.md +37 -0
  9. package/plugin/bin/cs-cite +77 -0
  10. package/plugin/commands/clear-all.md +8 -0
  11. package/plugin/commands/dashboard.md +8 -0
  12. package/plugin/commands/learn.md +12 -0
  13. package/plugin/commands/restart.md +8 -0
  14. package/plugin/commands/show.md +8 -0
  15. package/plugin/dashboard/AGENTS.md +6 -0
  16. package/plugin/dashboard/app/api/claude-settings/route.ts +19 -0
  17. package/plugin/dashboard/app/api/config/route.ts +16 -0
  18. package/plugin/dashboard/app/api/health/route.ts +10 -0
  19. package/plugin/dashboard/app/api/reflexio/[...path]/route.ts +63 -0
  20. package/plugin/dashboard/app/api/sessions/[id]/route.ts +28 -0
  21. package/plugin/dashboard/app/api/sessions/route.ts +14 -0
  22. package/plugin/dashboard/app/configure/env/page.tsx +318 -0
  23. package/plugin/dashboard/app/configure/layout.tsx +47 -0
  24. package/plugin/dashboard/app/configure/page.tsx +5 -0
  25. package/plugin/dashboard/app/configure/server/page.tsx +258 -0
  26. package/plugin/dashboard/app/dashboard/page.tsx +227 -0
  27. package/plugin/dashboard/app/globals.css +129 -0
  28. package/plugin/dashboard/app/icon.png +0 -0
  29. package/plugin/dashboard/app/layout.tsx +40 -0
  30. package/plugin/dashboard/app/page.tsx +5 -0
  31. package/plugin/dashboard/app/preferences/[id]/page.tsx +531 -0
  32. package/plugin/dashboard/app/preferences/page.tsx +126 -0
  33. package/plugin/dashboard/app/providers.tsx +12 -0
  34. package/plugin/dashboard/app/sessions/[sessionId]/page.tsx +321 -0
  35. package/plugin/dashboard/app/sessions/page.tsx +186 -0
  36. package/plugin/dashboard/app/skills/page.tsx +362 -0
  37. package/plugin/dashboard/app/skills/project/[id]/page.tsx +597 -0
  38. package/plugin/dashboard/app/skills/shared/[id]/page.tsx +830 -0
  39. package/plugin/dashboard/components/common/delete-all-button.tsx +45 -0
  40. package/plugin/dashboard/components/common/empty-state.tsx +34 -0
  41. package/plugin/dashboard/components/common/learnings-badge.tsx +34 -0
  42. package/plugin/dashboard/components/common/page-header.tsx +34 -0
  43. package/plugin/dashboard/components/common/page-tabs.tsx +115 -0
  44. package/plugin/dashboard/components/common/stat-card.tsx +38 -0
  45. package/plugin/dashboard/components/layout/nav-items.ts +22 -0
  46. package/plugin/dashboard/components/layout/sidebar.tsx +45 -0
  47. package/plugin/dashboard/components/layout/top-bar.tsx +64 -0
  48. package/plugin/dashboard/components/stall-banner.tsx +53 -0
  49. package/plugin/dashboard/components/ui/badge.tsx +52 -0
  50. package/plugin/dashboard/components/ui/button.tsx +60 -0
  51. package/plugin/dashboard/components/ui/collapsible.tsx +21 -0
  52. package/plugin/dashboard/components/ui/input.tsx +20 -0
  53. package/plugin/dashboard/components/ui/label.tsx +20 -0
  54. package/plugin/dashboard/components/ui/scroll-area.tsx +55 -0
  55. package/plugin/dashboard/components/ui/select.tsx +201 -0
  56. package/plugin/dashboard/components/ui/separator.tsx +25 -0
  57. package/plugin/dashboard/components/ui/sheet.tsx +135 -0
  58. package/plugin/dashboard/components/ui/switch.tsx +32 -0
  59. package/plugin/dashboard/components.json +25 -0
  60. package/plugin/dashboard/eslint.config.mjs +16 -0
  61. package/plugin/dashboard/hooks/use-settings.tsx +88 -0
  62. package/plugin/dashboard/hooks/use-stall-state.ts +59 -0
  63. package/plugin/dashboard/lib/claude-settings-file.ts +114 -0
  64. package/plugin/dashboard/lib/config-file.ts +131 -0
  65. package/plugin/dashboard/lib/format.ts +58 -0
  66. package/plugin/dashboard/lib/reflexio-client.ts +238 -0
  67. package/plugin/dashboard/lib/reflexio-url.ts +17 -0
  68. package/plugin/dashboard/lib/session-reader.ts +245 -0
  69. package/plugin/dashboard/lib/status.ts +24 -0
  70. package/plugin/dashboard/lib/types.ts +145 -0
  71. package/plugin/dashboard/lib/utils.ts +6 -0
  72. package/plugin/dashboard/next.config.ts +7 -0
  73. package/plugin/dashboard/package-lock.json +10275 -0
  74. package/plugin/dashboard/package.json +37 -0
  75. package/plugin/dashboard/postcss.config.mjs +7 -0
  76. package/plugin/dashboard/public/claude-smart-icon.png +0 -0
  77. package/plugin/dashboard/tsconfig.json +34 -0
  78. package/plugin/hooks/codex-hooks.json +67 -0
  79. package/plugin/hooks/hooks.json +111 -0
  80. package/plugin/pyproject.toml +49 -0
  81. package/plugin/scripts/_codex_env.sh +27 -0
  82. package/plugin/scripts/_lib.sh +325 -0
  83. package/plugin/scripts/backend-service.sh +208 -0
  84. package/plugin/scripts/cli.sh +40 -0
  85. package/plugin/scripts/dashboard-build.sh +139 -0
  86. package/plugin/scripts/dashboard-open.sh +107 -0
  87. package/plugin/scripts/dashboard-service.sh +195 -0
  88. package/plugin/scripts/ensure-plugin-root.sh +84 -0
  89. package/plugin/scripts/hook_entry.sh +70 -0
  90. package/plugin/scripts/smart-install.sh +411 -0
  91. package/plugin/src/claude_smart/__init__.py +3 -0
  92. package/plugin/src/claude_smart/cli.py +1273 -0
  93. package/plugin/src/claude_smart/context_format.py +277 -0
  94. package/plugin/src/claude_smart/context_inject.py +92 -0
  95. package/plugin/src/claude_smart/cs_cite.py +236 -0
  96. package/plugin/src/claude_smart/events/__init__.py +1 -0
  97. package/plugin/src/claude_smart/events/post_tool.py +148 -0
  98. package/plugin/src/claude_smart/events/pre_tool.py +52 -0
  99. package/plugin/src/claude_smart/events/session_end.py +20 -0
  100. package/plugin/src/claude_smart/events/session_start.py +119 -0
  101. package/plugin/src/claude_smart/events/stop.py +393 -0
  102. package/plugin/src/claude_smart/events/user_prompt.py +73 -0
  103. package/plugin/src/claude_smart/hook.py +114 -0
  104. package/plugin/src/claude_smart/ids.py +56 -0
  105. package/plugin/src/claude_smart/internal_call.py +89 -0
  106. package/plugin/src/claude_smart/optimizer_assistant.py +203 -0
  107. package/plugin/src/claude_smart/publish.py +71 -0
  108. package/plugin/src/claude_smart/query_compose.py +51 -0
  109. package/plugin/src/claude_smart/reflexio_adapter.py +403 -0
  110. package/plugin/src/claude_smart/runtime.py +52 -0
  111. package/plugin/src/claude_smart/stall_banner.py +61 -0
  112. package/plugin/src/claude_smart/state.py +276 -0
  113. package/plugin/uv.lock +3720 -0
@@ -0,0 +1,830 @@
1
+ "use client";
2
+
3
+ import { use, useEffect, useMemo, useState } from "react";
4
+ import Link from "next/link";
5
+ import { useRouter } from "next/navigation";
6
+ import {
7
+ ArrowLeft,
8
+ Trash2,
9
+ Save,
10
+ AlertTriangle,
11
+ Pencil,
12
+ X,
13
+ Copy,
14
+ Check,
15
+ BookMarked,
16
+ Hash,
17
+ FolderGit2,
18
+ Clock,
19
+ FileText,
20
+ } from "lucide-react";
21
+ import { PageHeader } from "@/components/common/page-header";
22
+ import { EmptyState } from "@/components/common/empty-state";
23
+ import { Button } from "@/components/ui/button";
24
+ import { Label } from "@/components/ui/label";
25
+ import { Badge } from "@/components/ui/badge";
26
+ import { Separator } from "@/components/ui/separator";
27
+ import {
28
+ Select,
29
+ SelectContent,
30
+ SelectItem,
31
+ SelectTrigger,
32
+ SelectValue,
33
+ } from "@/components/ui/select";
34
+ import { reflexio } from "@/lib/reflexio-client";
35
+ import { useSettings } from "@/hooks/use-settings";
36
+ import { formatTimestamp, truncateId } from "@/lib/format";
37
+ import { cn } from "@/lib/utils";
38
+ import { agentPlaybookStatusLabel, statusLabel } from "@/lib/status";
39
+ import type { AgentPlaybook, AgentPlaybookStatus } from "@/lib/types";
40
+
41
+ type FormState = {
42
+ content: string;
43
+ trigger: string;
44
+ rationale: string;
45
+ playbookStatus: AgentPlaybookStatus;
46
+ };
47
+
48
+ function toForm(p: AgentPlaybook): FormState {
49
+ return {
50
+ content: p.content,
51
+ trigger: p.trigger ?? "",
52
+ rationale: p.rationale ?? "",
53
+ playbookStatus: p.playbook_status,
54
+ };
55
+ }
56
+
57
+ function displayName(name: string | null | undefined): string | null {
58
+ if (!name) return null;
59
+ if (name === "default_playbook_extractor") return "shared skill";
60
+ return name;
61
+ }
62
+
63
+ const REVIEW_STATUS_META: Record<
64
+ AgentPlaybookStatus,
65
+ { label: string; description: string }
66
+ > = {
67
+ pending: {
68
+ label: "Auto generated",
69
+ description: "Auto-generated shared skill. It may be updated automatically.",
70
+ },
71
+ approved: {
72
+ label: "Persisted",
73
+ description: "Persisted shared skill. It will not be auto updated.",
74
+ },
75
+ rejected: {
76
+ label: "Rejected",
77
+ description: "Rejected shared skill. It will not be used in claude-smart.",
78
+ },
79
+ };
80
+
81
+ export default function SharedSkillDetailPage({
82
+ params,
83
+ }: {
84
+ params: Promise<{ id: string }>;
85
+ }) {
86
+ const { id } = use(params);
87
+ const router = useRouter();
88
+ const { reflexioUrl } = useSettings();
89
+
90
+ const [playbook, setPlaybook] = useState<AgentPlaybook | null>(null);
91
+ const [notFound, setNotFound] = useState(false);
92
+ const [error, setError] = useState<string | null>(null);
93
+ const [saving, setSaving] = useState(false);
94
+ const [deleting, setDeleting] = useState(false);
95
+ const [reviewingStatus, setReviewingStatus] =
96
+ useState<AgentPlaybookStatus | null>(null);
97
+ const [editing, setEditing] = useState(false);
98
+ const [form, setForm] = useState<FormState>({
99
+ content: "",
100
+ trigger: "",
101
+ rationale: "",
102
+ playbookStatus: "pending",
103
+ });
104
+
105
+ useEffect(() => {
106
+ let cancelled = false;
107
+ reflexio
108
+ .getAgentPlaybooks({ reflexioUrl })
109
+ .then((res) => {
110
+ if (cancelled) return;
111
+ const found = (res.agent_playbooks ?? []).find(
112
+ (p) => String(p.agent_playbook_id) === id,
113
+ );
114
+ if (!found) {
115
+ setNotFound(true);
116
+ return;
117
+ }
118
+ setPlaybook(found);
119
+ setForm(toForm(found));
120
+ })
121
+ .catch((e) => {
122
+ if (!cancelled) setError(e instanceof Error ? e.message : String(e));
123
+ });
124
+ return () => {
125
+ cancelled = true;
126
+ };
127
+ }, [id, reflexioUrl]);
128
+
129
+ const dirty = useMemo(() => {
130
+ if (!playbook) return false;
131
+ const orig = toForm(playbook);
132
+ return (
133
+ orig.content !== form.content ||
134
+ orig.trigger !== form.trigger ||
135
+ orig.rationale !== form.rationale ||
136
+ orig.playbookStatus !== form.playbookStatus
137
+ );
138
+ }, [playbook, form]);
139
+
140
+ const save = async () => {
141
+ if (!playbook || !dirty) return;
142
+ setSaving(true);
143
+ setError(null);
144
+ try {
145
+ await reflexio.updateAgentPlaybook(
146
+ {
147
+ agent_playbook_id: playbook.agent_playbook_id,
148
+ content: form.content,
149
+ trigger: form.trigger || null,
150
+ rationale: form.rationale || null,
151
+ playbook_status: form.playbookStatus,
152
+ },
153
+ reflexioUrl,
154
+ );
155
+ setPlaybook({
156
+ ...playbook,
157
+ content: form.content,
158
+ trigger: form.trigger || null,
159
+ rationale: form.rationale || null,
160
+ playbook_status: form.playbookStatus,
161
+ });
162
+ setEditing(false);
163
+ } catch (e) {
164
+ setError(e instanceof Error ? e.message : String(e));
165
+ } finally {
166
+ setSaving(false);
167
+ }
168
+ };
169
+
170
+ const setReviewStatus = async (nextStatus: AgentPlaybookStatus) => {
171
+ if (!playbook || playbook.playbook_status === nextStatus) return;
172
+ if (
173
+ nextStatus === "rejected" &&
174
+ !confirm(
175
+ `Reject shared skill #${playbook.agent_playbook_id}? Rejected shared skills will not be used in claude-smart.`,
176
+ )
177
+ ) {
178
+ return;
179
+ }
180
+
181
+ setReviewingStatus(nextStatus);
182
+ setError(null);
183
+ try {
184
+ await reflexio.updateAgentPlaybook(
185
+ {
186
+ agent_playbook_id: playbook.agent_playbook_id,
187
+ playbook_status: nextStatus,
188
+ },
189
+ reflexioUrl,
190
+ );
191
+ setPlaybook((current) =>
192
+ current ? { ...current, playbook_status: nextStatus } : current,
193
+ );
194
+ setForm((current) => ({ ...current, playbookStatus: nextStatus }));
195
+ } catch (e) {
196
+ setError(e instanceof Error ? e.message : String(e));
197
+ } finally {
198
+ setReviewingStatus(null);
199
+ }
200
+ };
201
+
202
+ const remove = async () => {
203
+ if (!playbook) return;
204
+ if (
205
+ !confirm(
206
+ `Delete shared skill #${playbook.agent_playbook_id}? This cannot be undone.`,
207
+ )
208
+ )
209
+ return;
210
+ setDeleting(true);
211
+ try {
212
+ await reflexio.deleteAgentPlaybook(playbook.agent_playbook_id, reflexioUrl);
213
+ router.push("/skills");
214
+ } catch (e) {
215
+ setError(e instanceof Error ? e.message : String(e));
216
+ setDeleting(false);
217
+ }
218
+ };
219
+
220
+ const cancelEdit = () => {
221
+ if (playbook) setForm(toForm(playbook));
222
+ setEditing(false);
223
+ };
224
+
225
+ if (notFound) {
226
+ return (
227
+ <div className="flex-1 overflow-auto">
228
+ <PageHeader title="Shared skill not found" />
229
+ <div className="p-6 max-w-2xl mx-auto">
230
+ <EmptyState
231
+ icon={AlertTriangle}
232
+ title="Shared skill not found"
233
+ description="It may have been deleted, archived, or moved outside the first 100 results."
234
+ action={
235
+ <Link href="/skills">
236
+ <Button variant="outline" size="sm">
237
+ <ArrowLeft className="h-3.5 w-3.5" />
238
+ Back to skills
239
+ </Button>
240
+ </Link>
241
+ }
242
+ />
243
+ </div>
244
+ </div>
245
+ );
246
+ }
247
+
248
+ const lifecycleStatus = playbook ? statusLabel(playbook) : null;
249
+ const playbookStatus = playbook ? agentPlaybookStatusLabel(playbook) : null;
250
+
251
+ return (
252
+ <div className="flex-1 overflow-auto">
253
+ <PageHeader
254
+ title={`Shared skill #${playbook?.agent_playbook_id ?? id}`}
255
+ description="Shared skill rolled up from project-specific skills."
256
+ actions={
257
+ <div className="flex items-center gap-2">
258
+ <Link href="/skills">
259
+ <Button variant="outline" size="sm">
260
+ <ArrowLeft className="h-3.5 w-3.5" />
261
+ Back
262
+ </Button>
263
+ </Link>
264
+ {!editing ? (
265
+ <Button
266
+ size="sm"
267
+ onClick={() => setEditing(true)}
268
+ disabled={!playbook || reviewingStatus !== null}
269
+ >
270
+ <Pencil className="h-3.5 w-3.5" />
271
+ Edit
272
+ </Button>
273
+ ) : (
274
+ <>
275
+ <Button
276
+ variant="outline"
277
+ size="sm"
278
+ onClick={cancelEdit}
279
+ disabled={saving}
280
+ >
281
+ <X className="h-3.5 w-3.5" />
282
+ Cancel
283
+ </Button>
284
+ <Button
285
+ size="sm"
286
+ onClick={save}
287
+ disabled={saving || !dirty}
288
+ >
289
+ <Save className="h-3.5 w-3.5" />
290
+ {saving ? "Saving…" : "Save"}
291
+ </Button>
292
+ </>
293
+ )}
294
+ </div>
295
+ }
296
+ />
297
+
298
+ <div className="p-6">
299
+ <div className="mx-auto max-w-5xl grid gap-6 lg:grid-cols-[1fr_280px]">
300
+ <div className="space-y-6 min-w-0">
301
+ {error && (
302
+ <div className="rounded-xl border border-destructive/30 bg-destructive/5 text-destructive px-4 py-3 text-sm flex items-start gap-2">
303
+ <AlertTriangle className="h-4 w-4 mt-0.5 shrink-0" />
304
+ <span>{error}</span>
305
+ </div>
306
+ )}
307
+
308
+ {playbook && (
309
+ <div className="flex items-center gap-2 flex-wrap">
310
+ <Badge variant="outline" className="gap-1.5">
311
+ <FolderGit2 className="h-3 w-3" />
312
+ {playbook.agent_version || "default"}
313
+ </Badge>
314
+ {editing ? (
315
+ <ReviewStatusBadge
316
+ status={playbook.playbook_status}
317
+ displayStatus={playbookStatus!}
318
+ />
319
+ ) : (
320
+ <ReviewStatusSelect
321
+ status={playbook.playbook_status}
322
+ displayStatus={playbookStatus!}
323
+ disabled={reviewingStatus !== null || deleting}
324
+ busy={reviewingStatus !== null}
325
+ onChange={setReviewStatus}
326
+ />
327
+ )}
328
+ {lifecycleStatus !== "CURRENT" && (
329
+ <StatusBadge status={lifecycleStatus!} />
330
+ )}
331
+ {displayName(playbook.playbook_name) && (
332
+ <Badge variant="secondary" className="font-mono text-[10px]">
333
+ {displayName(playbook.playbook_name)}
334
+ </Badge>
335
+ )}
336
+ {dirty && (
337
+ <Badge variant="destructive" className="gap-1.5">
338
+ unsaved changes
339
+ </Badge>
340
+ )}
341
+ </div>
342
+ )}
343
+
344
+ <Section
345
+ icon={AlertTriangle}
346
+ title="Trigger"
347
+ hint="When this shared skill should apply. Leave empty if it always applies."
348
+ >
349
+ {editing ? (
350
+ <AutoTextarea
351
+ value={form.trigger}
352
+ onChange={(v) => setForm((f) => ({ ...f, trigger: v }))}
353
+ rows={2}
354
+ placeholder="e.g. When writing or running async Python tests."
355
+ />
356
+ ) : (
357
+ <Prose text={playbook?.trigger ?? ""} muted={!playbook?.trigger} />
358
+ )}
359
+ </Section>
360
+
361
+ <Section
362
+ icon={Check}
363
+ title="Review status"
364
+ hint="Auto generated, persisted, or rejected."
365
+ >
366
+ {editing ? (
367
+ <Select
368
+ value={form.playbookStatus}
369
+ onValueChange={(v) =>
370
+ setForm((f) => ({
371
+ ...f,
372
+ playbookStatus: v as AgentPlaybookStatus,
373
+ }))
374
+ }
375
+ >
376
+ <SelectTrigger
377
+ className="w-48 text-xs"
378
+ title={REVIEW_STATUS_META[form.playbookStatus].description}
379
+ >
380
+ <SelectValue placeholder="Status" />
381
+ </SelectTrigger>
382
+ <SelectContent>
383
+ <SelectItem
384
+ value="pending"
385
+ title={REVIEW_STATUS_META.pending.description}
386
+ >
387
+ {REVIEW_STATUS_META.pending.label}
388
+ </SelectItem>
389
+ <SelectItem
390
+ value="approved"
391
+ title={REVIEW_STATUS_META.approved.description}
392
+ >
393
+ {REVIEW_STATUS_META.approved.label}
394
+ </SelectItem>
395
+ <SelectItem
396
+ value="rejected"
397
+ title={REVIEW_STATUS_META.rejected.description}
398
+ >
399
+ {REVIEW_STATUS_META.rejected.label}
400
+ </SelectItem>
401
+ </SelectContent>
402
+ </Select>
403
+ ) : (
404
+ playbook && (
405
+ <ReviewStatusSelect
406
+ status={playbook.playbook_status}
407
+ displayStatus={playbookStatus ?? "PENDING"}
408
+ disabled={reviewingStatus !== null || deleting}
409
+ busy={reviewingStatus !== null}
410
+ onChange={setReviewStatus}
411
+ />
412
+ )
413
+ )}
414
+ </Section>
415
+
416
+ <Section
417
+ icon={BookMarked}
418
+ title="Rule"
419
+ hint="What Claude should do. Injected when relevant in future sessions."
420
+ >
421
+ {editing ? (
422
+ <AutoTextarea
423
+ value={form.content}
424
+ onChange={(v) => setForm((f) => ({ ...f, content: v }))}
425
+ rows={6}
426
+ placeholder="e.g. Use anyio with trio backend — never pytest-asyncio."
427
+ />
428
+ ) : (
429
+ <Prose text={playbook?.content ?? ""} />
430
+ )}
431
+ </Section>
432
+
433
+ <Section
434
+ icon={FileText}
435
+ title="Rationale"
436
+ hint="Why — the reason, constraint, or past incident behind this rule."
437
+ >
438
+ {editing ? (
439
+ <AutoTextarea
440
+ value={form.rationale}
441
+ onChange={(v) => setForm((f) => ({ ...f, rationale: v }))}
442
+ rows={3}
443
+ placeholder="e.g. pytest-asyncio deadlocked CI on project X — trio is the project standard."
444
+ />
445
+ ) : (
446
+ <Prose
447
+ text={playbook?.rationale ?? ""}
448
+ muted={!playbook?.rationale}
449
+ />
450
+ )}
451
+ </Section>
452
+
453
+ {!editing && playbook && (
454
+ <>
455
+ <Separator />
456
+ <DangerZone
457
+ onDelete={remove}
458
+ deleting={deleting}
459
+ disabled={saving}
460
+ />
461
+ </>
462
+ )}
463
+ </div>
464
+
465
+ {playbook && (
466
+ <aside className="space-y-3 lg:sticky lg:top-6 lg:self-start">
467
+ <div className="rounded-xl border border-border bg-card p-4">
468
+ <h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-3">
469
+ Metadata
470
+ </h3>
471
+ <dl className="space-y-2.5 text-sm">
472
+ <Meta
473
+ icon={Hash}
474
+ label="ID"
475
+ value={String(playbook.agent_playbook_id)}
476
+ mono
477
+ />
478
+ <Meta
479
+ icon={Clock}
480
+ label="Created"
481
+ value={formatTimestamp(playbook.created_at)}
482
+ />
483
+ <Meta
484
+ label="Project"
485
+ value={playbook.agent_version || "default"}
486
+ mono
487
+ />
488
+ <Meta
489
+ label="Review"
490
+ value={REVIEW_STATUS_META[playbook.playbook_status].label}
491
+ mono
492
+ />
493
+ {playbook.playbook_metadata && (
494
+ <CopyMeta
495
+ label="Metadata"
496
+ value={playbook.playbook_metadata}
497
+ display={truncateId(playbook.playbook_metadata, 32, 8)}
498
+ />
499
+ )}
500
+ </dl>
501
+ </div>
502
+ </aside>
503
+ )}
504
+ </div>
505
+ </div>
506
+ </div>
507
+ );
508
+ }
509
+
510
+ function Section({
511
+ icon: Icon,
512
+ title,
513
+ hint,
514
+ children,
515
+ }: {
516
+ icon: React.ComponentType<{ className?: string }>;
517
+ title: string;
518
+ hint?: string;
519
+ children: React.ReactNode;
520
+ }) {
521
+ return (
522
+ <section className="space-y-2">
523
+ <div className="flex items-baseline gap-2">
524
+ <Label className="text-sm font-semibold flex items-center gap-1.5">
525
+ <Icon className="h-3.5 w-3.5 text-muted-foreground" />
526
+ {title}
527
+ </Label>
528
+ {hint && (
529
+ <span className="text-xs text-muted-foreground">{hint}</span>
530
+ )}
531
+ </div>
532
+ {children}
533
+ </section>
534
+ );
535
+ }
536
+
537
+ function Prose({ text, muted = false }: { text: string; muted?: boolean }) {
538
+ if (!text) {
539
+ return (
540
+ <p className="text-sm text-muted-foreground italic">
541
+ {muted ? "Not set" : "—"}
542
+ </p>
543
+ );
544
+ }
545
+ return (
546
+ <div
547
+ className={cn(
548
+ "rounded-xl border border-border bg-card px-4 py-3",
549
+ muted && "bg-muted/30",
550
+ )}
551
+ >
552
+ <p className="text-sm leading-relaxed whitespace-pre-wrap break-words">
553
+ {text}
554
+ </p>
555
+ </div>
556
+ );
557
+ }
558
+
559
+ function AutoTextarea({
560
+ value,
561
+ onChange,
562
+ rows = 3,
563
+ placeholder,
564
+ }: {
565
+ value: string;
566
+ onChange: (v: string) => void;
567
+ rows?: number;
568
+ placeholder?: string;
569
+ }) {
570
+ return (
571
+ <textarea
572
+ value={value}
573
+ onChange={(e) => onChange(e.target.value)}
574
+ rows={rows}
575
+ placeholder={placeholder}
576
+ className="w-full rounded-xl border border-input bg-transparent px-4 py-3 text-sm leading-relaxed font-sans resize-y outline-none transition-colors focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 placeholder:text-muted-foreground"
577
+ />
578
+ );
579
+ }
580
+
581
+ function StatusBadge({
582
+ status,
583
+ }: {
584
+ status: "CURRENT" | "ARCHIVED" | "PENDING" | "APPROVED" | "REJECTED";
585
+ }) {
586
+ const variant =
587
+ status === "CURRENT" || status === "APPROVED"
588
+ ? "secondary"
589
+ : status === "ARCHIVED" || status === "REJECTED"
590
+ ? "outline"
591
+ : "default";
592
+ return (
593
+ <Badge variant={variant} className="gap-1.5">
594
+ <span
595
+ className={cn(
596
+ "h-1.5 w-1.5 rounded-full",
597
+ status === "CURRENT" && "bg-emerald-500",
598
+ status === "APPROVED" && "bg-emerald-500",
599
+ status === "PENDING" && "bg-amber-500",
600
+ status === "REJECTED" && "bg-destructive",
601
+ status === "ARCHIVED" && "bg-muted-foreground",
602
+ )}
603
+ />
604
+ {status}
605
+ </Badge>
606
+ );
607
+ }
608
+
609
+ function ReviewStatusSelect({
610
+ status,
611
+ displayStatus,
612
+ disabled,
613
+ busy,
614
+ onChange,
615
+ }: {
616
+ status: AgentPlaybookStatus;
617
+ displayStatus: "PENDING" | "APPROVED" | "REJECTED";
618
+ disabled: boolean;
619
+ busy: boolean;
620
+ onChange: (status: AgentPlaybookStatus) => void;
621
+ }) {
622
+ const meta = REVIEW_STATUS_META[status];
623
+
624
+ return (
625
+ <Select
626
+ value={status}
627
+ onValueChange={(value) => onChange(value as AgentPlaybookStatus)}
628
+ disabled={disabled}
629
+ >
630
+ <SelectTrigger
631
+ size="sm"
632
+ aria-label="Review status"
633
+ title={meta.description}
634
+ className={cn(
635
+ "h-7 w-fit gap-1.5 rounded-lg border px-2.5 py-0 text-xs font-medium",
636
+ "bg-background hover:bg-muted focus-visible:ring-3",
637
+ displayStatus === "APPROVED" &&
638
+ "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
639
+ displayStatus === "PENDING" &&
640
+ "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300",
641
+ displayStatus === "REJECTED" &&
642
+ "border-destructive/30 bg-destructive/10 text-destructive",
643
+ )}
644
+ >
645
+ <StatusPillContent
646
+ status={displayStatus}
647
+ label={busy ? "Updating" : meta.label}
648
+ />
649
+ </SelectTrigger>
650
+ <SelectContent align="start" alignItemWithTrigger={false}>
651
+ <SelectItem
652
+ value="pending"
653
+ title={REVIEW_STATUS_META.pending.description}
654
+ >
655
+ <StatusPillContent
656
+ status="PENDING"
657
+ label={REVIEW_STATUS_META.pending.label}
658
+ />
659
+ </SelectItem>
660
+ <SelectItem
661
+ value="approved"
662
+ title={REVIEW_STATUS_META.approved.description}
663
+ >
664
+ <StatusPillContent
665
+ status="APPROVED"
666
+ label={REVIEW_STATUS_META.approved.label}
667
+ />
668
+ </SelectItem>
669
+ <SelectItem
670
+ value="rejected"
671
+ title={REVIEW_STATUS_META.rejected.description}
672
+ >
673
+ <StatusPillContent
674
+ status="REJECTED"
675
+ label={REVIEW_STATUS_META.rejected.label}
676
+ />
677
+ </SelectItem>
678
+ </SelectContent>
679
+ </Select>
680
+ );
681
+ }
682
+
683
+ function ReviewStatusBadge({
684
+ status,
685
+ displayStatus,
686
+ }: {
687
+ status: AgentPlaybookStatus;
688
+ displayStatus: "PENDING" | "APPROVED" | "REJECTED";
689
+ }) {
690
+ const meta = REVIEW_STATUS_META[status];
691
+ return (
692
+ <Badge
693
+ variant={displayStatus === "REJECTED" ? "outline" : "secondary"}
694
+ className={cn(
695
+ "gap-1.5",
696
+ displayStatus === "APPROVED" &&
697
+ "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
698
+ displayStatus === "PENDING" &&
699
+ "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300",
700
+ displayStatus === "REJECTED" &&
701
+ "border-destructive/30 bg-destructive/10 text-destructive",
702
+ )}
703
+ title={meta.description}
704
+ >
705
+ <StatusPillContent status={displayStatus} label={meta.label} />
706
+ </Badge>
707
+ );
708
+ }
709
+
710
+ function StatusPillContent({
711
+ status,
712
+ label,
713
+ }: {
714
+ status: "PENDING" | "APPROVED" | "REJECTED";
715
+ label: string;
716
+ }) {
717
+ return (
718
+ <span className="inline-flex items-center gap-1.5">
719
+ <span
720
+ className={cn(
721
+ "h-1.5 w-1.5 rounded-full",
722
+ status === "APPROVED" && "bg-emerald-500",
723
+ status === "PENDING" && "bg-amber-500",
724
+ status === "REJECTED" && "bg-destructive",
725
+ )}
726
+ />
727
+ {label}
728
+ </span>
729
+ );
730
+ }
731
+
732
+ function Meta({
733
+ icon: Icon,
734
+ label,
735
+ value,
736
+ mono,
737
+ }: {
738
+ icon?: React.ComponentType<{ className?: string }>;
739
+ label: string;
740
+ value: string;
741
+ mono?: boolean;
742
+ }) {
743
+ return (
744
+ <div className="flex items-start justify-between gap-3">
745
+ <dt className="text-xs text-muted-foreground shrink-0 flex items-center gap-1.5">
746
+ {Icon && <Icon className="h-3 w-3" />}
747
+ {label}
748
+ </dt>
749
+ <dd
750
+ className={cn(
751
+ "text-xs text-right min-w-0 break-words",
752
+ mono && "font-mono",
753
+ )}
754
+ >
755
+ {value}
756
+ </dd>
757
+ </div>
758
+ );
759
+ }
760
+
761
+ function CopyMeta({
762
+ label,
763
+ value,
764
+ display,
765
+ }: {
766
+ label: string;
767
+ value: string;
768
+ display: string;
769
+ }) {
770
+ const [copied, setCopied] = useState(false);
771
+ const copy = async () => {
772
+ try {
773
+ await navigator.clipboard.writeText(value);
774
+ setCopied(true);
775
+ setTimeout(() => setCopied(false), 1200);
776
+ } catch {
777
+ // ignore
778
+ }
779
+ };
780
+ return (
781
+ <div className="flex items-start justify-between gap-3">
782
+ <dt className="text-xs text-muted-foreground shrink-0">{label}</dt>
783
+ <dd className="text-xs min-w-0 flex items-center gap-1.5">
784
+ <code className="font-mono">{display}</code>
785
+ <button
786
+ onClick={copy}
787
+ className="text-muted-foreground hover:text-foreground transition-colors"
788
+ title="Copy full id"
789
+ >
790
+ {copied ? (
791
+ <Check className="h-3 w-3 text-emerald-500" />
792
+ ) : (
793
+ <Copy className="h-3 w-3" />
794
+ )}
795
+ </button>
796
+ </dd>
797
+ </div>
798
+ );
799
+ }
800
+
801
+ function DangerZone({
802
+ onDelete,
803
+ deleting,
804
+ disabled,
805
+ }: {
806
+ onDelete: () => void;
807
+ deleting: boolean;
808
+ disabled: boolean;
809
+ }) {
810
+ return (
811
+ <section className="rounded-xl border border-destructive/30 bg-destructive/5 p-4 flex items-start justify-between gap-4">
812
+ <div className="min-w-0">
813
+ <h3 className="text-sm font-semibold text-destructive">Danger zone</h3>
814
+ <p className="text-xs text-muted-foreground mt-0.5">
815
+ Deleting removes this shared skill permanently. It will stop being
816
+ available in the dashboard.
817
+ </p>
818
+ </div>
819
+ <Button
820
+ variant="destructive"
821
+ size="sm"
822
+ onClick={onDelete}
823
+ disabled={deleting || disabled}
824
+ >
825
+ <Trash2 className="h-3.5 w-3.5" />
826
+ {deleting ? "Deleting…" : "Delete"}
827
+ </Button>
828
+ </section>
829
+ );
830
+ }