stagent 0.1.11 → 0.1.13

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 (145) hide show
  1. package/README.md +74 -49
  2. package/package.json +3 -2
  3. package/public/readme/cost-usage-list.png +0 -0
  4. package/public/readme/dashboard-bulk-select.png +0 -0
  5. package/public/readme/dashboard-card-edit.png +0 -0
  6. package/public/readme/dashboard-create-form-ai-applied.png +0 -0
  7. package/public/readme/dashboard-create-form-ai-assist.png +0 -0
  8. package/public/readme/dashboard-create-form-empty.png +0 -0
  9. package/public/readme/dashboard-create-form-filled.png +0 -0
  10. package/public/readme/dashboard-filtered.png +0 -0
  11. package/public/readme/dashboard-list.png +0 -0
  12. package/public/readme/dashboard-workflow-confirm.png +0 -0
  13. package/public/readme/home-below-fold.png +0 -0
  14. package/public/readme/home-list.png +0 -0
  15. package/public/readme/inbox-list.png +0 -0
  16. package/public/readme/playbook-list.png +0 -0
  17. package/public/readme/profiles-list.png +0 -0
  18. package/public/readme/settings-list.png +0 -0
  19. package/public/readme/workflows-list.png +0 -0
  20. package/src/__tests__/e2e/blueprint.test.ts +63 -0
  21. package/src/__tests__/e2e/cross-runtime.test.ts +77 -0
  22. package/src/__tests__/e2e/helpers.ts +286 -0
  23. package/src/__tests__/e2e/parallel-workflow.test.ts +120 -0
  24. package/src/__tests__/e2e/sequence-workflow.test.ts +109 -0
  25. package/src/__tests__/e2e/setup.ts +156 -0
  26. package/src/__tests__/e2e/single-task.test.ts +170 -0
  27. package/src/app/api/command-palette/recent/route.ts +41 -18
  28. package/src/app/api/context/batch/route.ts +44 -0
  29. package/src/app/api/permissions/presets/route.ts +80 -0
  30. package/src/app/api/playbook/status/route.ts +15 -0
  31. package/src/app/api/profiles/route.ts +23 -20
  32. package/src/app/api/settings/pricing/route.ts +15 -0
  33. package/src/app/api/tasks/[id]/route.ts +54 -3
  34. package/src/app/api/workflows/[id]/route.ts +43 -4
  35. package/src/app/api/workflows/[id]/status/route.ts +70 -2
  36. package/src/app/api/workflows/from-assist/route.ts +6 -32
  37. package/src/app/costs/page.tsx +53 -43
  38. package/src/app/dashboard/page.tsx +59 -21
  39. package/src/app/documents/[id]/page.tsx +10 -8
  40. package/src/app/globals.css +11 -0
  41. package/src/app/page.tsx +60 -3
  42. package/src/app/playbook/[slug]/page.tsx +76 -0
  43. package/src/app/playbook/page.tsx +54 -0
  44. package/src/app/profiles/page.tsx +7 -4
  45. package/src/app/settings/page.tsx +2 -2
  46. package/src/app/tasks/[id]/page.tsx +22 -2
  47. package/src/components/costs/cost-dashboard.tsx +226 -320
  48. package/src/components/dashboard/activity-feed.tsx +6 -2
  49. package/src/components/dashboard/greeting.tsx +3 -1
  50. package/src/components/dashboard/priority-queue.tsx +58 -9
  51. package/src/components/dashboard/stats-cards.tsx +16 -2
  52. package/src/components/documents/document-chip-bar.tsx +183 -0
  53. package/src/components/documents/document-content-renderer.tsx +146 -0
  54. package/src/components/documents/document-detail-view.tsx +16 -239
  55. package/src/components/documents/image-zoom-view.tsx +60 -0
  56. package/src/components/documents/smart-extracted-text.tsx +47 -0
  57. package/src/components/documents/utils.ts +70 -0
  58. package/src/components/notifications/batch-proposal-review.tsx +150 -0
  59. package/src/components/notifications/inbox-list.tsx +4 -5
  60. package/src/components/notifications/notification-item.tsx +73 -6
  61. package/src/components/notifications/pending-approval-host.tsx +63 -14
  62. package/src/components/playbook/adoption-heatmap.tsx +69 -0
  63. package/src/components/playbook/journey-card.tsx +110 -0
  64. package/src/components/playbook/playbook-action-button.tsx +22 -0
  65. package/src/components/playbook/playbook-browser.tsx +143 -0
  66. package/src/components/playbook/playbook-card.tsx +102 -0
  67. package/src/components/playbook/playbook-detail-view.tsx +225 -0
  68. package/src/components/playbook/playbook-homepage.tsx +142 -0
  69. package/src/components/playbook/playbook-toc.tsx +90 -0
  70. package/src/components/playbook/playbook-updated-badge.tsx +23 -0
  71. package/src/components/playbook/related-docs.tsx +30 -0
  72. package/src/components/profiles/__tests__/learned-context-panel.test.tsx +175 -0
  73. package/src/components/profiles/context-proposal-review.tsx +7 -3
  74. package/src/components/profiles/learned-context-panel.tsx +116 -8
  75. package/src/components/profiles/profile-browser.tsx +1 -0
  76. package/src/components/profiles/profile-card.tsx +16 -8
  77. package/src/components/profiles/profile-detail-view.tsx +12 -4
  78. package/src/components/settings/__tests__/auth-config-section.test.tsx +147 -0
  79. package/src/components/settings/api-key-form.tsx +5 -43
  80. package/src/components/settings/auth-config-section.tsx +10 -6
  81. package/src/components/settings/auth-status-badge.tsx +8 -0
  82. package/src/components/settings/budget-guardrails-section.tsx +403 -620
  83. package/src/components/settings/connection-test-control.tsx +63 -0
  84. package/src/components/settings/permissions-section.tsx +85 -75
  85. package/src/components/settings/permissions-sections.tsx +24 -0
  86. package/src/components/settings/presets-section.tsx +159 -0
  87. package/src/components/settings/pricing-registry-panel.tsx +164 -0
  88. package/src/components/shared/app-sidebar.tsx +4 -2
  89. package/src/components/shared/command-palette.tsx +30 -0
  90. package/src/components/shared/light-markdown.tsx +134 -0
  91. package/src/components/tasks/__tests__/kanban-board-accessibility.test.tsx +1 -1
  92. package/src/components/tasks/ai-assist-panel.tsx +108 -78
  93. package/src/components/tasks/content-preview.tsx +2 -1
  94. package/src/components/tasks/kanban-board.tsx +57 -5
  95. package/src/components/tasks/kanban-column.tsx +34 -23
  96. package/src/components/tasks/task-bento-cell.tsx +50 -0
  97. package/src/components/tasks/task-bento-grid.tsx +155 -0
  98. package/src/components/tasks/task-card.tsx +14 -16
  99. package/src/components/tasks/task-chip-bar.tsx +207 -0
  100. package/src/components/tasks/task-detail-view.tsx +42 -190
  101. package/src/components/tasks/task-result-renderer.tsx +33 -0
  102. package/src/components/workflows/blueprint-gallery.tsx +19 -12
  103. package/src/components/workflows/blueprint-preview.tsx +8 -1
  104. package/src/components/workflows/loop-status-view.tsx +2 -4
  105. package/src/components/workflows/swarm-dashboard.tsx +2 -3
  106. package/src/components/workflows/workflow-confirmation-view.tsx +2 -7
  107. package/src/components/workflows/workflow-full-output.tsx +80 -0
  108. package/src/components/workflows/workflow-kanban-card.tsx +121 -0
  109. package/src/components/workflows/workflow-list.tsx +47 -42
  110. package/src/components/workflows/workflow-status-view.tsx +163 -16
  111. package/src/lib/agents/learned-context.ts +27 -15
  112. package/src/lib/agents/learning-session.ts +354 -0
  113. package/src/lib/agents/pattern-extractor.ts +19 -0
  114. package/src/lib/agents/profiles/__tests__/sort.test.ts +42 -0
  115. package/src/lib/agents/profiles/sort.ts +7 -0
  116. package/src/lib/constants/card-icons.tsx +202 -0
  117. package/src/lib/constants/prose-styles.ts +7 -0
  118. package/src/lib/constants/settings.ts +1 -0
  119. package/src/lib/constants/task-status.ts +3 -0
  120. package/src/lib/db/schema.ts +3 -0
  121. package/src/lib/docs/adoption.ts +105 -0
  122. package/src/lib/docs/journey-tracker.ts +21 -0
  123. package/src/lib/docs/reader.ts +107 -0
  124. package/src/lib/docs/types.ts +54 -0
  125. package/src/lib/docs/usage-stage.ts +60 -0
  126. package/src/lib/documents/context-builder.ts +41 -0
  127. package/src/lib/notifications/actionable.ts +18 -10
  128. package/src/lib/queries/chart-data.ts +20 -1
  129. package/src/lib/settings/__tests__/budget-guardrails.test.ts +86 -24
  130. package/src/lib/settings/budget-guardrails.ts +213 -85
  131. package/src/lib/settings/permission-presets.ts +150 -0
  132. package/src/lib/settings/runtime-setup.ts +71 -0
  133. package/src/lib/usage/__tests__/ledger.test.ts +2 -2
  134. package/src/lib/usage/__tests__/pricing-registry.test.ts +78 -0
  135. package/src/lib/usage/ledger.ts +1 -1
  136. package/src/lib/usage/pricing-registry.ts +570 -0
  137. package/src/lib/usage/pricing.ts +15 -95
  138. package/src/lib/utils/__tests__/learned-context-history.test.ts +171 -0
  139. package/src/lib/utils/learned-context-history.ts +150 -0
  140. package/src/lib/validators/__tests__/settings.test.ts +23 -16
  141. package/src/lib/validators/settings.ts +3 -9
  142. package/src/lib/workflows/engine.ts +75 -61
  143. package/src/lib/workflows/types.ts +2 -0
  144. package/tsconfig.json +2 -1
  145. package/src/components/documents/document-preview.tsx +0 -68
