stagent 0.1.10 → 0.1.12

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 (112) hide show
  1. package/README.md +58 -27
  2. package/package.json +3 -3
  3. package/src/__tests__/e2e/blueprint.test.ts +63 -0
  4. package/src/__tests__/e2e/cross-runtime.test.ts +77 -0
  5. package/src/__tests__/e2e/helpers.ts +286 -0
  6. package/src/__tests__/e2e/parallel-workflow.test.ts +120 -0
  7. package/src/__tests__/e2e/sequence-workflow.test.ts +109 -0
  8. package/src/__tests__/e2e/setup.ts +156 -0
  9. package/src/__tests__/e2e/single-task.test.ts +170 -0
  10. package/src/app/api/command-palette/recent/route.ts +41 -18
  11. package/src/app/api/context/batch/route.ts +44 -0
  12. package/src/app/api/permissions/presets/route.ts +80 -0
  13. package/src/app/api/playbook/status/route.ts +15 -0
  14. package/src/app/api/profiles/route.ts +23 -21
  15. package/src/app/api/settings/pricing/route.ts +15 -0
  16. package/src/app/costs/page.tsx +53 -43
  17. package/src/app/globals.css +0 -5
  18. package/src/app/playbook/[slug]/page.tsx +76 -0
  19. package/src/app/playbook/page.tsx +54 -0
  20. package/src/app/profiles/page.tsx +7 -4
  21. package/src/app/settings/page.tsx +2 -2
  22. package/src/app/tasks/page.tsx +5 -0
  23. package/src/components/costs/cost-dashboard.tsx +226 -320
  24. package/src/components/dashboard/activity-feed.tsx +6 -2
  25. package/src/components/notifications/batch-proposal-review.tsx +150 -0
  26. package/src/components/notifications/notification-item.tsx +6 -3
  27. package/src/components/notifications/pending-approval-host.tsx +57 -11
  28. package/src/components/playbook/adoption-heatmap.tsx +69 -0
  29. package/src/components/playbook/journey-card.tsx +110 -0
  30. package/src/components/playbook/playbook-action-button.tsx +22 -0
  31. package/src/components/playbook/playbook-browser.tsx +143 -0
  32. package/src/components/playbook/playbook-card.tsx +102 -0
  33. package/src/components/playbook/playbook-detail-view.tsx +223 -0
  34. package/src/components/playbook/playbook-homepage.tsx +142 -0
  35. package/src/components/playbook/playbook-toc.tsx +90 -0
  36. package/src/components/playbook/playbook-updated-badge.tsx +23 -0
  37. package/src/components/playbook/related-docs.tsx +30 -0
  38. package/src/components/profiles/__tests__/learned-context-panel.test.tsx +175 -0
  39. package/src/components/profiles/context-proposal-review.tsx +7 -3
  40. package/src/components/profiles/learned-context-panel.tsx +116 -8
  41. package/src/components/profiles/profile-detail-view.tsx +7 -19
  42. package/src/components/profiles/profile-form-view.tsx +0 -22
  43. package/src/components/settings/__tests__/auth-config-section.test.tsx +147 -0
  44. package/src/components/settings/api-key-form.tsx +5 -43
  45. package/src/components/settings/auth-config-section.tsx +10 -6
  46. package/src/components/settings/auth-status-badge.tsx +8 -0
  47. package/src/components/settings/budget-guardrails-section.tsx +403 -620
  48. package/src/components/settings/connection-test-control.tsx +63 -0
  49. package/src/components/settings/permissions-section.tsx +85 -75
  50. package/src/components/settings/permissions-sections.tsx +24 -0
  51. package/src/components/settings/presets-section.tsx +159 -0
  52. package/src/components/settings/pricing-registry-panel.tsx +164 -0
  53. package/src/components/shared/app-sidebar.tsx +2 -0
  54. package/src/components/shared/command-palette.tsx +30 -0
  55. package/src/components/shared/light-markdown.tsx +134 -0
  56. package/src/components/workflows/loop-status-view.tsx +8 -4
  57. package/src/components/workflows/workflow-status-view.tsx +16 -9
  58. package/src/lib/agents/__tests__/claude-agent.test.ts +7 -2
  59. package/src/lib/agents/__tests__/learned-context.test.ts +500 -0
  60. package/src/lib/agents/__tests__/pattern-extractor.test.ts +243 -0
  61. package/src/lib/agents/__tests__/sweep.test.ts +202 -0
  62. package/src/lib/agents/claude-agent.ts +104 -78
  63. package/src/lib/agents/learned-context.ts +32 -28
  64. package/src/lib/agents/learning-session.ts +234 -0
  65. package/src/lib/agents/pattern-extractor.ts +34 -64
  66. package/src/lib/agents/profiles/__tests__/sort.test.ts +42 -0
  67. package/src/lib/agents/profiles/builtins/code-reviewer/profile.yaml +0 -1
  68. package/src/lib/agents/profiles/builtins/data-analyst/profile.yaml +0 -1
  69. package/src/lib/agents/profiles/builtins/devops-engineer/profile.yaml +0 -1
  70. package/src/lib/agents/profiles/builtins/document-writer/profile.yaml +0 -1
  71. package/src/lib/agents/profiles/builtins/general/profile.yaml +0 -1
  72. package/src/lib/agents/profiles/builtins/health-fitness-coach/profile.yaml +0 -1
  73. package/src/lib/agents/profiles/builtins/learning-coach/profile.yaml +0 -1
  74. package/src/lib/agents/profiles/builtins/project-manager/profile.yaml +0 -1
  75. package/src/lib/agents/profiles/builtins/researcher/profile.yaml +0 -1
  76. package/src/lib/agents/profiles/builtins/shopping-assistant/profile.yaml +0 -1
  77. package/src/lib/agents/profiles/builtins/sweep/profile.yaml +0 -1
  78. package/src/lib/agents/profiles/builtins/technical-writer/profile.yaml +0 -1
  79. package/src/lib/agents/profiles/builtins/travel-planner/profile.yaml +0 -1
  80. package/src/lib/agents/profiles/builtins/wealth-manager/profile.yaml +0 -1
  81. package/src/lib/agents/profiles/registry.ts +0 -1
  82. package/src/lib/agents/profiles/sort.ts +7 -0
  83. package/src/lib/agents/profiles/types.ts +0 -1
  84. package/src/lib/agents/runtime/catalog.ts +1 -1
  85. package/src/lib/agents/runtime/claude.ts +66 -0
  86. package/src/lib/constants/settings.ts +1 -0
  87. package/src/lib/constants/task-status.ts +6 -0
  88. package/src/lib/data/seed-data/profiles.ts +0 -3
  89. package/src/lib/db/schema.ts +3 -0
  90. package/src/lib/docs/adoption.ts +105 -0
  91. package/src/lib/docs/journey-tracker.ts +21 -0
  92. package/src/lib/docs/reader.ts +102 -0
  93. package/src/lib/docs/types.ts +54 -0
  94. package/src/lib/docs/usage-stage.ts +60 -0
  95. package/src/lib/notifications/actionable.ts +18 -10
  96. package/src/lib/settings/__tests__/budget-guardrails.test.ts +86 -24
  97. package/src/lib/settings/budget-guardrails.ts +213 -85
  98. package/src/lib/settings/permission-presets.ts +150 -0
  99. package/src/lib/settings/runtime-setup.ts +71 -0
  100. package/src/lib/usage/__tests__/ledger.test.ts +29 -5
  101. package/src/lib/usage/__tests__/pricing-registry.test.ts +78 -0
  102. package/src/lib/usage/ledger.ts +4 -2
  103. package/src/lib/usage/pricing-registry.ts +570 -0
  104. package/src/lib/usage/pricing.ts +15 -41
  105. package/src/lib/utils/__tests__/learned-context-history.test.ts +171 -0
  106. package/src/lib/utils/learned-context-history.ts +150 -0
  107. package/src/lib/validators/__tests__/profile.test.ts +0 -15
  108. package/src/lib/validators/__tests__/settings.test.ts +23 -16
  109. package/src/lib/validators/profile.ts +0 -1
  110. package/src/lib/validators/settings.ts +3 -9
  111. package/src/lib/workflows/__tests__/engine.test.ts +2 -0
  112. package/src/lib/workflows/engine.ts +20 -1
