stagent 0.1.11 → 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.
- package/README.md +35 -4
- package/package.json +3 -2
- package/src/__tests__/e2e/blueprint.test.ts +63 -0
- package/src/__tests__/e2e/cross-runtime.test.ts +77 -0
- package/src/__tests__/e2e/helpers.ts +286 -0
- package/src/__tests__/e2e/parallel-workflow.test.ts +120 -0
- package/src/__tests__/e2e/sequence-workflow.test.ts +109 -0
- package/src/__tests__/e2e/setup.ts +156 -0
- package/src/__tests__/e2e/single-task.test.ts +170 -0
- package/src/app/api/command-palette/recent/route.ts +41 -18
- package/src/app/api/context/batch/route.ts +44 -0
- package/src/app/api/permissions/presets/route.ts +80 -0
- package/src/app/api/playbook/status/route.ts +15 -0
- package/src/app/api/profiles/route.ts +23 -20
- package/src/app/api/settings/pricing/route.ts +15 -0
- package/src/app/costs/page.tsx +53 -43
- package/src/app/playbook/[slug]/page.tsx +76 -0
- package/src/app/playbook/page.tsx +54 -0
- package/src/app/profiles/page.tsx +7 -4
- package/src/app/settings/page.tsx +2 -2
- package/src/components/costs/cost-dashboard.tsx +226 -320
- package/src/components/dashboard/activity-feed.tsx +6 -2
- package/src/components/notifications/batch-proposal-review.tsx +150 -0
- package/src/components/notifications/notification-item.tsx +6 -3
- package/src/components/notifications/pending-approval-host.tsx +57 -11
- package/src/components/playbook/adoption-heatmap.tsx +69 -0
- package/src/components/playbook/journey-card.tsx +110 -0
- package/src/components/playbook/playbook-action-button.tsx +22 -0
- package/src/components/playbook/playbook-browser.tsx +143 -0
- package/src/components/playbook/playbook-card.tsx +102 -0
- package/src/components/playbook/playbook-detail-view.tsx +223 -0
- package/src/components/playbook/playbook-homepage.tsx +142 -0
- package/src/components/playbook/playbook-toc.tsx +90 -0
- package/src/components/playbook/playbook-updated-badge.tsx +23 -0
- package/src/components/playbook/related-docs.tsx +30 -0
- package/src/components/profiles/__tests__/learned-context-panel.test.tsx +175 -0
- package/src/components/profiles/context-proposal-review.tsx +7 -3
- package/src/components/profiles/learned-context-panel.tsx +116 -8
- package/src/components/profiles/profile-detail-view.tsx +6 -3
- package/src/components/settings/__tests__/auth-config-section.test.tsx +147 -0
- package/src/components/settings/api-key-form.tsx +5 -43
- package/src/components/settings/auth-config-section.tsx +10 -6
- package/src/components/settings/auth-status-badge.tsx +8 -0
- package/src/components/settings/budget-guardrails-section.tsx +403 -620
- package/src/components/settings/connection-test-control.tsx +63 -0
- package/src/components/settings/permissions-section.tsx +85 -75
- package/src/components/settings/permissions-sections.tsx +24 -0
- package/src/components/settings/presets-section.tsx +159 -0
- package/src/components/settings/pricing-registry-panel.tsx +164 -0
- package/src/components/shared/app-sidebar.tsx +2 -0
- package/src/components/shared/command-palette.tsx +30 -0
- package/src/components/shared/light-markdown.tsx +134 -0
- package/src/components/workflows/loop-status-view.tsx +8 -4
- package/src/components/workflows/workflow-status-view.tsx +16 -9
- package/src/lib/agents/learned-context.ts +27 -15
- package/src/lib/agents/learning-session.ts +234 -0
- package/src/lib/agents/pattern-extractor.ts +19 -0
- package/src/lib/agents/profiles/__tests__/sort.test.ts +42 -0
- package/src/lib/agents/profiles/sort.ts +7 -0
- package/src/lib/constants/settings.ts +1 -0
- package/src/lib/db/schema.ts +3 -0
- package/src/lib/docs/adoption.ts +105 -0
- package/src/lib/docs/journey-tracker.ts +21 -0
- package/src/lib/docs/reader.ts +102 -0
- package/src/lib/docs/types.ts +54 -0
- package/src/lib/docs/usage-stage.ts +60 -0
- package/src/lib/notifications/actionable.ts +18 -10
- package/src/lib/settings/__tests__/budget-guardrails.test.ts +86 -24
- package/src/lib/settings/budget-guardrails.ts +213 -85
- package/src/lib/settings/permission-presets.ts +150 -0
- package/src/lib/settings/runtime-setup.ts +71 -0
- package/src/lib/usage/__tests__/ledger.test.ts +2 -2
- package/src/lib/usage/__tests__/pricing-registry.test.ts +78 -0
- package/src/lib/usage/ledger.ts +1 -1
- package/src/lib/usage/pricing-registry.ts +570 -0
- package/src/lib/usage/pricing.ts +15 -95
- package/src/lib/utils/__tests__/learned-context-history.test.ts +171 -0
- package/src/lib/utils/learned-context-history.ts +150 -0
- package/src/lib/validators/__tests__/settings.test.ts +23 -16
- package/src/lib/validators/settings.ts +3 -9
- package/src/lib/workflows/engine.ts +18 -0
|
@@ -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 &&
|
|
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
|
-
<
|
|
178
|
-
{notification.body}
|
|
179
|
-
|
|
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 === "
|
|
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 === "
|
|
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
|
-
<
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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
|
+
}
|