@@ -0,0 +1,175 @@
1
+ import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
2
+
3
+ import { LearnedContextPanel } from "@/components/profiles/learned-context-panel";
4
+ import type { LearnedContextRow } from "@/lib/db/schema";
5
+
6
+ const { fetchMock } = vi.hoisted(() => ({
7
+ fetchMock: vi.fn(),
8
+ }));
9
+
10
+ vi.mock("sonner", () => ({
11
+ toast: {
12
+ success: vi.fn(),
13
+ error: vi.fn(),
14
+ },
15
+ }));
16
+
17
+ vi.mock("@/lib/utils/format-timestamp", () => ({
18
+ formatTimestamp: () => "just now",
19
+ }));
20
+
21
+ function makeRow(
22
+ overrides: Partial<LearnedContextRow> & {
23
+ id: string;
24
+ version: number;
25
+ changeType: LearnedContextRow["changeType"];
26
+ }
27
+ ): LearnedContextRow {
28
+ return {
29
+ id: overrides.id,
30
+ profileId: overrides.profileId ?? "general",
31
+ version: overrides.version,
32
+ content: overrides.content ?? null,
33
+ diff: overrides.diff ?? null,
34
+ changeType: overrides.changeType,
35
+ sourceTaskId: overrides.sourceTaskId ?? null,
36
+ proposalNotificationId: overrides.proposalNotificationId ?? null,
37
+ proposedAdditions: overrides.proposedAdditions ?? null,
38
+ approvedBy: overrides.approvedBy ?? null,
39
+ createdAt: overrides.createdAt ?? new Date("2026-03-17T12:00:00.000Z"),
40
+ };
41
+ }
42
+
43
+ describe("LearnedContextPanel", () => {
44
+ beforeEach(() => {
45
+ fetchMock.mockReset();
46
+ vi.stubGlobal("fetch", fetchMock);
47
+ });
48
+
49
+ afterEach(() => {
50
+ vi.unstubAllGlobals();
51
+ vi.clearAllMocks();
52
+ });
53
+
54
+ it("shows singular and plural version labels", async () => {
55
+ fetchMock.mockResolvedValueOnce({
56
+ ok: true,
57
+ json: vi.fn().mockResolvedValue({
58
+ history: [
59
+ makeRow({
60
+ id: "v1",
61
+ version: 1,
62
+ changeType: "approved",
63
+ content: "Validate inputs",
64
+ diff: "Validate inputs",
65
+ }),
66
+ ],
67
+ currentSize: 15,
68
+ limit: 8000,
69
+ needsSummarization: false,
70
+ }),
71
+ } as Response);
72
+
73
+ const { rerender } = render(<LearnedContextPanel profileId="general" />);
74
+
75
+ expect(await screen.findByText("1 version")).toBeInTheDocument();
76
+
77
+ fetchMock.mockResolvedValueOnce({
78
+ ok: true,
79
+ json: vi.fn().mockResolvedValue({
80
+ history: [
81
+ makeRow({
82
+ id: "v2",
83
+ version: 2,
84
+ changeType: "approved",
85
+ content: "Validate inputs\n\nUse retries",
86
+ diff: "Use retries",
87
+ }),
88
+ makeRow({
89
+ id: "v1",
90
+ version: 1,
91
+ changeType: "approved",
92
+ content: "Validate inputs",
93
+ diff: "Validate inputs",
94
+ }),
95
+ ],
96
+ currentSize: 28,
97
+ limit: 8000,
98
+ needsSummarization: false,
99
+ }),
100
+ } as Response);
101
+
102
+ rerender(<LearnedContextPanel profileId="general-v2" />);
103
+
104
+ expect(await screen.findByText("2 versions")).toBeInTheDocument();
105
+ });
106
+
107
+ it("renders badges, rollback content preview, and a unified diff toggle", async () => {
108
+ fetchMock.mockResolvedValueOnce({
109
+ ok: true,
110
+ json: vi.fn().mockResolvedValue({
111
+ history: [
112
+ makeRow({
113
+ id: "v4",
114
+ version: 4,
115
+ changeType: "rollback",
116
+ content: "Validate inputs",
117
+ diff: "Rolled back to version 1",
118
+ }),
119
+ makeRow({
120
+ id: "v3",
121
+ version: 3,
122
+ changeType: "summarization",
123
+ content: "Validate inputs\nUse retries",
124
+ diff: "Summarized from 80 to 42 chars",
125
+ }),
126
+ makeRow({
127
+ id: "v2",
128
+ version: 2,
129
+ changeType: "proposal",
130
+ diff: "Always include retry guidance",
131
+ }),
132
+ makeRow({
133
+ id: "v1",
134
+ version: 1,
135
+ changeType: "approved",
136
+ content: "Validate inputs",
137
+ diff: "Validate inputs",
138
+ }),
139
+ ],
140
+ currentSize: 32,
141
+ limit: 8000,
142
+ needsSummarization: false,
143
+ }),
144
+ } as Response);
145
+
146
+ render(<LearnedContextPanel profileId="general" />);
147
+
148
+ expect((await screen.findAllByText("Rollback")).length).toBeGreaterThan(0);
149
+ expect(screen.getByText("Summarized")).toBeInTheDocument();
150
+ expect(screen.getByText("Proposed")).toBeInTheDocument();
151
+ expect(screen.getAllByText("Approved")).not.toHaveLength(0);
152
+
153
+ const rollbackCard = screen.getByText("v4").closest(".surface-card-muted");
154
+ expect(rollbackCard).not.toBeNull();
155
+ expect(
156
+ within(rollbackCard as HTMLElement).getByText("Restored Context")
157
+ ).toBeInTheDocument();
158
+ expect(
159
+ within(rollbackCard as HTMLElement).getByText("Validate inputs")
160
+ ).toBeInTheDocument();
161
+
162
+ const showDiffButtons = screen.getAllByRole("button", { name: /Show Diff/i });
163
+ fireEvent.click(showDiffButtons[0]);
164
+
165
+ const diffPanel = await screen.findByText("Unified Diff");
166
+ expect(diffPanel).toBeInTheDocument();
167
+ expect(screen.getByText("vs v3")).toBeInTheDocument();
168
+ expect(
169
+ screen.getByText((content, element) => {
170
+ return content === "-" && element?.getAttribute("aria-hidden") === "true";
171
+ })
172
+ ).toBeInTheDocument();
173
+ expect(screen.getByRole("button", { name: /Hide Diff/i })).toBeInTheDocument();
174
+ });
175
+ });
@@ -6,6 +6,7 @@ import { toast } from "sonner";
6
6
 