@@ -73,9 +73,13 @@ export function ActivityFeed({ entries, hourlyActivity }: ActivityFeedProps) {
73
73
  <span className="text-muted-foreground"> — {entry.taskTitle}</span>
74
74
  )}
75
75
  </p>
76
- <p className="text-xs text-muted-foreground">
76
+ <p className="text-xs text-muted-foreground" suppressHydrationWarning>
77
77
  {new Date(entry.timestamp).toLocaleTimeString()}
78
- {entry.payload && ` · ${entry.payload.slice(0, 60)}${entry.payload.length > 60 ? "..." : ""}`}
78
+ {entry.payload && (() => {
79
+ const chars = Array.from(entry.payload);
80
+ const truncated = chars.length > 60 ? chars.slice(0, 60).join("") + "..." : entry.payload;
81
+ return ` · ${truncated}`;
82
+ })()}
79
83
  </p>
80
84
  </div>
81
85
  </div>
@@ -0,0 +1,150 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { Button } from "@/components/ui/button";
5
+ import { Badge } from "@/components/ui/badge";
6
+ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
7
+ import { CheckCircle2, XCircle, Layers3, Brain } from "lucide-react";
8
+ import { LightMarkdown } from "@/components/shared/light-markdown";
9
+
10
+ interface BatchProposalReviewProps {
11
+ proposalIds: string[];
12
+ profileIds: string[];
13
+ body: string;
14
+ onResponded?: () => void;
15
+ compact?: boolean;
16
+ }
17
+
18
+ export function BatchProposalReview({
19
+ proposalIds,
20
+ profileIds,
21
+ body,
22
+ onResponded,
23
+ compact = false,
24
+ }: BatchProposalReviewProps) {
25
+ const [loading, setLoading] = useState<"approve" | "reject" | null>(null);
26
+ const [responded, setResponded] = useState(false);
27
+ const [result, setResult] = useState<{
28
+ action: string;
29
+ count: number;
30
+ } | null>(null);
31
+
32
+ async function handleBatchAction(action: "approve" | "reject") {
33
+ setLoading(action);
34
+ try {
35
+ const res = await fetch("/api/context/batch", {
36
+ method: "POST",
37
+ headers: { "Content-Type": "application/json" },
38
+ body: JSON.stringify({ proposalIds, action }),
39
+ });
40
+ if (res.ok) {
41
+ const data = await res.json();
42
+ setResult({ action: data.action, count: data.count });
43
+ setResponded(true);
44
+ onResponded?.();
45
+ }
46
+ } finally {
47
+ setLoading(null);
48
+ }
49
+ }
50
+
51
+ if (responded && result) {
52
+ return (
53
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
54
+ {result.action === "approve" ? (
55
+ <CheckCircle2 className="h-4 w-4 text-status-success" />
56
+ ) : (
57
+ <XCircle className="h-4 w-4 text-status-error" />
58
+ )}
59
+ <span>
60
+ {result.count} proposal{result.count !== 1 ? "s" : ""}{" "}
61
+ {result.action === "approve" ? "approved" : "rejected"}
62
+ </span>
63
+ </div>
64
+ );
65
+ }
66
+
67
+ if (compact) {
68
+ return (
69
+ <div className="space-y-2">
70
+ <div className="flex items-center gap-2">
71
+ <Badge variant="secondary" className="text-xs">
72
+ <Layers3 className="h-3.5 w-3.5" />
73
+ {proposalIds.length} proposals
74
+ </Badge>
75
+ {profileIds.map((id) => (
76
+ <Badge key={id} variant="outline" className="text-xs">
77
+ <Brain className="h-3.5 w-3.5" />
78
+ {id}
79
+ </Badge>
80
+ ))}
81
+ </div>
82
+ <div className="flex gap-2">
83
+ <Button
84
+ size="sm"
85
+ variant="default"
86
+ disabled={loading !== null}
87
+ onClick={() => handleBatchAction("approve")}
88
+ >
89
+ {loading === "approve" ? "Approving..." : "Approve All"}
90
+ </Button>
91
+ <Button
92
+ size="sm"
93
+ variant="outline"
94
+ disabled={loading !== null}
95
+ onClick={() => handleBatchAction("reject")}
96
+ >
97
+ {loading === "reject" ? "Rejecting..." : "Reject All"}
98
+ </Button>
99
+ </div>
100
+ </div>
101
+ );
102
+ }
103
+
104
+ return (
105
+ <Card className="surface-card">
106
+ <CardHeader className="pb-3">
107
+ <CardTitle className="text-sm flex items-center gap-2">
108
+ <Brain className="h-4 w-4" />
109
+ Workflow Learning — {proposalIds.length} Proposals
110
+ </CardTitle>
111
+ </CardHeader>
112
+ <CardContent className="space-y-4">
113
+ <div className="flex flex-wrap gap-1.5">
114
+ {profileIds.map((id) => (
115
+ <Badge key={id} variant="outline" className="text-xs">
116
+ {id}
117
+ </Badge>
118
+ ))}
119
+ </div>
120
+
121
+ <div className="rounded-lg border p-3 max-h-64 overflow-y-auto">
122
+ <LightMarkdown content={body} />
123
+ </div>
124
+
125
+ <div className="flex gap-2">
126
+ <Button
127
+ variant="default"
128
+ disabled={loading !== null}
129
+ onClick={() => handleBatchAction("approve")}
130
+ >
131
+ <CheckCircle2 className="h-4 w-4" />
132
+ {loading === "approve"
133
+ ? "Approving..."
134
+ : `Approve All (${proposalIds.length})`}
135
+ </Button>
136
+ <Button
137
+ variant="outline"
138
+ disabled={loading !== null}
139
+ onClick={() => handleBatchAction("reject")}
140
+ >
141
+ <XCircle className="h-4 w-4" />
142
+ {loading === "reject"
143
+ ? "Rejecting..."
144
+ : `Reject All (${proposalIds.length})`}
145
+ </Button>
146
+ </div>
147
+ </CardContent>
148
+ </Card>
149
+ );
150
+ }
@@ -4,6 +4,7 @@ import { Badge } from "@/components/ui/badge";
4
4
  import { Card } from "@/components/ui/card";
