stagent 0.10.0 → 0.11.1

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 (176) hide show
  1. package/README.md +44 -31
  2. package/dist/cli.js +24 -0
  3. package/docs/.coverage-gaps.json +154 -24
  4. package/docs/.last-generated +1 -1
  5. package/docs/features/agent-intelligence.md +12 -2
  6. package/docs/features/chat.md +40 -5
  7. package/docs/features/cost-usage.md +1 -1
  8. package/docs/features/documents.md +5 -2
  9. package/docs/features/inbox-notifications.md +10 -2
  10. package/docs/features/keyboard-navigation.md +12 -3
  11. package/docs/features/provider-runtimes.md +16 -2
  12. package/docs/features/settings.md +2 -2
  13. package/docs/features/shared-components.md +7 -3
  14. package/docs/features/tables.md +3 -1
  15. package/docs/features/tool-permissions.md +6 -2
  16. package/docs/features/workflows.md +6 -2
  17. package/docs/getting-started.md +1 -1
  18. package/docs/index.md +1 -1
  19. package/docs/journeys/developer.md +25 -2
  20. package/docs/journeys/personal-use.md +12 -5
  21. package/docs/journeys/power-user.md +45 -14
  22. package/docs/journeys/work-use.md +17 -8
  23. package/docs/manifest.json +15 -15
  24. package/docs/superpowers/plans/2026-04-07-instance-bootstrap.md +2 -2
  25. package/docs/superpowers/plans/2026-04-14-chat-command-namespace-refactor.md +1390 -0
  26. package/docs/superpowers/plans/2026-04-14-chat-environment-integration.md +1561 -0
  27. package/docs/superpowers/plans/2026-04-14-chat-polish-bundle-v1.md +1219 -0
  28. package/docs/superpowers/plans/2026-04-14-chat-session-persistence-provider-closeout.md +399 -0
  29. package/next.config.mjs +1 -0
  30. package/package.json +3 -3
  31. package/src/app/api/chat/conversations/[id]/skills/__tests__/activate.test.ts +141 -0
  32. package/src/app/api/chat/conversations/[id]/skills/activate/route.ts +74 -0
  33. package/src/app/api/chat/conversations/[id]/skills/deactivate/route.ts +33 -0
  34. package/src/app/api/chat/export/route.ts +52 -0
  35. package/src/app/api/chat/files/search/route.ts +50 -0
  36. package/src/app/api/environment/rescan-if-stale/__tests__/route.test.ts +45 -0
  37. package/src/app/api/environment/rescan-if-stale/route.ts +23 -0
  38. package/src/app/api/environment/skills/route.ts +13 -0
  39. package/src/app/api/schedules/[id]/execute/route.ts +2 -2
  40. package/src/app/api/settings/chat/pins/route.ts +94 -0
  41. package/src/app/api/settings/chat/saved-searches/__tests__/route.test.ts +119 -0
  42. package/src/app/api/settings/chat/saved-searches/route.ts +79 -0
  43. package/src/app/api/settings/environment/route.ts +26 -0
  44. package/src/app/api/tasks/[id]/execute/route.ts +52 -12
  45. package/src/app/api/tasks/[id]/respond/route.ts +31 -15
  46. package/src/app/api/tasks/[id]/resume/route.ts +24 -3
  47. package/src/app/documents/page.tsx +4 -1
  48. package/src/app/settings/page.tsx +2 -0
  49. package/src/components/book/content-blocks.tsx +1 -1
  50. package/src/components/chat/__tests__/capability-banner.test.tsx +38 -0
  51. package/src/components/chat/__tests__/chat-session-provider.test.tsx +166 -1
  52. package/src/components/chat/__tests__/skill-row.test.tsx +91 -0
  53. package/src/components/chat/capability-banner.tsx +68 -0
  54. package/src/components/chat/chat-command-popover.tsx +668 -47
  55. package/src/components/chat/chat-input.tsx +103 -8
  56. package/src/components/chat/chat-message.tsx +12 -3
  57. package/src/components/chat/chat-session-provider.tsx +73 -3
  58. package/src/components/chat/chat-shell.tsx +62 -3
  59. package/src/components/chat/command-tab-bar.tsx +68 -0
  60. package/src/components/chat/conversation-template-picker.tsx +421 -0
  61. package/src/components/chat/help-dialog.tsx +39 -0
  62. package/src/components/chat/skill-composition-conflict-dialog.tsx +96 -0
  63. package/src/components/chat/skill-row.tsx +147 -0
  64. package/src/components/documents/document-browser.tsx +37 -19
  65. package/src/components/notifications/__tests__/permission-response-actions.test.tsx +70 -0
  66. package/src/components/notifications/permission-response-actions.tsx +155 -1
  67. package/src/components/playbook/playbook-detail-view.tsx +1 -1
  68. package/src/components/settings/environment-section.tsx +102 -0
  69. package/src/components/shared/__tests__/filter-hint.test.tsx +40 -0
  70. package/src/components/shared/__tests__/saved-searches-manager.test.tsx +147 -0
  71. package/src/components/shared/command-palette.tsx +262 -2
  72. package/src/components/shared/filter-hint.tsx +70 -0
  73. package/src/components/shared/filter-input.tsx +59 -0
  74. package/src/components/shared/saved-searches-manager.tsx +199 -0
  75. package/src/components/tasks/task-bento-grid.tsx +12 -2
  76. package/src/components/tasks/task-card.tsx +3 -0
  77. package/src/components/tasks/task-chip-bar.tsx +30 -1
  78. package/src/hooks/__tests__/use-chat-autocomplete-tabs.test.ts +47 -0
  79. package/src/hooks/__tests__/use-saved-searches.test.ts +70 -0
  80. package/src/hooks/use-active-skills.ts +110 -0
  81. package/src/hooks/use-chat-autocomplete.ts +120 -7
  82. package/src/hooks/use-enriched-skills.ts +19 -0
  83. package/src/hooks/use-pinned-entries.ts +104 -0
  84. package/src/hooks/use-recent-user-messages.ts +19 -0
  85. package/src/hooks/use-saved-searches.ts +142 -0
  86. package/src/lib/agents/__tests__/claude-agent-sdk-options.test.ts +56 -0
  87. package/src/lib/agents/__tests__/claude-agent.test.ts +17 -4
  88. package/src/lib/agents/__tests__/task-dispatch.test.ts +166 -0
  89. package/src/lib/agents/__tests__/tool-permissions.test.ts +60 -0
  90. package/src/lib/agents/claude-agent.ts +105 -46
  91. package/src/lib/agents/handoff/bus.ts +2 -2
  92. package/src/lib/agents/profiles/__tests__/list-fused-profiles.test.ts +110 -0
  93. package/src/lib/agents/profiles/__tests__/registry.test.ts +47 -0
  94. package/src/lib/agents/profiles/builtins/upgrade-assistant/SKILL.md +30 -3
  95. package/src/lib/agents/profiles/builtins/upgrade-assistant/profile.yaml +6 -2
  96. package/src/lib/agents/profiles/list-fused-profiles.ts +104 -0
  97. package/src/lib/agents/profiles/registry.ts +97 -22
  98. package/src/lib/agents/profiles/types.ts +7 -1
  99. package/src/lib/agents/router.ts +3 -6
  100. package/src/lib/agents/runtime/__tests__/catalog.test.ts +130 -0
  101. package/src/lib/agents/runtime/__tests__/execution-target.test.ts +183 -0
  102. package/src/lib/agents/runtime/anthropic-direct.ts +8 -0
  103. package/src/lib/agents/runtime/catalog.ts +121 -0
  104. package/src/lib/agents/runtime/claude-sdk.ts +32 -0
  105. package/src/lib/agents/runtime/execution-target.ts +456 -0
  106. package/src/lib/agents/runtime/index.ts +4 -0
  107. package/src/lib/agents/runtime/launch-failure.ts +101 -0
  108. package/src/lib/agents/runtime/openai-codex.ts +35 -0
  109. package/src/lib/agents/runtime/openai-direct.ts +8 -0
  110. package/src/lib/agents/task-dispatch.ts +220 -0
  111. package/src/lib/agents/tool-permissions.ts +16 -1
  112. package/src/lib/chat/__tests__/active-skill-injection.test.ts +261 -0
  113. package/src/lib/chat/__tests__/clean-filter-input.test.ts +68 -0
  114. package/src/lib/chat/__tests__/command-tabs.test.ts +68 -0
  115. package/src/lib/chat/__tests__/context-builder-files.test.ts +112 -0
  116. package/src/lib/chat/__tests__/dismissals.test.ts +65 -0
  117. package/src/lib/chat/__tests__/engine-sdk-options.test.ts +117 -0
  118. package/src/lib/chat/__tests__/skill-conflict.test.ts +35 -0
  119. package/src/lib/chat/__tests__/types.test.ts +28 -0
  120. package/src/lib/chat/active-skills.ts +31 -0
  121. package/src/lib/chat/clean-filter-input.ts +30 -0
  122. package/src/lib/chat/codex-engine.ts +30 -7
  123. package/src/lib/chat/command-tabs.ts +61 -0
  124. package/src/lib/chat/context-builder.ts +141 -1
  125. package/src/lib/chat/dismissals.ts +73 -0
  126. package/src/lib/chat/engine.ts +109 -15
  127. package/src/lib/chat/files/__tests__/search.test.ts +135 -0
  128. package/src/lib/chat/files/expand-mention.ts +76 -0
  129. package/src/lib/chat/files/search.ts +99 -0
  130. package/src/lib/chat/skill-composition.ts +210 -0
  131. package/src/lib/chat/skill-conflict.ts +105 -0
  132. package/src/lib/chat/stagent-tools.ts +6 -19
  133. package/src/lib/chat/stream-telemetry.ts +9 -4
  134. package/src/lib/chat/system-prompt.ts +22 -0
  135. package/src/lib/chat/tool-catalog.ts +33 -3
  136. package/src/lib/chat/tools/__tests__/profile-tools.test.ts +51 -0
  137. package/src/lib/chat/tools/__tests__/settings-tools.test.ts +294 -0
  138. package/src/lib/chat/tools/__tests__/skill-tools.test.ts +474 -0
  139. package/src/lib/chat/tools/__tests__/task-tools.test.ts +47 -0
  140. package/src/lib/chat/tools/__tests__/workflow-tools-dedup.test.ts +134 -0
  141. package/src/lib/chat/tools/blueprint-tools.ts +190 -0
  142. package/src/lib/chat/tools/helpers.ts +2 -0
  143. package/src/lib/chat/tools/profile-tools.ts +120 -23
  144. package/src/lib/chat/tools/skill-tools.ts +183 -0
  145. package/src/lib/chat/tools/task-tools.ts +6 -2
  146. package/src/lib/chat/tools/workflow-tools.ts +61 -20
  147. package/src/lib/chat/types.ts +15 -0
  148. package/src/lib/constants/settings.ts +2 -0
  149. package/src/lib/data/clear.ts +2 -6
  150. package/src/lib/db/bootstrap.ts +17 -0
  151. package/src/lib/db/schema.ts +26 -0
  152. package/src/lib/environment/__tests__/auto-promote.test.ts +132 -0
  153. package/src/lib/environment/__tests__/list-skills-enriched.test.ts +55 -0
  154. package/src/lib/environment/__tests__/skill-enrichment.test.ts +129 -0
  155. package/src/lib/environment/__tests__/skill-recommendations.test.ts +87 -0
  156. package/src/lib/environment/data.ts +9 -0
  157. package/src/lib/environment/list-skills.ts +176 -0
  158. package/src/lib/environment/parsers/__tests__/skill.test.ts +54 -0
  159. package/src/lib/environment/parsers/skill.ts +26 -5
  160. package/src/lib/environment/profile-generator.ts +56 -2
  161. package/src/lib/environment/skill-enrichment.ts +106 -0
  162. package/src/lib/environment/skill-recommendations.ts +66 -0
  163. package/src/lib/filters/__tests__/parse.quoted.test.ts +40 -0
  164. package/src/lib/filters/__tests__/parse.test.ts +135 -0
  165. package/src/lib/filters/parse.ts +86 -0
  166. package/src/lib/instance/__tests__/detect.test.ts +1 -1
  167. package/src/lib/instance/__tests__/upgrade-poller.test.ts +50 -0
  168. package/src/lib/instance/fingerprint.ts +8 -10
  169. package/src/lib/instance/upgrade-poller.ts +53 -1
  170. package/src/lib/schedules/scheduler.ts +4 -4
  171. package/src/lib/utils/stagent-paths.ts +4 -0
  172. package/src/lib/workflows/blueprints/__tests__/render-prompt.test.ts +124 -0
  173. package/src/lib/workflows/blueprints/render-prompt.ts +71 -0
  174. package/src/lib/workflows/blueprints/types.ts +6 -0
  175. package/src/lib/workflows/engine.ts +5 -3
  176. package/src/test/setup.ts +10 -0