7
7
  import { Button } from "@/components/ui/button";
8
8
  import { Textarea } from "@/components/ui/textarea";
9
+ import { LightMarkdown } from "@/components/shared/light-markdown";
9
10
 
10
11
  interface ContextProposalReviewProps {
11
12
  notificationId: string;
@@ -99,9 +100,12 @@ export function ContextProposalReview({
99
100
  placeholder="Edit the proposed context additions..."
100
101
  />
101
102
  ) : (
102
- <pre className="max-h-48 overflow-auto whitespace-pre-wrap rounded-lg bg-background/50 p-3 text-xs text-foreground">
103
- {proposedAdditions}
104
- </pre>
103
+ <LightMarkdown
104
+ content={proposedAdditions}
105
+ maxHeight="max-h-48"
106
+ stripBracketTags
107
+ className="rounded-lg bg-background/50 p-3"
108
+ />
105
109
  )}
106
110
  </div>
107
111
 
@@ -3,6 +3,8 @@
3
3
  import { useCallback, useEffect, useState } from "react";
4
4
  import {
5
5
  Brain,
6
+ ChevronDown,
7
+ ChevronUp,
6
8
  Check,
7
9
  Clock,
8
10
  History,
@@ -10,7 +12,6 @@ import {
10
12
  Plus,
11
13
  RotateCcw,
12
14
  Sparkles,
13
- X,
14
15
  } from "lucide-react";
15
16
  import { toast } from "sonner";
16
17
 
@@ -18,8 +19,13 @@ import { Badge } from "@/components/ui/badge";
18
19
  import { Button } from "@/components/ui/button";
19
20
  import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
20
21
  import { Textarea } from "@/components/ui/textarea";
22
+ import { LightMarkdown } from "@/components/shared/light-markdown";
21
23
  import { formatTimestamp } from "@/lib/utils/format-timestamp";
22
24
  import type { LearnedContextRow } from "@/lib/db/schema";
25
+ import {
26
+ buildLearnedContextHistoryEntries,
27
+ hasMeaningfulDerivedDiff,
28
+ } from "@/lib/utils/learned-context-history";
23
29
 
24
30
  interface ContextHistoryResponse {
25
31
  history: LearnedContextRow[];
@@ -49,6 +55,7 @@ export function LearnedContextPanel({ profileId }: LearnedContextPanelProps) {
49
55
  const [addingManual, setAddingManual] = useState(false);
50
56
  const [manualContent, setManualContent] = useState("");
51
57
  const [submitting, setSubmitting] = useState(false);
58
+ const [expandedDiffs, setExpandedDiffs] = useState<Record<string, boolean>>({});
52
59
 
53
60
  const refresh = useCallback(async () => {
54
61
  try {
@@ -66,6 +73,10 @@ export function LearnedContextPanel({ profileId }: LearnedContextPanelProps) {
66
73
  refresh();
67
74
  }, [refresh]);
68
75
 
76
+ useEffect(() => {
77
+ setExpandedDiffs({});
78
+ }, [profileId]);
79
+
69
80
  async function handleAddManual() {
70
81
  if (!manualContent.trim()) return;
71
82
  setSubmitting(true);
@@ -121,6 +132,7 @@ export function LearnedContextPanel({ profileId }: LearnedContextPanelProps) {
121
132
  }
122
133
 
123
134
  const history = data?.history ?? [];
135
+ const entries = buildLearnedContextHistoryEntries(history);
124
136
  const currentSize = data?.currentSize ?? 0;
125
137
  const limit = data?.limit ?? 8000;
126
138
  const usagePercent = Math.min((currentSize / limit) * 100, 100);
@@ -134,7 +146,7 @@ export function LearnedContextPanel({ profileId }: LearnedContextPanelProps) {
134
146
  Learned Context
135
147
  {history.length > 0 && (
136
148
  <Badge variant="secondary" className="text-xs">
137
- {history.length} versions
149
+ {history.length} {history.length === 1 ? "version" : "versions"}
138
150
  </Badge>
139
151
  )}
140
152
  </CardTitle>
@@ -223,11 +235,25 @@ export function LearnedContextPanel({ profileId }: LearnedContextPanelProps) {
223
235
  Version History
224
236
  </div>
225
237
  <div className="max-h-72 space-y-2 overflow-y-auto">
226
- {history.map((row) => {
238
+ {entries.map((entry) => {
239
+ const { row, snapshotContent, derivedDiff } = entry;
227
240
  const badgeConfig = CHANGE_TYPE_BADGE[row.changeType] ?? {
228
241
  variant: "outline" as const,
229
242
  label: row.changeType,
230
243
  };
244
+ const diffId = `learned-context-diff-${row.id}`;
245
+ const diffExpanded = expandedDiffs[row.id] ?? false;
246
+ const canShowDiff = hasMeaningfulDerivedDiff(derivedDiff);
247
+ const previewLabel =
248
+ row.changeType === "rollback"
249
+ ? "Restored Context"
250
+ : row.changeType === "summarization"
251
+ ? "Summarized Context"
252
+ : row.changeType === "approved"
253
+ ? "Approved Context"
254
+ : row.changeType === "proposal"
255
+ ? "Proposed Additions"
256
+ : "Change Summary";
231
257
  return (
232
258
  <div
233
259
  key={row.id}
@@ -269,11 +295,93 @@ export function LearnedContextPanel({ profileId }: LearnedContextPanelProps) {
269
295
  </span>
270
296
  </div>
271
297
  </div>
272
- {row.diff && (
273
- <pre className="mt-2 max-h-24 overflow-auto whitespace-pre-wrap rounded-md bg-background/50 p-2 text-xs text-muted-foreground">
274
- {row.diff}
275
- </pre>
276
- )}
298
+ <div className="mt-3 space-y-2">
299
+ <div className="flex items-center justify-between gap-3">
300
+ <p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
301
+ {previewLabel}
302
+ </p>
303
+ {canShowDiff && (
304
+ <Button
305
+ size="sm"
306
+ variant="ghost"
307
+ className="h-6 px-2 text-xs"
308
+ aria-expanded={diffExpanded}
309
+ aria-controls={diffId}
310
+ onClick={() =>
311
+ setExpandedDiffs((current) => ({
312
+ ...current,
313
+ [row.id]: !current[row.id],
314
+ }))
315
+ }
316
+ >
317
+ {diffExpanded ? (
318
+ <ChevronUp className="mr-1 h-3 w-3" />
319
+ ) : (
320
+ <ChevronDown className="mr-1 h-3 w-3" />
321
+ )}
322
+ {diffExpanded ? "Hide Diff" : "Show Diff"}
323
+ </Button>
324
+ )}
325
+ </div>
326
+
327
+ {snapshotContent ? (
328
+ <LightMarkdown
329
+ content={snapshotContent}
330
+ maxHeight="max-h-28"
331
+ className="rounded-md bg-background/50 p-2"
332
+ />
333
+ ) : row.diff ? (
334
+ <LightMarkdown
335
+ content={row.diff}
336
+ maxHeight="max-h-24"
337
+ className="rounded-md bg-background/50 p-2"
338
+ />
339
+ ) : null}
340
+
341
+ {canShowDiff && diffExpanded && derivedDiff && (
342
+ <div
343
+ id={diffId}
344
+ className="surface-scroll rounded-md border border-border/60 bg-background/60 p-2"
345
+ >
346
+ <div className="mb-2 flex items-center justify-between gap-2 text-[11px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
347
+ <span>Unified Diff</span>
348
+ <span>
349
+ {derivedDiff.previousVersion === null
350
+ ? "Initial version"
351
+ : `vs v${derivedDiff.previousVersion}`}
352
+ </span>
353
+ </div>
354
+ <div className="max-h-44 space-y-1 overflow-y-auto font-mono text-xs">
355
+ {derivedDiff.lines.map((line, index) => {
356
+ const toneClass =
357
+ line.kind === "added"
358
+ ? "bg-status-completed/10 text-status-completed"
359
+ : line.kind === "removed"
360
+ ? "bg-destructive/10 text-destructive"
361
+ : "bg-muted/40 text-muted-foreground";
362
+ const prefix =
363
+ line.kind === "added"
364
+ ? "+"
365
+ : line.kind === "removed"
366
+ ? "-"
367
+ : " ";
368
+
369
+ return (
370
+ <div
371
+ key={`${row.id}-diff-${index}`}
372
+ className={`grid grid-cols-[auto_minmax(0,1fr)] gap-2 rounded-sm px-2 py-1 ${toneClass}`}
373
+ >
374
+ <span aria-hidden="true">{prefix}</span>
375
+ <span className="whitespace-pre-wrap break-words">
376
+ {line.value || " "}
377
+ </span>
378
+ </div>
379
+ );
380
+ })}
381
+ </div>
382
+ </div>
383
+ )}
384
+ </div>
277
385
  </div>
278
386
  );
279
387
  })}
@@ -107,6 +107,7 @@ export function ProfileBrowser({ initialProfiles }: ProfileBrowserProps) {
107
107
  <ProfileCard
108
108
  key={profile.id}
109
109
  profile={profile}
110
+ isBuiltin={profile.isBuiltin}
110
111
  onClick={() => router.push(`/profiles/${profile.id}`)}
111
112
  />
112
113
  ))}
@@ -4,14 +4,16 @@ import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
4
4
  import { Badge } from "@/components/ui/badge";
5
5
  import { listRuntimeCatalog } from "@/lib/agents/runtime/catalog";
6
6
  import { getSupportedRuntimes } from "@/lib/agents/profiles/compatibility";
7
+ import { IconCircle, getProfileIcon, getDomainColors } from "@/lib/constants/card-icons";
7
8
  import type { AgentProfile } from "@/lib/agents/profiles/types";
8
9
 
9
10
  interface ProfileCardProps {
10
11
  profile: AgentProfile;
12
+ isBuiltin?: boolean;
11
13
  onClick: () => void;
12
14
  }
13
15
 
14
- export function ProfileCard({ profile, onClick }: ProfileCardProps) {
16
+ export function ProfileCard({ profile, isBuiltin = true, onClick }: ProfileCardProps) {
15
17
  const runtimeLabelMap = new Map(
16
18
  listRuntimeCatalog().map((runtime) => [
17
19
  runtime.id,
@@ -32,13 +34,19 @@ export function ProfileCard({ profile, onClick }: ProfileCardProps) {
32
34
  }
33
35
  }}
34
36
  >
35
- <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
36
- <CardTitle className="text-base font-medium">{profile.name}</CardTitle>
37
- <Badge
38
- variant={profile.domain === "work" ? "default" : "secondary"}
39
- >
40
- {profile.domain}
41
- </Badge>
37
+ <CardHeader className="flex flex-row items-center gap-3 space-y-0 pb-2">
38
+ <IconCircle
39
+ icon={getProfileIcon(profile.id)}
40
+ colors={getDomainColors(profile.domain, isBuiltin)}
41
+ />
42
+ <div className="flex min-w-0 flex-1 items-center justify-between">
43
+ <CardTitle className="truncate text-base font-medium">{profile.name}</CardTitle>
44
+ <Badge
45
+ variant={profile.domain === "work" ? "default" : "secondary"}
46
+ >
47
+ {profile.domain}
48
+ </Badge>
49
+ </div>
42
50
  </CardHeader>
43
51
  <CardContent className="space-y-3">
44
52
  <p className="line-clamp-2 text-sm text-muted-foreground">
@@ -6,6 +6,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
6
6
  import { Badge } from "@/components/ui/badge";
7
7
  import { Button } from "@/components/ui/button";
8
8
  import { Skeleton } from "@/components/ui/skeleton";
9
+ import { LightMarkdown } from "@/components/shared/light-markdown";
9
10
  import {
10
11
  Copy,
11
12
  Pencil,
@@ -39,6 +40,7 @@ import {
39
40
  getSupportedRuntimes,
40
41
  } from "@/lib/agents/profiles/compatibility";
41
42
  import type { AgentProfile } from "@/lib/agents/profiles/types";
43
+ import { IconCircle, getProfileIcon, getDomainColors } from "@/lib/constants/card-icons";
42
44
 
43
45
  interface TestResult {
44
46
  task: string;
@@ -178,7 +180,11 @@ export function ProfileDetailView({ profileId, isBuiltin, initialProfile }: Prof
178
180
  <div className="space-y-6" aria-live="polite">
179
181
  {/* Header */}
180
182
  <div className="flex items-center justify-between">
181
- <div className="flex items-center gap-2">
183
+ <div className="flex items-center gap-3">
184
+ <IconCircle
185
+ icon={getProfileIcon(profile.id)}
186
+ colors={getDomainColors(profile.domain, isBuiltin)}
187
+ />
182
188
  <h1 className="text-2xl font-bold">{profile.name}</h1>
183
189
  <Badge variant={profile.domain === "work" ? "default" : "secondary"}>
184
190
  {profile.domain}
@@ -420,9 +426,11 @@ export function ProfileDetailView({ profileId, isBuiltin, initialProfile }: Prof
420
426
  <span className="text-muted-foreground text-xs group-open:rotate-90 transition-transform">▶</span>
421
427
  </summary>
422
428
  <div className="surface-panel mt-2 rounded-lg p-4">
423
- <pre className="surface-scroll max-h-64 overflow-auto whitespace-pre-wrap rounded-lg p-4 text-xs">
424
- {profile.skillMd}
425
- </pre>
429
+ <LightMarkdown
430
+ content={profile.skillMd}
431
+ maxHeight="max-h-64"
432
+ className="surface-scroll rounded-lg p-4"
433
+ />
426
434
  </div>
427
435
  </details>
428
436
  )}
@@ -0,0 +1,147 @@
1
+ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+
4
+ import { AuthConfigSection } from "@/components/settings/auth-config-section";
5
+
6
+ vi.mock("@/lib/agents/runtime/catalog", () => ({
7
+ DEFAULT_AGENT_RUNTIME: "claude-code",
8
+ getRuntimeCatalogEntry: () => ({
9
+ id: "claude-code",
10
+ label: "Claude Code",
11
+ }),
12
+ }));
13
+
14
+ describe("auth config section", () => {
15
+ beforeEach(() => {
16
+ vi.clearAllMocks();
17
+ vi.stubGlobal(
18
+ "fetch",
19
+ vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
20
+ const url = String(input);
21
+ const method = init?.method ?? "GET";
22
+
23
+ if (url === "/api/settings" && method === "GET") {
24
+ return {
25
+ ok: true,
26
+ json: async () => ({
27
+ method: "oauth",
28
+ hasKey: false,
29
+ apiKeySource: "oauth",
30
+ }),
31
+ };
32
+ }
33
+
34
+ if (url === "/api/settings/test" && method === "POST") {
35
+ return {
36
+ ok: true,
37
+ json: async () => ({
38
+ connected: true,
39
+ apiKeySource: "oauth",
40
+ }),
41
+ };
42
+ }
43
+
44
+ if (url === "/api/settings" && method === "POST") {
45
+ const body = JSON.parse(String(init?.body ?? "{}")) as { method?: string };
46
+ return {
47
+ ok: true,
48
+ json: async () => ({
49
+ method: body.method ?? "oauth",
50
+ hasKey: false,
51
+ apiKeySource: body.method === "oauth" ? "oauth" : "unknown",
52
+ }),
53
+ };
54
+ }
55
+
56
+ throw new Error(`Unexpected fetch: ${method} ${url}`);
57
+ })
58
+ );
59
+ });
60
+
61
+ afterEach(() => {
62
+ vi.unstubAllGlobals();
63
+ });
64
+
65
+ it("renders an OAuth test button and shows success feedback", async () => {
66
+ render(<AuthConfigSection />);
67
+
68
+ const testButton = await screen.findByRole("button", { name: "Test Connection" });
69
+ expect(testButton).toBeInTheDocument();
70
+ expect(screen.queryByText("Test OAuth connection")).not.toBeInTheDocument();
71
+
72
+ fireEvent.click(testButton);
73
+
74
+ await waitFor(() => {
75
+ expect(screen.getByText("Connected")).toBeInTheDocument();
76
+ });
77
+ });
78
+
79
+ it("shows the returned error message when the OAuth test fails", async () => {
80
+ vi.stubGlobal(
81
+ "fetch",
82
+ vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
83
+ const url = String(input);
84
+ const method = init?.method ?? "GET";
85
+
86
+ if (url === "/api/settings" && method === "GET") {
87
+ return {
88
+ ok: true,
89
+ json: async () => ({
90
+ method: "oauth",
91
+ hasKey: false,
92
+ apiKeySource: "oauth",
93
+ }),
94
+ };
95
+ }
96
+
97
+ if (url === "/api/settings/test" && method === "POST") {
98
+ return {
99
+ ok: true,
100
+ json: async () => ({
101
+ connected: false,
102
+ error: "OAuth token expired",
103
+ }),
104
+ };
105
+ }
106
+
107
+ if (url === "/api/settings" && method === "POST") {
108
+ const body = JSON.parse(String(init?.body ?? "{}")) as { method?: string };
109
+ return {
110
+ ok: true,
111
+ json: async () => ({
112
+ method: body.method ?? "oauth",
113
+ hasKey: false,
114
+ apiKeySource: body.method === "oauth" ? "oauth" : "unknown",
115
+ }),
116
+ };
117
+ }
118
+
119
+ throw new Error(`Unexpected fetch: ${method} ${url}`);
120
+ })
121
+ );
122
+
123
+ render(<AuthConfigSection />);
124
+
125
+ fireEvent.click(await screen.findByRole("button", { name: "Test Connection" }));
126
+
127
+ await waitFor(() => {
128
+ expect(screen.getByText("OAuth token expired")).toBeInTheDocument();
129
+ });
130
+ });
131
+
132
+ it("clears the inline test result when switching auth methods", async () => {
133
+ render(<AuthConfigSection />);
134
+
135
+ fireEvent.click(await screen.findByRole("button", { name: "Test Connection" }));
136
+
137
+ await waitFor(() => {
138
+ expect(screen.getByText("Connected")).toBeInTheDocument();
139
+ });
140
+
141
+ fireEvent.click(screen.getByRole("button", { name: /API Key/i }));
142
+
143
+ await waitFor(() => {
144
+ expect(screen.queryByText("Connected")).not.toBeInTheDocument();
145
+ });
146
+ });
147
+ });