5
5
  import { Shield, MessageCircle, CheckCircle, XCircle, Eye, EyeOff, Trash2, Wallet } from "lucide-react";
6
6
  import { Button } from "@/components/ui/button";
7
+ import { LightMarkdown } from "@/components/shared/light-markdown";
7
8
  import { useState } from "react";
8
9
  import { PermissionAction } from "./permission-action";
9
10
  import { MessageResponse, type Question } from "./message-response";
@@ -174,9 +175,11 @@ export function NotificationItem({ notification, onUpdated }: NotificationItemPr
174
175
  {notification.body &&
175
176
  notification.type !== "permission_required" &&
176
177
  notification.type !== "agent_message" && (
177
- <p className="text-sm text-muted-foreground line-clamp-3">
178
- {notification.body}
179
- </p>
178
+ <LightMarkdown
179
+ content={notification.body}
180
+ lineClamp={3}
181
+ textSize="sm"
182
+ />
180
183
  )}
181
184
 
182
185
  {/* Actions based on type */}
@@ -13,6 +13,7 @@ import {
13
13
 
14
14
  import { PermissionResponseActions } from "@/components/notifications/permission-response-actions";
15
15
  import { ContextProposalReview } from "@/components/profiles/context-proposal-review";
16
+ import { BatchProposalReview } from "@/components/notifications/batch-proposal-review";
16
17
  import { Badge } from "@/components/ui/badge";
17
18
  import {
18
19
  Dialog,
@@ -46,6 +47,22 @@ function dedupePendingApprovals(items: PendingApprovalPayload[]) {
46
47
  );
47
48
  }
48
49
 
50
+ function parseBatchToolInput(toolInput: unknown): {
51
+ proposalIds: string[];
52
+ profileIds: string[];
53
+ } {
54
+ try {
55
+ const parsed =
56
+ typeof toolInput === "string" ? JSON.parse(toolInput) : toolInput;
57
+ return {
58
+ proposalIds: Array.isArray(parsed?.proposalIds) ? parsed.proposalIds : [],
59
+ profileIds: Array.isArray(parsed?.profileIds) ? parsed.profileIds : [],
60
+ };
61
+ } catch {
62
+ return { proposalIds: [], profileIds: [] };
63
+ }
64
+ }
65
+
49
66
  function buildContextLabel(payload: PendingApprovalPayload) {
50
67
  if (payload.workflowName && payload.taskTitle) {
51
68
  return `${payload.workflowName} · ${payload.taskTitle}`;
@@ -127,7 +144,7 @@ function PendingApprovalDetail({
127
144
  <p className="mt-2 text-sm text-muted-foreground">
128
145
  {selected.compactSummary}
129
146
  </p>
130
- {selected.body && (
147
+ {selected.body && selected.notificationType !== "context_proposal" && (
131
148
  <p className="mt-3 text-sm leading-6 text-muted-foreground">
132
149
  {selected.body}
133
150
  </p>
@@ -137,7 +154,19 @@ function PendingApprovalDetail({
137
154
  </p>
138
155
  </div>
139
156
 
140
- {selected.notificationType === "context_proposal" ? (
157
+ {selected.notificationType === "context_proposal_batch" ? (
158
+ (() => {
159
+ const parsed = parseBatchToolInput(selected.toolInput);
160
+ return (
161
+ <BatchProposalReview
162
+ proposalIds={parsed.proposalIds}
163
+ profileIds={parsed.profileIds}
164
+ body={selected.body ?? ""}
165
+ onResponded={onResponded}
166
+ />
167
+ );
168
+ })()
169
+ ) : selected.notificationType === "context_proposal" ? (
141
170
  <ContextProposalReview
142
171
  notificationId={selected.notificationId}
143
172
  profileId={selected.toolName ?? ""}
@@ -405,7 +434,22 @@ export function PendingApprovalHost() {
405
434
  </div>
406
435
  </button>
407
436
 
408
- {primary.notificationType === "context_proposal" ? (
437
+ {primary.notificationType === "context_proposal_batch" ? (
438
+ <div className="mt-3">
439
+ {(() => {
440
+ const parsed = parseBatchToolInput(primary.toolInput);
441
+ return (
442
+ <BatchProposalReview
443
+ proposalIds={parsed.proposalIds}
444
+ profileIds={parsed.profileIds}
445
+ body={primary.body ?? ""}
446
+ onResponded={() => removeNotification(primary.notificationId)}
447
+ compact
448
+ />
449
+ );
450
+ })()}
451
+ </div>
452
+ ) : primary.notificationType === "context_proposal" ? (
409
453
  <div className="mt-3">
410
454
  <ContextProposalReview
411
455
  notificationId={primary.notificationId}
@@ -474,7 +518,7 @@ export function PendingApprovalHost() {
474
518
  ) : (
475
519
  <Dialog open={detailOpen} onOpenChange={setDetailOpen}>
476
520
  <DialogContent
477
- className="max-w-2xl"
521
+ className="max-w-2xl max-h-[85dvh] flex flex-col"
478
522
  onCloseAutoFocus={(event) => {
479
523
  event.preventDefault();
480
524
  triggerRef.current?.focus();
@@ -487,13 +531,15 @@ export function PendingApprovalHost() {
487
531
  the Inbox first.
488
532
  </DialogDescription>
489
533
  </DialogHeader>
490
- <PendingApprovalDetail
491
- selected={selected}
492
- overflow={overflowItems}
493
- onResponded={() => removeNotification(selected.notificationId)}
494
- onOpenInbox={handleOpenInbox}
495
- onSelect={setSelectedId}
496
- />
534
+ <div className="overflow-y-auto -mx-6 px-6 pb-1">
535
+ <PendingApprovalDetail
536
+ selected={selected}
537
+ overflow={overflowItems}
538
+ onResponded={() => removeNotification(selected.notificationId)}
539
+ onOpenInbox={handleOpenInbox}
540
+ onSelect={setSelectedId}
541
+ />
542
+ </div>
497
543
  </DialogContent>
498
544
  </Dialog>
499
545
  ))}
@@ -0,0 +1,69 @@
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import type { DocSection, AdoptionEntry } from "@/lib/docs/types";
5
+
6
+ interface AdoptionHeatmapProps {
7
+ sections: DocSection[];
8
+ adoption: Record<string, AdoptionEntry>;
9
+ }
10
+
11
+ const depthStyles: Record<AdoptionEntry["depth"], string> = {
12
+ none: "bg-muted border-border/30 text-muted-foreground",
13
+ light: "bg-amber-500/10 border-amber-500/30 text-amber-600 dark:text-amber-400",
14
+ deep: "bg-emerald-500/10 border-emerald-500/30 text-emerald-600 dark:text-emerald-400",
15
+ };
16
+
17
+ const depthLabels: Record<AdoptionEntry["depth"], string> = {
18
+ none: "Not explored",
19
+ light: "Lightly used",
20
+ deep: "Deeply used",
21
+ };
22
+
23
+ export function AdoptionHeatmap({
24
+ sections,
25
+ adoption,
26
+ }: AdoptionHeatmapProps) {
27
+ return (
28
+ <div className="space-y-3">
29
+ <div className="flex items-center justify-between">
30
+ <h3 className="text-base font-medium text-muted-foreground uppercase tracking-wider">
31
+ Feature Adoption
32
+ </h3>
33
+ <div className="flex items-center gap-3 text-xs text-muted-foreground">
34
+ <span className="flex items-center gap-1">
35
+ <span className="h-2 w-2 rounded-full bg-muted-foreground/30" />
36
+ Not explored
37
+ </span>
38
+ <span className="flex items-center gap-1">
39
+ <span className="h-2 w-2 rounded-full bg-amber-500" />
40
+ Light
41
+ </span>
42
+ <span className="flex items-center gap-1">
43
+ <span className="h-2 w-2 rounded-full bg-emerald-500" />
44
+ Deep
45
+ </span>
46
+ </div>
47
+ </div>
48
+ <div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-4">
49
+ {sections.map((section) => {
50
+ const entry = adoption[section.slug] ?? {
51
+ adopted: false,
52
+ depth: "none" as const,
53
+ };
54
+
55
+ return (
56
+ <Link
57
+ key={section.slug}
58
+ href={`/playbook/${section.slug}`}
59
+ className={`rounded-lg border px-4 py-3 text-sm font-medium transition-colors hover:opacity-80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${depthStyles[entry.depth]}`}
60
+ title={depthLabels[entry.depth]}
61
+ >
62
+ {section.title}
63
+ </Link>
64
+ );
65
+ })}
66
+ </div>
67
+ </div>
68
+ );
69
+ }
@@ -0,0 +1,110 @@
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import { Badge } from "@/components/ui/badge";
5
+ import { Card, CardHeader, CardContent } from "@/components/ui/card";
6
+ import { Map, Clock, GraduationCap, Briefcase, Zap, Code } from "lucide-react";
7
+ import type {
8
+ DocJourney,
9
+ AdoptionEntry,
10
+ JourneyCompletion,
11
+ } from "@/lib/docs/types";
12
+
13
+ const personaIcons: Record<string, typeof Map> = {
14
+ personal: GraduationCap,
15
+ work: Briefcase,
16
+ "power-user": Zap,
17
+ developer: Code,
18
+ };
19
+
20
+ const difficultyColors: Record<string, string> = {
21
+ beginner: "bg-emerald-500/10 text-emerald-500 border-emerald-500/20",
22
+ intermediate: "bg-amber-500/10 text-amber-500 border-amber-500/20",
23
+ advanced: "bg-rose-500/10 text-rose-500 border-rose-500/20",
24
+ };
25
+
26
+ interface JourneyCardProps {
27
+ journey: DocJourney;
28
+ completion?: JourneyCompletion;
29
+ adoption: Record<string, AdoptionEntry>;
30
+ }
31
+
32
+ export function JourneyCard({
33
+ journey,
34
+ completion,
35
+ adoption,
36
+ }: JourneyCardProps) {
37
+ const Icon = personaIcons[journey.persona] || Map;
38
+ const completed = completion?.completed ?? 0;
39
+ const total = completion?.total ?? journey.sections.length;
40
+ const percentage = completion?.percentage ?? 0;
41
+
42
+ return (
43
+ <Link
44
+ href={`/playbook/${journey.slug}`}
45
+ className="block focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-xl"
46
+ >
47
+ <Card className="surface-card glass-shimmer group h-full transition-colors hover:border-border hover:bg-accent/50 rounded-xl">
48
+ <CardHeader className="pb-2">
49
+ <div className="flex items-start justify-between gap-2">
50
+ <div className="flex items-center gap-2">
51
+ <div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10">
52
+ <Icon className="h-4 w-4 text-primary" />
53
+ </div>
54
+ <div>
55
+ <h3 className="text-base font-medium group-hover:text-primary transition-colors">
56
+ {journey.title}
57
+ </h3>
58
+ <div className="flex items-center gap-2 mt-0.5">
59
+ <Badge
60
+ variant="outline"
61
+ className={`text-xs ${difficultyColors[journey.difficulty] || ""}`}
62
+ >
63
+ {journey.difficulty}
64
+ </Badge>
65
+ <span className="flex items-center gap-1 text-xs text-muted-foreground">
66
+ <Clock className="h-3 w-3" />
67
+ {journey.stepCount} steps
68
+ </span>
69
+ </div>
70
+ </div>
71
+ </div>
72
+ </div>
73
+ </CardHeader>
74
+
75
+ <CardContent className="space-y-3">
76
+ {/* Progress bar — segmented by section */}
77
+ <div className="flex gap-1">
78
+ {journey.sections.map((sectionSlug) => {
79
+ const sectionAdoption = adoption[sectionSlug];
80
+ const isAdopted = sectionAdoption?.adopted === true;
81
+ const depth = sectionAdoption?.depth ?? "none";
82
+
83
+ let barColor = "bg-muted-foreground/20";
84
+ if (depth === "deep") barColor = "bg-emerald-500";
85
+ else if (depth === "light") barColor = "bg-amber-500";
86
+
87
+ return (
88
+ <div
89
+ key={sectionSlug}
90
+ className={`h-2 flex-1 rounded-full ${barColor} transition-colors`}
91
+ title={`${sectionSlug}: ${isAdopted ? "explored" : "not explored"}`}
92
+ />
93
+ );
94
+ })}
95
+ </div>
96
+
97
+ {/* Completion label */}
98
+ <div className="flex items-center justify-between text-xs text-muted-foreground">
99
+ <span>
100
+ {completed} of {total} explored
101
+ </span>
102
+ <span className="font-medium">
103
+ {percentage}%
104
+ </span>
105
+ </div>
106
+ </CardContent>
107
+ </Card>
108
+ </Link>
109
+ );
110
+ }
@@ -0,0 +1,22 @@
1
+ import Link from "next/link";
2
+ import { ArrowRight } from "lucide-react";
3
+
4
+ interface PlaybookActionButtonProps {
5
+ href: string;
6
+ children: React.ReactNode;
7
+ }
8
+
9
+ export function PlaybookActionButton({
10
+ href,
11
+ children,
12
+ }: PlaybookActionButtonProps) {
13
+ return (
14
+ <Link
15
+ href={href}
16
+ className="inline-flex items-center gap-1.5 rounded-full bg-primary px-4 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 no-underline"
17
+ >
18
+ {children}
19
+ <ArrowRight className="h-3.5 w-3.5" />
20
+ </Link>
21
+ );
22
+ }
@@ -0,0 +1,143 @@
1
+ "use client";
2
+
3
+ import { useState, useMemo } from "react";
4
+ import { Search, BookOpen } from "lucide-react";
5
+ import { Input } from "@/components/ui/input";
6
+ import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
7
+ import { EmptyState } from "@/components/shared/empty-state";
8
+ import { PlaybookCard } from "@/components/playbook/playbook-card";
9
+ import { JourneyCard } from "@/components/playbook/journey-card";
10
+ import type {
11
+ DocSection,
12
+ DocJourney,
13
+ AdoptionEntry,
14
+ JourneyCompletion,
15
+ } from "@/lib/docs/types";
16
+
17
+ type CategoryFilter = "all" | "features" | "journeys" | "getting-started";
18
+
19
+ interface PlaybookBrowserProps {
20
+ sections: DocSection[];
21
+ journeys: DocJourney[];
22
+ adoption: Record<string, AdoptionEntry>;
23
+ journeyCompletions: Record<string, JourneyCompletion>;
24
+ }
25
+
26
+ export function PlaybookBrowser({
27
+ sections,
28
+ journeys,
29
+ adoption,
30
+ journeyCompletions,
31
+ }: PlaybookBrowserProps) {
32
+ const [search, setSearch] = useState("");
33
+ const [category, setCategory] = useState<CategoryFilter>("all");
34
+
35
+ const filteredSections = useMemo(() => {
36
+ const q = search.toLowerCase();
37
+ return sections.filter((s) => {
38
+ if (!q) return true;
39
+ return (
40
+ s.title.toLowerCase().includes(q) ||
41
+ s.tags.some((t) => t.toLowerCase().includes(q)) ||
42
+ s.slug.toLowerCase().includes(q)
43
+ );
44
+ });
45
+ }, [sections, search]);
46
+
47
+ const filteredJourneys = useMemo(() => {
48
+ const q = search.toLowerCase();
49
+ return journeys.filter((j) => {
50
+ if (!q) return true;
51
+ return (
52
+ j.title.toLowerCase().includes(q) ||
53
+ j.persona.toLowerCase().includes(q) ||
54
+ j.slug.toLowerCase().includes(q)
55
+ );
56
+ });
57
+ }, [journeys, search]);
58
+
59
+ const showSections = category === "all" || category === "features";
60
+ const showJourneys = category === "all" || category === "journeys";
61
+ const isEmpty =
62
+ (showSections ? filteredSections.length : 0) +
63
+ (showJourneys ? filteredJourneys.length : 0) ===
64
+ 0;
65
+
66
+ return (
67
+ <div className="space-y-4">
68
+ {/* Search + Filter */}
69
+ <div className="surface-panel flex flex-col gap-4 rounded-2xl p-4 sm:flex-row sm:items-center">
70
+ <div className="relative flex-1">
71
+ <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
72
+ <Input
73
+ placeholder="Search docs..."
74
+ value={search}
75
+ onChange={(e) => setSearch(e.target.value)}
76
+ className="surface-control pl-9"
77
+ />
78
+ </div>
79
+ <Tabs
80
+ value={category}
81
+ onValueChange={(v) => setCategory(v as CategoryFilter)}
82
+ >
83
+ <TabsList className="surface-control">
84
+ <TabsTrigger value="all">All</TabsTrigger>
85
+ <TabsTrigger value="features">Features</TabsTrigger>
86
+ <TabsTrigger value="journeys">Journeys</TabsTrigger>
87
+ </TabsList>
88
+ </Tabs>
89
+ </div>
90
+
91
+ {isEmpty ? (
92
+ <EmptyState
93
+ icon={BookOpen}
94
+ heading="No docs found"
95
+ description="Try adjusting your search or filter."
96
+ />
97
+ ) : (
98
+ <div className="space-y-6">
99
+ {/* Journeys */}
100
+ {showJourneys && filteredJourneys.length > 0 && (
101
+ <div className="space-y-3">
102
+ {category === "all" && (
103
+ <h3 className="text-base font-medium text-muted-foreground uppercase tracking-wider">
104
+ Guided Journeys
105
+ </h3>
106
+ )}
107
+ <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
108
+ {filteredJourneys.map((journey) => (
109
+ <JourneyCard
110
+ key={journey.slug}
111
+ journey={journey}
112
+ completion={journeyCompletions[journey.slug]}
113
+ adoption={adoption}
114
+ />
115
+ ))}
116
+ </div>
117
+ </div>
118
+ )}
119
+
120
+ {/* Feature Sections */}
121
+ {showSections && filteredSections.length > 0 && (
122
+ <div className="space-y-3">
123
+ {category === "all" && (
124
+ <h3 className="text-base font-medium text-muted-foreground uppercase tracking-wider">
125
+ Feature Reference
126
+ </h3>
127
+ )}
128
+ <div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
129
+ {filteredSections.map((section) => (
130
+ <PlaybookCard
131
+ key={section.slug}
132
+ section={section}
133
+ adoption={adoption[section.slug]}
134
+ />
135
+ ))}
136
+ </div>
137
+ </div>
138
+ )}
139
+ </div>
140
+ )}
141
+ </div>
142
+ );
143
+ }