@@ -1,9 +1,8 @@
1
1
  "use client";
2
2
 
3
3
  import { useState, useCallback, useRef } from "react";
4
- import { useRouter } from "next/navigation";
4
+ import { useRouter, useSearchParams } from "next/navigation";
5
5
  import { Button } from "@/components/ui/button";
6
- import { Input } from "@/components/ui/input";
7
6
  import {
8
7
  Select,
9
8
  SelectContent,
@@ -11,7 +10,9 @@ import {
11
10
  SelectTrigger,
12
11
  SelectValue,
13
12
  } from "@/components/ui/select";
14
- import { LayoutGrid, LayoutList, Upload, Trash2, Search } from "lucide-react";
13
+ import { LayoutGrid, LayoutList, Upload, Trash2 } from "lucide-react";
14
+ import { FilterInput } from "@/components/shared/filter-input";
15
+ import { parseFilterInput, matchesClauses, type FilterClause } from "@/lib/filters/parse";
15
16
  import { toast } from "sonner";
16
17
  import { DocumentTable } from "./document-table";
17
18
  import { DocumentGrid } from "./document-grid";
@@ -30,7 +31,12 @@ export function DocumentBrowser({
30
31
  }: DocumentBrowserProps) {
31
32
  const [docs, setDocs] = useState(initialDocuments);
32
33
  const [view, setView] = useState<"table" | "grid">("table");
33
- const [search, setSearch] = useState("");
34
+ const searchParams = useSearchParams();
35
+ const initialFilter = searchParams.get("filter") ?? "";
36
+ const initialParsed = parseFilterInput(initialFilter);
37
+ const [filterRaw, setFilterRaw] = useState(initialFilter);
38
+ const [rawQuery, setRawQuery] = useState(initialParsed.rawQuery);
39
+ const [clauses, setClauses] = useState<FilterClause[]>(initialParsed.clauses);
34
40
  const [statusFilter, setStatusFilter] = useState<string>("all");
35
41
  const [directionFilter, setDirectionFilter] = useState<string>("all");
36
42
  const [projectFilter, setProjectFilter] = useState<string>("all");
@@ -54,16 +60,20 @@ export function DocumentBrowser({
54
60
 
55
61
  const filtered = docs.filter((doc) => {
56
62
  if (
57
- search &&
58
- !doc.originalName.toLowerCase().includes(search.toLowerCase()) &&
59
- !(doc.extractedText ?? "").toLowerCase().includes(search.toLowerCase())
63
+ rawQuery &&
64
+ !doc.originalName.toLowerCase().includes(rawQuery.toLowerCase()) &&
65
+ !(doc.extractedText ?? "").toLowerCase().includes(rawQuery.toLowerCase())
60
66
  ) {
61
67
  return false;
62
68
  }
63
69
  if (statusFilter !== "all" && doc.status !== statusFilter) return false;
64
70
  if (directionFilter !== "all" && doc.direction !== directionFilter) return false;
65
71
  if (projectFilter !== "all" && doc.projectId !== projectFilter) return false;
66
- return true;
72
+ return matchesClauses(doc, clauses, {
73
+ status: (d, v) => (d.status ?? "").toLowerCase() === v.toLowerCase(),
74
+ direction: (d, v) => (d.direction ?? "").toLowerCase() === v.toLowerCase(),
75
+ type: (d, v) => (d.mimeType ?? "").toLowerCase().includes(v.toLowerCase()),
76
+ });
67
77
  });
68
78
 
69
79
  function toggleSelect(id: string) {
@@ -112,27 +122,35 @@ export function DocumentBrowser({
112
122
 
113
123
  <FilterBar
114
124
  activeCount={
115
- (search ? 1 : 0) +
125
+ (filterRaw ? 1 : 0) +
116
126
  (statusFilter !== "all" ? 1 : 0) +
117
127
  (directionFilter !== "all" ? 1 : 0) +
118
128
  (projectFilter !== "all" ? 1 : 0)
119
129
  }
120
130
  onClear={() => {
121
- setSearch("");
131
+ setFilterRaw("");
132
+ setRawQuery("");
133
+ setClauses([]);
122
134
  setStatusFilter("all");
123
135
  setDirectionFilter("all");
124
136
  setProjectFilter("all");
137
+ router.replace("?", { scroll: false });
125
138
  }}
126
139
  >
127
- <div className="relative flex-1 min-w-[200px]">
128
- <Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
129
- <Input
130
- placeholder="Search by name or content..."
131
- value={search}
132
- onChange={(e) => setSearch(e.target.value)}
133
- className="pl-9"
134
- />
135
- </div>
140
+ <FilterInput
141
+ value={filterRaw}
142
+ onChange={({ raw, clauses, rawQuery }) => {
143
+ setFilterRaw(raw);
144
+ setClauses(clauses);
145
+ setRawQuery(rawQuery);
146
+ const params = new URLSearchParams(searchParams.toString());
147
+ if (raw) params.set("filter", raw);
148
+ else params.delete("filter");
149
+ const query = params.toString();
150
+ router.replace(query ? `?${query}` : "?", { scroll: false });
151
+ }}
152
+ placeholder="Search or #status:ready #type:pdf …"
153
+ />
136
154
 
137
155
  <Select value={statusFilter} onValueChange={setStatusFilter}>
138
156
  <SelectTrigger className="w-[140px]">
@@ -62,6 +62,76 @@ describe("permission response actions", () => {
62
62
  });
63
63
  });
64
64
 
65
+ it("renders option cards for AskUserQuestion with options and posts { answer } on click", async () => {
66
+ const onResponded = vi.fn();
67
+ render(
68
+ <PermissionResponseActions
69
+ taskId="task-42"
70
+ notificationId="notif-q1"
71
+ toolName="AskUserQuestion"
72
+ toolInput={{
73
+ question: "Which version?",
74
+ options: [
75
+ { label: "Keep my version", description: "Use your changes" },
76
+ { label: "Take main's version", description: "Use main's changes" },
77
+ ],
78
+ }}
79
+ responded={false}
80
+ response={null}
81
+ onResponded={onResponded}
82
+ />
83
+ );
84
+
85
+ const group = screen.getByRole("radiogroup");
86
+ expect(group).toBeInTheDocument();
87
+ fireEvent.click(screen.getByText("Take main's version"));
88
+
89
+ await waitFor(() => {
90
+ expect(fetch).toHaveBeenCalledWith("/api/tasks/task-42/respond", {
91
+ method: "POST",
92
+ headers: { "Content-Type": "application/json" },
93
+ body: JSON.stringify({
94
+ notificationId: "notif-q1",
95
+ behavior: "allow",
96
+ updatedInput: { answer: "Take main's version" },
97
+ }),
98
+ });
99
+ expect(onResponded).toHaveBeenCalled();
100
+ });
101
+ });
102
+
103
+ it("renders a textarea for AskUserQuestion without options and posts the typed answer", async () => {
104
+ const onResponded = vi.fn();
105
+ render(
106
+ <PermissionResponseActions
107
+ taskId="task-42"
108
+ notificationId="notif-q2"
109
+ toolName="AskUserQuestion"
110
+ toolInput={{ question: "Move commits to local or abort?" }}
111
+ responded={false}
112
+ response={null}
113
+ onResponded={onResponded}
114
+ />
115
+ );
116
+
117
+ const textarea = screen.getByPlaceholderText("Type your reply…");
118
+ fireEvent.change(textarea, { target: { value: "move them to local" } });
119
+ fireEvent.click(screen.getByRole("button", { name: /Send/i }));
120
+
121
+ await waitFor(() => {
122
+ expect(fetch).toHaveBeenCalledWith("/api/tasks/task-42/respond", {
123
+ method: "POST",
124
+ headers: { "Content-Type": "application/json" },
125
+ body: JSON.stringify({
126
+ notificationId: "notif-q2",
127
+ behavior: "allow",
128
+ updatedInput: { answer: "move them to local" },
129
+ }),
130
+ });
131
+ expect(onResponded).toHaveBeenCalled();
132
+ });
133
+ });
134
+
65
135
  it("renders the resolved state label when a response already exists", () => {
66
136
  render(
67
137
  <PermissionResponseActions
@@ -1,10 +1,11 @@
1
1
  "use client";
2
2
 
3
3
  import { useState } from "react";
4
- import { Check, ShieldCheck, X } from "lucide-react";
4
+ import { Check, Send, ShieldCheck, X } from "lucide-react";
5
5
  import { toast } from "sonner";
6
6
 
7
7
  import { Button } from "@/components/ui/button";
8
+ import { Textarea } from "@/components/ui/textarea";
8
9
  import { cn } from "@/lib/utils";
9
10
  import {
10
11
  buildPermissionPattern,
@@ -12,6 +13,28 @@ import {
12
13
  type PermissionToolInput,
13
14
  } from "@/lib/notifications/permissions";
14
15
 
16
+ interface AskUserQuestionOption {
17
+ label: string;
18
+ description?: string;
19
+ }
20
+
21
+ function parseQuestionOptions(toolInput: PermissionToolInput): AskUserQuestionOption[] {
22
+ const raw = (toolInput as { options?: unknown }).options;
23
+ if (!Array.isArray(raw)) return [];
24
+ const out: AskUserQuestionOption[] = [];
25
+ for (const item of raw) {
26
+ if (item && typeof item === "object" && typeof (item as { label?: unknown }).label === "string") {
27
+ const entry: AskUserQuestionOption = {
28
+ label: (item as { label: string }).label,
29
+ };
30
+ const desc = (item as { description?: unknown }).description;
31
+ if (typeof desc === "string") entry.description = desc;
32
+ out.push(entry);
33
+ }
34
+ }
35
+ return out;
36
+ }
37
+
15
38
  interface PermissionResponseActionsProps {
16
39
  taskId?: string | null;
17
40
  notificationId: string;
@@ -87,6 +110,19 @@ export function PermissionResponseActions({
87
110
  }
88
111
  }
89
112
 
113
+ if (toolName === "AskUserQuestion" || toolName === "ask_user_question") {
114
+ return (
115
+ <QuestionReplyActions
116
+ taskId={taskId}
117
+ notificationId={notificationId}
118
+ toolInput={toolInput}
119
+ onResponded={onResponded}
120
+ className={className}
121
+ buttonSize={buttonSize}
122
+ />
123
+ );
124
+ }
125
+
90
126
  return (
91
127
  <div
92
128
  className={cn(
@@ -124,3 +160,121 @@ export function PermissionResponseActions({
124
160
  </div>
125
161
  );
126
162
  }
163
+
164
+ interface QuestionReplyActionsProps {
165
+ taskId?: string | null;
166
+ notificationId: string;
167
+ toolInput: PermissionToolInput;
168
+ onResponded?: () => void;
169
+ className?: string;
170
+ buttonSize?: "sm" | "default";
171
+ }
172
+
173
+ /**
174
+ * Renders the response UI for an `AskUserQuestion` notification:
175
+ * - If `toolInput.options` is a non-empty array → card-cluster radiogroup (one click = answer).
176
+ * - Otherwise → free-form textarea + Send.
177
+ *
178
+ * Posts to /api/tasks/[id]/respond with `{ behavior: "allow", updatedInput: { answer } }`.
179
+ * The task runtime's `waitForToolPermissionResponse()` unblocks and returns `{ answer }`
180
+ * to the agent.
181
+ */
182
+ function QuestionReplyActions({
183
+ taskId,
184
+ notificationId,
185
+ toolInput,
186
+ onResponded,
187
+ className,
188
+ buttonSize = "sm",
189
+ }: QuestionReplyActionsProps) {
190
+ const [loading, setLoading] = useState(false);
191
+ const [draft, setDraft] = useState("");
192
+ const options = parseQuestionOptions(toolInput);
193
+
194
+ async function sendAnswer(answer: string) {
195
+ if (!answer.trim()) return;
196
+ setLoading(true);
197
+ try {
198
+ const res = await fetch(`/api/tasks/${taskId ?? "_checkpoint"}/respond`, {
199
+ method: "POST",
200
+ headers: { "Content-Type": "application/json" },
201
+ body: JSON.stringify({
202
+ notificationId,
203
+ behavior: "allow",
204
+ updatedInput: { answer: answer.trim() },
205
+ }),
206
+ });
207
+
208
+ if (!res.ok) {
209
+ const data = (await res.json().catch(() => null)) as
210
+ | { error?: string }
211
+ | null;
212
+ throw new Error(data?.error ?? "Failed to send answer");
213
+ }
214
+
215
+ onResponded?.();
216
+ } catch (error) {
217
+ toast.error(
218
+ error instanceof Error ? error.message : "Failed to send answer"
219
+ );
220
+ } finally {
221
+ setLoading(false);
222
+ }
223
+ }
224
+
225
+ if (options.length > 0) {
226
+ return (
227
+ <div
228
+ role="radiogroup"
229
+ aria-label="Choose a response"
230
+ className={cn("grid gap-2 sm:grid-cols-1", className)}
231
+ >
232
+ {options.map((option) => (
233
+ <button
234
+ key={option.label}
235
+ type="button"
236
+ role="radio"
237
+ aria-checked={false}
238
+ onClick={() => sendAnswer(option.label)}
239
+ disabled={loading}
240
+ className="rounded-lg border border-border/60 bg-background/60 p-3 text-left transition-colors hover:bg-accent/40 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none disabled:opacity-60"
241
+ >
242
+ <div className="text-sm font-medium text-foreground">{option.label}</div>
243
+ {option.description && (
244
+ <div className="mt-1 text-xs text-muted-foreground">{option.description}</div>
245
+ )}
246
+ </button>
247
+ ))}
248
+ </div>
249
+ );
250
+ }
251
+
252
+ return (
253
+ <div className={cn("flex flex-col gap-2", className)}>
254
+ <Textarea
255
+ value={draft}
256
+ onChange={(e) => setDraft(e.target.value)}
257
+ placeholder="Type your reply…"
258
+ rows={3}
259
+ disabled={loading}
260
+ onKeyDown={(e) => {
261
+ if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
262
+ e.preventDefault();
263
+ sendAnswer(draft);
264
+ }
265
+ }}
266
+ />
267
+ <div className="flex items-center justify-between gap-2">
268
+ <span className="text-xs text-muted-foreground">⌘/Ctrl + Enter to send</span>
269
+ <Button
270
+ size={buttonSize}
271
+ onClick={() => sendAnswer(draft)}
272
+ disabled={loading || !draft.trim()}
273
+ >
274
+ <Send className="h-3.5 w-3.5" />
275
+ Send
276
+ </Button>
277
+ </div>
278
+ </div>
279
+ );
280
+ }
@@ -130,7 +130,7 @@ export function PlaybookDetailView({
130
130
 
131
131
  // Resolve image paths to GitHub raw URLs (public/readme/ excluded from npm package)
132
132
  const GITHUB_RAW_BASE =
133
- "https://raw.githubusercontent.com/navam-io/stagent/main/public/readme";
133
+ "https://raw.githubusercontent.com/manavsehgal/stagent/main/public/readme";
134
134
  let resolvedSrc = src;
135
135
  if (src.includes("screengrabs/")) {
136
136
  resolvedSrc = `${GITHUB_RAW_BASE}/${src.split("screengrabs/").pop()}`;
@@ -0,0 +1,102 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState, useCallback } from "react";
4
+ import { Sparkles, Wand2 } from "lucide-react";
5
+ import { toast } from "sonner";
6
+ import {
7
+ Card,
8
+ CardContent,
9
+ CardDescription,
10
+ CardHeader,
11
+ CardTitle,
12
+ } from "@/components/ui/card";
13
+ import { Switch } from "@/components/ui/switch";
14
+ import { Label } from "@/components/ui/label";
15
+ import { FormSectionCard } from "@/components/shared/form-section-card";
16
+
17
+ interface EnvironmentState {
18
+ autoPromoteSkills: boolean;
19
+ }
20
+
21
+ const DEFAULT_STATE: EnvironmentState = {
22
+ autoPromoteSkills: false,
23
+ };
24
+
25
+ export function EnvironmentSection() {
26
+ const [state, setState] = useState<EnvironmentState>(DEFAULT_STATE);
27
+ const [saving, setSaving] = useState(false);
28
+
29
+ const fetchSettings = useCallback(async () => {
30
+ try {
31
+ const res = await fetch("/api/settings/environment");
32
+ if (res.ok) {
33
+ const data = await res.json();
34
+ setState(data);
35
+ }
36
+ } catch {
37
+ // Use defaults
38
+ }
39
+ }, []);
40
+
41
+ useEffect(() => {
42
+ fetchSettings();
43
+ }, [fetchSettings]);
44
+
45
+ const handleToggle = async (value: boolean) => {
46
+ setState((prev) => ({ ...prev, autoPromoteSkills: value }));
47
+ setSaving(true);
48
+ try {
49
+ const res = await fetch("/api/settings/environment", {
50
+ method: "POST",
51
+ headers: { "Content-Type": "application/json" },
52
+ body: JSON.stringify({ autoPromoteSkills: value }),
53
+ });
54
+ if (res.ok) {
55
+ const data = await res.json();
56
+ setState(data);
57
+ toast.success(`Auto-promote ${value ? "enabled" : "disabled"}`);
58
+ } else {
59
+ throw new Error("Save failed");
60
+ }
61
+ } catch {
62
+ toast.error("Failed to save setting");
63
+ setState((prev) => ({ ...prev, autoPromoteSkills: !value }));
64
+ } finally {
65
+ setSaving(false);
66
+ }
67
+ };
68
+
69
+ return (
70
+ <Card>
71
+ <CardHeader>
72
+ <CardTitle className="flex items-center gap-2">
73
+ <Sparkles className="h-5 w-5" />
74
+ Environment
75
+ </CardTitle>
76
+ <CardDescription>
77
+ How Stagent discovers and syncs skills from your environment into the
78
+ agent profile registry.
79
+ </CardDescription>
80
+ </CardHeader>
81
+ <CardContent className="space-y-4">
82
+ <FormSectionCard
83
+ icon={Wand2}
84
+ title="Auto-promote discovered skills"
85
+ hint="When enabled, every unlinked skill in ~/.claude/skills/ with a valid SKILL.md is automatically converted into an agent profile on the next environment scan. Leave off to review and promote skills manually from the Environment dashboard."
86
+ >
87
+ <div className="flex items-center justify-between">
88
+ <Label htmlFor="auto-promote-toggle" className="text-sm">
89
+ {state.autoPromoteSkills ? "Enabled" : "Disabled"}
90
+ </Label>
91
+ <Switch
92
+ id="auto-promote-toggle"
93
+ checked={state.autoPromoteSkills}
94
+ disabled={saving}
95
+ onCheckedChange={handleToggle}
96
+ />
97
+ </div>
98
+ </FormSectionCard>
99
+ </CardContent>
100
+ </Card>
101
+ );
102
+ }
@@ -0,0 +1,40 @@
1
+ import { render, screen, waitFor } from "@testing-library/react";
2
+ import { describe, it, expect, beforeEach } from "vitest";
3
+ import { FilterHint } from "../filter-hint";
4
+
5
+ const KEY = "stagent.filter-hint.dismissed";
6
+
7
+ describe("FilterHint", () => {
8
+ beforeEach(() => {
9
+ localStorage.removeItem(KEY);
10
+ });
11
+
12
+ it("renders when input is empty and not dismissed", () => {
13
+ render(<FilterHint inputValue="" storageKey={KEY} />);
14
+ expect(screen.getByText(/#key:value/i)).toBeInTheDocument();
15
+ });
16
+
17
+ it("renders when input has no # character", () => {
18
+ render(<FilterHint inputValue="some search" storageKey={KEY} />);
19
+ expect(screen.getByText(/#key:value/i)).toBeInTheDocument();
20
+ });
21
+
22
+ it("hides when input contains #", () => {
23
+ render(<FilterHint inputValue="#status:blocked" storageKey={KEY} />);
24
+ expect(screen.queryByText(/#key:value/i)).toBeNull();
25
+ });
26
+
27
+ it("sets dismissal flag and hides when input parses a valid clause", async () => {
28
+ render(<FilterHint inputValue="#type:pdf" storageKey={KEY} />);
29
+ expect(localStorage.getItem(KEY)).toBe("1");
30
+ await waitFor(() => {
31
+ expect(screen.queryByText(/#key:value/i)).toBeNull();
32
+ });
33
+ });
34
+
35
+ it("stays hidden on subsequent mounts once dismissed", () => {
36
+ localStorage.setItem(KEY, "1");
37
+ render(<FilterHint inputValue="" storageKey={KEY} />);
38
+ expect(screen.queryByText(/#key:value/i)).toBeNull();
39
+ });
40
+ });
@@ -0,0 +1,147 @@
1
+ import { render, screen, fireEvent } from "@testing-library/react";
2
+ import { describe, it, expect, vi } from "vitest";
3
+ import { SavedSearchesManager } from "../saved-searches-manager";
4
+ import type { SavedSearch } from "@/hooks/use-saved-searches";
5
+
6
+ const search = (over: Partial<SavedSearch> = {}): SavedSearch => ({
7
+ id: "s1",
8
+ surface: "task",
9
+ label: "Blocked tasks",
10
+ filterInput: "#status:blocked",
11
+ createdAt: "2026-04-14T00:00:00.000Z",
12
+ ...over,
13
+ });
14
+
15
+ describe("SavedSearchesManager", () => {
16
+ it("lists all saved searches", () => {
17
+ const items = [
18
+ search({ id: "s1", label: "Blocked tasks" }),
19
+ search({ id: "s2", label: "Pdf docs", surface: "document", filterInput: "#type:pdf" }),
20
+ ];
21
+ render(
22
+ <SavedSearchesManager
23
+ open
24
+ onOpenChange={() => {}}
25
+ searches={items}
26
+ onRename={() => {}}
27
+ onRemove={() => {}}
28
+ />
29
+ );
30
+ expect(screen.getByText("Blocked tasks")).toBeInTheDocument();
31
+ expect(screen.getByText("Pdf docs")).toBeInTheDocument();
32
+ });
33
+
34
+ it("renames on blur with non-empty trimmed label", () => {
35
+ const onRename = vi.fn();
36
+ render(
37
+ <SavedSearchesManager
38
+ open
39
+ onOpenChange={() => {}}
40
+ searches={[search()]}
41
+ onRename={onRename}
42
+ onRemove={() => {}}
43
+ />
44
+ );
45
+ fireEvent.click(screen.getByRole("button", { name: /rename blocked tasks/i }));
46
+ const input = screen.getByRole("textbox", { name: /rename/i });
47
+ fireEvent.change(input, { target: { value: " Renamed " } });
48
+ fireEvent.blur(input);
49
+ expect(onRename).toHaveBeenCalledWith("s1", "Renamed");
50
+ });
51
+
52
+ it("rejects empty label with inline error", () => {
53
+ const onRename = vi.fn();
54
+ render(
55
+ <SavedSearchesManager
56
+ open
57
+ onOpenChange={() => {}}
58
+ searches={[search()]}
59
+ onRename={onRename}
60
+ onRemove={() => {}}
61
+ />
62
+ );
63
+ fireEvent.click(screen.getByRole("button", { name: /rename blocked tasks/i }));
64
+ const input = screen.getByRole("textbox", { name: /rename/i });
65
+ fireEvent.change(input, { target: { value: " " } });
66
+ fireEvent.blur(input);
67
+ expect(onRename).not.toHaveBeenCalled();
68
+ expect(screen.getByText(/cannot be empty/i)).toBeInTheDocument();
69
+ });
70
+
71
+ it("rejects duplicate label within same surface (case-insensitive)", () => {
72
+ const onRename = vi.fn();
73
+ render(
74
+ <SavedSearchesManager
75
+ open
76
+ onOpenChange={() => {}}
77
+ searches={[
78
+ search({ id: "s1", label: "Blocked tasks" }),
79
+ search({ id: "s2", label: "Another" }),
80
+ ]}
81
+ onRename={onRename}
82
+ onRemove={() => {}}
83
+ />
84
+ );
85
+ fireEvent.click(screen.getByRole("button", { name: /rename another/i }));
86
+ const input = screen.getByRole("textbox", { name: /rename/i });
87
+ fireEvent.change(input, { target: { value: "blocked TASKS" } });
88
+ fireEvent.blur(input);
89
+ expect(onRename).not.toHaveBeenCalled();
90
+ expect(screen.getByText(/already exists/i)).toBeInTheDocument();
91
+ });
92
+
93
+ it("rejects label longer than 120 chars", () => {
94
+ const onRename = vi.fn();
95
+ render(
96
+ <SavedSearchesManager
97
+ open
98
+ onOpenChange={() => {}}
99
+ searches={[search()]}
100
+ onRename={onRename}
101
+ onRemove={() => {}}
102
+ />
103
+ );
104
+ fireEvent.click(screen.getByRole("button", { name: /rename blocked tasks/i }));
105
+ const input = screen.getByRole("textbox", { name: /rename/i });
106
+ fireEvent.change(input, { target: { value: "x".repeat(121) } });
107
+ fireEvent.blur(input);
108
+ expect(onRename).not.toHaveBeenCalled();
109
+ expect(screen.getByText(/too long/i)).toBeInTheDocument();
110
+ });
111
+
112
+ it("Escape cancels rename without persisting", () => {
113
+ const onRename = vi.fn();
114
+ render(
115
+ <SavedSearchesManager
116
+ open
117
+ onOpenChange={() => {}}
118
+ searches={[search()]}
119
+ onRename={onRename}
120
+ onRemove={() => {}}
121
+ />
122
+ );
123
+ fireEvent.click(screen.getByRole("button", { name: /rename blocked tasks/i }));
124
+ const input = screen.getByRole("textbox", { name: /rename/i });
125
+ fireEvent.change(input, { target: { value: "Changed" } });
126
+ fireEvent.keyDown(input, { key: "Escape" });
127
+ expect(onRename).not.toHaveBeenCalled();
128
+ expect(screen.queryByRole("textbox", { name: /rename/i })).toBeNull();
129
+ });
130
+
131
+ it("delete requires explicit confirm", () => {
132
+ const onRemove = vi.fn();
133
+ render(
134
+ <SavedSearchesManager
135
+ open
136
+ onOpenChange={() => {}}
137
+ searches={[search()]}
138
+ onRename={() => {}}
139
+ onRemove={onRemove}
140
+ />
141
+ );
142
+ fireEvent.click(screen.getByRole("button", { name: /delete blocked tasks/i }));
143
+ expect(onRemove).not.toHaveBeenCalled();
144
+ fireEvent.click(screen.getByRole("button", { name: /confirm delete/i }));
145
+ expect(onRemove).toHaveBeenCalledWith("s1");
146
+ });
147
+ });