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.
- package/README.md +44 -31
- package/dist/cli.js +24 -0
- package/docs/.coverage-gaps.json +154 -24
- package/docs/.last-generated +1 -1
- package/docs/features/agent-intelligence.md +12 -2
- package/docs/features/chat.md +40 -5
- package/docs/features/cost-usage.md +1 -1
- package/docs/features/documents.md +5 -2
- package/docs/features/inbox-notifications.md +10 -2
- package/docs/features/keyboard-navigation.md +12 -3
- package/docs/features/provider-runtimes.md +16 -2
- package/docs/features/settings.md +2 -2
- package/docs/features/shared-components.md +7 -3
- package/docs/features/tables.md +3 -1
- package/docs/features/tool-permissions.md +6 -2
- package/docs/features/workflows.md +6 -2
- package/docs/getting-started.md +1 -1
- package/docs/index.md +1 -1
- package/docs/journeys/developer.md +25 -2
- package/docs/journeys/personal-use.md +12 -5
- package/docs/journeys/power-user.md +45 -14
- package/docs/journeys/work-use.md +17 -8
- package/docs/manifest.json +15 -15
- package/docs/superpowers/plans/2026-04-07-instance-bootstrap.md +2 -2
- package/docs/superpowers/plans/2026-04-14-chat-command-namespace-refactor.md +1390 -0
- package/docs/superpowers/plans/2026-04-14-chat-environment-integration.md +1561 -0
- package/docs/superpowers/plans/2026-04-14-chat-polish-bundle-v1.md +1219 -0
- package/docs/superpowers/plans/2026-04-14-chat-session-persistence-provider-closeout.md +399 -0
- package/next.config.mjs +1 -0
- package/package.json +3 -3
- package/src/app/api/chat/conversations/[id]/skills/__tests__/activate.test.ts +141 -0
- package/src/app/api/chat/conversations/[id]/skills/activate/route.ts +74 -0
- package/src/app/api/chat/conversations/[id]/skills/deactivate/route.ts +33 -0
- package/src/app/api/chat/export/route.ts +52 -0
- package/src/app/api/chat/files/search/route.ts +50 -0
- package/src/app/api/environment/rescan-if-stale/__tests__/route.test.ts +45 -0
- package/src/app/api/environment/rescan-if-stale/route.ts +23 -0
- package/src/app/api/environment/skills/route.ts +13 -0
- package/src/app/api/schedules/[id]/execute/route.ts +2 -2
- package/src/app/api/settings/chat/pins/route.ts +94 -0
- package/src/app/api/settings/chat/saved-searches/__tests__/route.test.ts +119 -0
- package/src/app/api/settings/chat/saved-searches/route.ts +79 -0
- package/src/app/api/settings/environment/route.ts +26 -0
- package/src/app/api/tasks/[id]/execute/route.ts +52 -12
- package/src/app/api/tasks/[id]/respond/route.ts +31 -15
- package/src/app/api/tasks/[id]/resume/route.ts +24 -3
- package/src/app/documents/page.tsx +4 -1
- package/src/app/settings/page.tsx +2 -0
- package/src/components/book/content-blocks.tsx +1 -1
- package/src/components/chat/__tests__/capability-banner.test.tsx +38 -0
- package/src/components/chat/__tests__/chat-session-provider.test.tsx +166 -1
- package/src/components/chat/__tests__/skill-row.test.tsx +91 -0
- package/src/components/chat/capability-banner.tsx +68 -0
- package/src/components/chat/chat-command-popover.tsx +668 -47
- package/src/components/chat/chat-input.tsx +103 -8
- package/src/components/chat/chat-message.tsx +12 -3
- package/src/components/chat/chat-session-provider.tsx +73 -3
- package/src/components/chat/chat-shell.tsx +62 -3
- package/src/components/chat/command-tab-bar.tsx +68 -0
- package/src/components/chat/conversation-template-picker.tsx +421 -0
- package/src/components/chat/help-dialog.tsx +39 -0
- package/src/components/chat/skill-composition-conflict-dialog.tsx +96 -0
- package/src/components/chat/skill-row.tsx +147 -0
- package/src/components/documents/document-browser.tsx +37 -19
- package/src/components/notifications/__tests__/permission-response-actions.test.tsx +70 -0
- package/src/components/notifications/permission-response-actions.tsx +155 -1
- package/src/components/playbook/playbook-detail-view.tsx +1 -1
- package/src/components/settings/environment-section.tsx +102 -0
- package/src/components/shared/__tests__/filter-hint.test.tsx +40 -0
- package/src/components/shared/__tests__/saved-searches-manager.test.tsx +147 -0
- package/src/components/shared/command-palette.tsx +262 -2
- package/src/components/shared/filter-hint.tsx +70 -0
- package/src/components/shared/filter-input.tsx +59 -0
- package/src/components/shared/saved-searches-manager.tsx +199 -0
- package/src/components/tasks/task-bento-grid.tsx +12 -2
- package/src/components/tasks/task-card.tsx +3 -0
- package/src/components/tasks/task-chip-bar.tsx +30 -1
- package/src/hooks/__tests__/use-chat-autocomplete-tabs.test.ts +47 -0
- package/src/hooks/__tests__/use-saved-searches.test.ts +70 -0
- package/src/hooks/use-active-skills.ts +110 -0
- package/src/hooks/use-chat-autocomplete.ts +120 -7
- package/src/hooks/use-enriched-skills.ts +19 -0
- package/src/hooks/use-pinned-entries.ts +104 -0
- package/src/hooks/use-recent-user-messages.ts +19 -0
- package/src/hooks/use-saved-searches.ts +142 -0
- package/src/lib/agents/__tests__/claude-agent-sdk-options.test.ts +56 -0
- package/src/lib/agents/__tests__/claude-agent.test.ts +17 -4
- package/src/lib/agents/__tests__/task-dispatch.test.ts +166 -0
- package/src/lib/agents/__tests__/tool-permissions.test.ts +60 -0
- package/src/lib/agents/claude-agent.ts +105 -46
- package/src/lib/agents/handoff/bus.ts +2 -2
- package/src/lib/agents/profiles/__tests__/list-fused-profiles.test.ts +110 -0
- package/src/lib/agents/profiles/__tests__/registry.test.ts +47 -0
- package/src/lib/agents/profiles/builtins/upgrade-assistant/SKILL.md +30 -3
- package/src/lib/agents/profiles/builtins/upgrade-assistant/profile.yaml +6 -2
- package/src/lib/agents/profiles/list-fused-profiles.ts +104 -0
- package/src/lib/agents/profiles/registry.ts +97 -22
- package/src/lib/agents/profiles/types.ts +7 -1
- package/src/lib/agents/router.ts +3 -6
- package/src/lib/agents/runtime/__tests__/catalog.test.ts +130 -0
- package/src/lib/agents/runtime/__tests__/execution-target.test.ts +183 -0
- package/src/lib/agents/runtime/anthropic-direct.ts +8 -0
- package/src/lib/agents/runtime/catalog.ts +121 -0
- package/src/lib/agents/runtime/claude-sdk.ts +32 -0
- package/src/lib/agents/runtime/execution-target.ts +456 -0
- package/src/lib/agents/runtime/index.ts +4 -0
- package/src/lib/agents/runtime/launch-failure.ts +101 -0
- package/src/lib/agents/runtime/openai-codex.ts +35 -0
- package/src/lib/agents/runtime/openai-direct.ts +8 -0
- package/src/lib/agents/task-dispatch.ts +220 -0
- package/src/lib/agents/tool-permissions.ts +16 -1
- package/src/lib/chat/__tests__/active-skill-injection.test.ts +261 -0
- package/src/lib/chat/__tests__/clean-filter-input.test.ts +68 -0
- package/src/lib/chat/__tests__/command-tabs.test.ts +68 -0
- package/src/lib/chat/__tests__/context-builder-files.test.ts +112 -0
- package/src/lib/chat/__tests__/dismissals.test.ts +65 -0
- package/src/lib/chat/__tests__/engine-sdk-options.test.ts +117 -0
- package/src/lib/chat/__tests__/skill-conflict.test.ts +35 -0
- package/src/lib/chat/__tests__/types.test.ts +28 -0
- package/src/lib/chat/active-skills.ts +31 -0
- package/src/lib/chat/clean-filter-input.ts +30 -0
- package/src/lib/chat/codex-engine.ts +30 -7
- package/src/lib/chat/command-tabs.ts +61 -0
- package/src/lib/chat/context-builder.ts +141 -1
- package/src/lib/chat/dismissals.ts +73 -0
- package/src/lib/chat/engine.ts +109 -15
- package/src/lib/chat/files/__tests__/search.test.ts +135 -0
- package/src/lib/chat/files/expand-mention.ts +76 -0
- package/src/lib/chat/files/search.ts +99 -0
- package/src/lib/chat/skill-composition.ts +210 -0
- package/src/lib/chat/skill-conflict.ts +105 -0
- package/src/lib/chat/stagent-tools.ts +6 -19
- package/src/lib/chat/stream-telemetry.ts +9 -4
- package/src/lib/chat/system-prompt.ts +22 -0
- package/src/lib/chat/tool-catalog.ts +33 -3
- package/src/lib/chat/tools/__tests__/profile-tools.test.ts +51 -0
- package/src/lib/chat/tools/__tests__/settings-tools.test.ts +294 -0
- package/src/lib/chat/tools/__tests__/skill-tools.test.ts +474 -0
- package/src/lib/chat/tools/__tests__/task-tools.test.ts +47 -0
- package/src/lib/chat/tools/__tests__/workflow-tools-dedup.test.ts +134 -0
- package/src/lib/chat/tools/blueprint-tools.ts +190 -0
- package/src/lib/chat/tools/helpers.ts +2 -0
- package/src/lib/chat/tools/profile-tools.ts +120 -23
- package/src/lib/chat/tools/skill-tools.ts +183 -0
- package/src/lib/chat/tools/task-tools.ts +6 -2
- package/src/lib/chat/tools/workflow-tools.ts +61 -20
- package/src/lib/chat/types.ts +15 -0
- package/src/lib/constants/settings.ts +2 -0
- package/src/lib/data/clear.ts +2 -6
- package/src/lib/db/bootstrap.ts +17 -0
- package/src/lib/db/schema.ts +26 -0
- package/src/lib/environment/__tests__/auto-promote.test.ts +132 -0
- package/src/lib/environment/__tests__/list-skills-enriched.test.ts +55 -0
- package/src/lib/environment/__tests__/skill-enrichment.test.ts +129 -0
- package/src/lib/environment/__tests__/skill-recommendations.test.ts +87 -0
- package/src/lib/environment/data.ts +9 -0
- package/src/lib/environment/list-skills.ts +176 -0
- package/src/lib/environment/parsers/__tests__/skill.test.ts +54 -0
- package/src/lib/environment/parsers/skill.ts +26 -5
- package/src/lib/environment/profile-generator.ts +56 -2
- package/src/lib/environment/skill-enrichment.ts +106 -0
- package/src/lib/environment/skill-recommendations.ts +66 -0
- package/src/lib/filters/__tests__/parse.quoted.test.ts +40 -0
- package/src/lib/filters/__tests__/parse.test.ts +135 -0
- package/src/lib/filters/parse.ts +86 -0
- package/src/lib/instance/__tests__/detect.test.ts +1 -1
- package/src/lib/instance/__tests__/upgrade-poller.test.ts +50 -0
- package/src/lib/instance/fingerprint.ts +8 -10
- package/src/lib/instance/upgrade-poller.ts +53 -1
- package/src/lib/schedules/scheduler.ts +4 -4
- package/src/lib/utils/stagent-paths.ts +4 -0
- package/src/lib/workflows/blueprints/__tests__/render-prompt.test.ts +124 -0
- package/src/lib/workflows/blueprints/render-prompt.ts +71 -0
- package/src/lib/workflows/blueprints/types.ts +6 -0
- package/src/lib/workflows/engine.ts +5 -3
- 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
|
|
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
|
|
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
|
-
|
|
58
|
-
!doc.originalName.toLowerCase().includes(
|
|
59
|
-
!(doc.extractedText ?? "").toLowerCase().includes(
|
|
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
|
|
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
|
-
(
|
|
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
|
-
|
|
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
|
-
<
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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/
|
|
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
|
+
});
|