stagent 0.1.0 → 0.1.2
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 +33 -30
- package/dist/cli.js +376 -49
- package/package.json +23 -24
- package/public/desktop-icon-512.png +0 -0
- package/public/icon-512.png +0 -0
- package/src/app/api/data/clear/route.ts +0 -7
- package/src/app/api/data/seed/route.ts +0 -7
- package/src/app/api/profiles/[id]/context/route.ts +109 -0
- package/src/components/dashboard/__tests__/accessibility.test.tsx +42 -0
- package/src/components/documents/__tests__/document-upload-dialog.test.tsx +46 -0
- package/src/components/notifications/__tests__/pending-approval-host.test.tsx +122 -0
- package/src/components/notifications/__tests__/permission-response-actions.test.tsx +79 -0
- package/src/components/notifications/pending-approval-host.tsx +49 -25
- package/src/components/profiles/context-proposal-review.tsx +145 -0
- package/src/components/profiles/learned-context-panel.tsx +286 -0
- package/src/components/profiles/profile-detail-view.tsx +4 -0
- package/src/components/projects/__tests__/dialog-focus.test.tsx +87 -0
- package/src/components/tasks/__tests__/kanban-board-accessibility.test.tsx +59 -0
- package/src/lib/__tests__/setup-verify.test.ts +28 -0
- package/src/lib/__tests__/utils.test.ts +29 -0
- package/src/lib/agents/__tests__/claude-agent.test.ts +946 -0
- package/src/lib/agents/__tests__/execution-manager.test.ts +63 -0
- package/src/lib/agents/__tests__/router.test.ts +61 -0
- package/src/lib/agents/claude-agent.ts +34 -5
- package/src/lib/agents/learned-context.ts +322 -0
- package/src/lib/agents/pattern-extractor.ts +150 -0
- package/src/lib/agents/profiles/__tests__/compatibility.test.ts +76 -0
- package/src/lib/agents/profiles/__tests__/registry.test.ts +177 -0
- package/src/lib/agents/profiles/builtins/sweep/SKILL.md +47 -0
- package/src/lib/agents/profiles/builtins/sweep/profile.yaml +12 -0
- package/src/lib/agents/runtime/__tests__/catalog.test.ts +38 -0
- package/src/lib/agents/runtime/openai-codex.ts +1 -1
- package/src/lib/agents/sweep.ts +65 -0
- package/src/lib/constants/__tests__/task-status.test.ts +119 -0
- package/src/lib/data/seed-data/__tests__/profiles.test.ts +141 -0
- package/src/lib/db/__tests__/bootstrap.test.ts +56 -0
- package/src/lib/db/bootstrap.ts +301 -0
- package/src/lib/db/index.ts +2 -205
- package/src/lib/db/migrations/0004_add_documents.sql +2 -1
- package/src/lib/db/migrations/0005_add_document_preprocessing.sql +2 -0
- package/src/lib/db/migrations/0006_add_agent_profile.sql +1 -0
- package/src/lib/db/migrations/0007_add_usage_metering_ledger.sql +9 -2
- package/src/lib/db/migrations/meta/_journal.json +43 -1
- package/src/lib/db/schema.ts +34 -0
- package/src/lib/desktop/__tests__/sidecar-launch.test.ts +70 -0
- package/src/lib/desktop/sidecar-launch.ts +85 -0
- package/src/lib/documents/__tests__/context-builder.test.ts +57 -0
- package/src/lib/documents/__tests__/output-scanner.test.ts +141 -0
- package/src/lib/notifications/actionable.ts +21 -7
- package/src/lib/settings/__tests__/auth.test.ts +220 -0
- package/src/lib/settings/__tests__/budget-guardrails.test.ts +181 -0
- package/src/lib/tauri-bridge.ts +138 -0
- package/src/lib/usage/__tests__/ledger.test.ts +284 -0
- package/src/lib/utils/__tests__/crypto.test.ts +90 -0
- package/src/lib/validators/__tests__/profile.test.ts +119 -0
- package/src/lib/validators/__tests__/project.test.ts +82 -0
- package/src/lib/validators/__tests__/settings.test.ts +151 -0
- package/src/lib/validators/__tests__/task.test.ts +144 -0
- package/src/lib/workflows/__tests__/definition-validation.test.ts +164 -0
- package/src/lib/workflows/__tests__/engine.test.ts +114 -0
- package/src/lib/workflows/__tests__/loop-executor.test.ts +54 -0
- package/src/lib/workflows/__tests__/parallel.test.ts +75 -0
- package/src/lib/workflows/__tests__/swarm.test.ts +97 -0
- package/src/test/setup.ts +10 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "stagent",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Governed AI agent workspace for supervised local execution, workflows, documents, and provider runtimes.",
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "Governed desktop AI agent workspace for supervised local execution, workflows, documents, and provider runtimes.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai",
|
|
7
7
|
"agents",
|
|
@@ -15,54 +15,54 @@
|
|
|
15
15
|
],
|
|
16
16
|
"license": "Apache-2.0",
|
|
17
17
|
"type": "module",
|
|
18
|
-
"repository": {
|
|
19
|
-
"type": "git",
|
|
20
|
-
"url": "https://github.com/navam-io/stagent.git"
|
|
21
|
-
},
|
|
22
|
-
"bugs": {
|
|
23
|
-
"url": "https://github.com/navam-io/stagent/issues"
|
|
24
|
-
},
|
|
25
|
-
"homepage": "https://github.com/navam-io/stagent#readme",
|
|
26
18
|
"bin": {
|
|
27
19
|
"stagent": "./dist/cli.js"
|
|
28
20
|
},
|
|
29
21
|
"files": [
|
|
30
22
|
"dist/",
|
|
31
23
|
"src/",
|
|
32
|
-
"!src/**/__tests__/",
|
|
33
|
-
"!src/**/*.test.ts",
|
|
34
|
-
"!src/**/*.test.tsx",
|
|
35
|
-
"!src/**/*.spec.ts",
|
|
36
|
-
"!src/**/*.spec.tsx",
|
|
37
|
-
"!src/test/",
|
|
38
24
|
"public/",
|
|
39
25
|
"next.config.mjs",
|
|
40
26
|
"tsconfig.json",
|
|
41
|
-
"drizzle.config.ts",
|
|
42
27
|
"postcss.config.mjs",
|
|
43
28
|
"components.json",
|
|
44
|
-
"
|
|
29
|
+
"drizzle.config.ts"
|
|
45
30
|
],
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "https://github.com/navam-io/stagent.git"
|
|
34
|
+
},
|
|
35
|
+
"bugs": {
|
|
36
|
+
"url": "https://github.com/navam-io/stagent/issues"
|
|
37
|
+
},
|
|
38
|
+
"homepage": "https://github.com/navam-io/stagent#readme",
|
|
46
39
|
"scripts": {
|
|
47
40
|
"dev": "next dev --turbopack",
|
|
48
41
|
"build": "next build",
|
|
49
42
|
"build:cli": "tsup",
|
|
50
|
-
"
|
|
51
|
-
"
|
|
43
|
+
"desktop:dev": "node scripts/tauri.mjs dev",
|
|
44
|
+
"desktop:build": "node scripts/tauri.mjs build",
|
|
45
|
+
"desktop:smoke": "node scripts/desktop-sidecar-smoke.mjs",
|
|
46
|
+
"desktop:smoke:dmg": "node scripts/desktop-mounted-dmg-smoke.mjs",
|
|
47
|
+
"desktop:icon": "node scripts/tauri.mjs icon",
|
|
48
|
+
"desktop:release": "node scripts/release-desktop.mjs",
|
|
52
49
|
"test": "vitest run",
|
|
53
50
|
"test:watch": "vitest",
|
|
54
51
|
"test:coverage": "vitest run --coverage",
|
|
55
|
-
"test:ui": "vitest --ui"
|
|
52
|
+
"test:ui": "vitest --ui",
|
|
53
|
+
"prepublishOnly": "npm run build:cli"
|
|
56
54
|
},
|
|
57
55
|
"engines": {
|
|
58
56
|
"node": ">=20.0.0"
|
|
59
57
|
},
|
|
60
58
|
"dependencies": {
|
|
61
59
|
"@anthropic-ai/claude-agent-sdk": "^0.2.71",
|
|
60
|
+
"@anthropic-ai/sdk": "^0.78.0",
|
|
62
61
|
"@dnd-kit/core": "^6.3.1",
|
|
63
62
|
"@dnd-kit/sortable": "^10.0.0",
|
|
64
63
|
"@dnd-kit/utilities": "^3.2.2",
|
|
65
64
|
"@hookform/resolvers": "^5.2.2",
|
|
65
|
+
"@tailwindcss/postcss": "^4",
|
|
66
66
|
"@tailwindcss/typography": "^0.5",
|
|
67
67
|
"better-sqlite3": "^12",
|
|
68
68
|
"class-variance-authority": "^0.7.1",
|
|
@@ -90,10 +90,11 @@
|
|
|
90
90
|
"tailwind-merge": "^3",
|
|
91
91
|
"tw-animate-css": "^1",
|
|
92
92
|
"xlsx": "^0.18.5",
|
|
93
|
+
"tailwindcss": "^4",
|
|
94
|
+
"typescript": "^5",
|
|
93
95
|
"zod": "^4.3.6"
|
|
94
96
|
},
|
|
95
97
|
"devDependencies": {
|
|
96
|
-
"@tailwindcss/postcss": "^4",
|
|
97
98
|
"@testing-library/dom": "^10.4.1",
|
|
98
99
|
"@testing-library/jest-dom": "^6.9.1",
|
|
99
100
|
"@testing-library/react": "^16.3.2",
|
|
@@ -106,9 +107,7 @@
|
|
|
106
107
|
"@vitest/coverage-v8": "^4.0.18",
|
|
107
108
|
"drizzle-kit": "^0.30",
|
|
108
109
|
"jsdom": "^28.1.0",
|
|
109
|
-
"tailwindcss": "^4",
|
|
110
110
|
"tsup": "^8.5",
|
|
111
|
-
"typescript": "^5",
|
|
112
111
|
"vitest": "^4.0.18"
|
|
113
112
|
}
|
|
114
113
|
}
|
|
Binary file
|
package/public/icon-512.png
CHANGED
|
Binary file
|
|
@@ -2,13 +2,6 @@ import { NextResponse } from "next/server";
|
|
|
2
2
|
import { clearAllData } from "@/lib/data/clear";
|
|
3
3
|
|
|
4
4
|
export async function POST() {
|
|
5
|
-
if (process.env.NODE_ENV === "production") {
|
|
6
|
-
return NextResponse.json(
|
|
7
|
-
{ error: "This endpoint is disabled in production" },
|
|
8
|
-
{ status: 403 }
|
|
9
|
-
);
|
|
10
|
-
}
|
|
11
|
-
|
|
12
5
|
try {
|
|
13
6
|
const deleted = clearAllData();
|
|
14
7
|
return NextResponse.json({ success: true, deleted });
|
|
@@ -2,13 +2,6 @@ import { NextResponse } from "next/server";
|
|
|
2
2
|
import { seedSampleData } from "@/lib/data/seed";
|
|
3
3
|
|
|
4
4
|
export async function POST() {
|
|
5
|
-
if (process.env.NODE_ENV === "production") {
|
|
6
|
-
return NextResponse.json(
|
|
7
|
-
{ error: "This endpoint is disabled in production" },
|
|
8
|
-
{ status: 403 }
|
|
9
|
-
);
|
|
10
|
-
}
|
|
11
|
-
|
|
12
5
|
try {
|
|
13
6
|
const seeded = await seedSampleData();
|
|
14
7
|
return NextResponse.json({ success: true, seeded });
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import {
|
|
3
|
+
getContextHistory,
|
|
4
|
+
approveProposal,
|
|
5
|
+
rejectProposal,
|
|
6
|
+
rollbackToVersion,
|
|
7
|
+
addDirectContext,
|
|
8
|
+
checkContextSize,
|
|
9
|
+
} from "@/lib/agents/learned-context";
|
|
10
|
+
|
|
11
|
+
interface RouteParams {
|
|
12
|
+
params: Promise<{ id: string }>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** GET /api/profiles/[id]/context — version history + size info */
|
|
16
|
+
export async function GET(_request: Request, { params }: RouteParams) {
|
|
17
|
+
const { id: profileId } = await params;
|
|
18
|
+
|
|
19
|
+
const history = await getContextHistory(profileId);
|
|
20
|
+
const sizeInfo = checkContextSize(profileId);
|
|
21
|
+
|
|
22
|
+
return NextResponse.json({ history, ...sizeInfo });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** POST /api/profiles/[id]/context — manual direct addition */
|
|
26
|
+
export async function POST(request: Request, { params }: RouteParams) {
|
|
27
|
+
const { id: profileId } = await params;
|
|
28
|
+
|
|
29
|
+
const body = await request.json();
|
|
30
|
+
const { additions } = body as { additions?: string };
|
|
31
|
+
|
|
32
|
+
if (!additions || typeof additions !== "string" || !additions.trim()) {
|
|
33
|
+
return NextResponse.json(
|
|
34
|
+
{ error: "additions is required" },
|
|
35
|
+
{ status: 400 }
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
await addDirectContext(profileId, additions.trim());
|
|
40
|
+
|
|
41
|
+
return NextResponse.json({ ok: true });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** PATCH /api/profiles/[id]/context — approve / reject / rollback */
|
|
45
|
+
export async function PATCH(request: Request, { params }: RouteParams) {
|
|
46
|
+
const { id: profileId } = await params;
|
|
47
|
+
|
|
48
|
+
const body = await request.json();
|
|
49
|
+
const { action, notificationId, targetVersion, editedContent } = body as {
|
|
50
|
+
action?: string;
|
|
51
|
+
notificationId?: string;
|
|
52
|
+
targetVersion?: number;
|
|
53
|
+
editedContent?: string;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
if (!action) {
|
|
57
|
+
return NextResponse.json(
|
|
58
|
+
{ error: "action is required (approve | reject | rollback)" },
|
|
59
|
+
{ status: 400 }
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
switch (action) {
|
|
65
|
+
case "approve":
|
|
66
|
+
if (!notificationId) {
|
|
67
|
+
return NextResponse.json(
|
|
68
|
+
{ error: "notificationId is required for approve" },
|
|
69
|
+
{ status: 400 }
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
await approveProposal(notificationId, editedContent ?? undefined);
|
|
73
|
+
break;
|
|
74
|
+
|
|
75
|
+
case "reject":
|
|
76
|
+
if (!notificationId) {
|
|
77
|
+
return NextResponse.json(
|
|
78
|
+
{ error: "notificationId is required for reject" },
|
|
79
|
+
{ status: 400 }
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
await rejectProposal(notificationId);
|
|
83
|
+
break;
|
|
84
|
+
|
|
85
|
+
case "rollback":
|
|
86
|
+
if (targetVersion === undefined) {
|
|
87
|
+
return NextResponse.json(
|
|
88
|
+
{ error: "targetVersion is required for rollback" },
|
|
89
|
+
{ status: 400 }
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
await rollbackToVersion(profileId, targetVersion);
|
|
93
|
+
break;
|
|
94
|
+
|
|
95
|
+
default:
|
|
96
|
+
return NextResponse.json(
|
|
97
|
+
{ error: `Unknown action: ${action}` },
|
|
98
|
+
{ status: 400 }
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
} catch (error) {
|
|
102
|
+
return NextResponse.json(
|
|
103
|
+
{ error: error instanceof Error ? error.message : String(error) },
|
|
104
|
+
{ status: 400 }
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return NextResponse.json({ ok: true });
|
|
109
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { render } from "@testing-library/react";
|
|
2
|
+
|
|
3
|
+
import { ActivityFeed } from "@/components/dashboard/activity-feed";
|
|
4
|
+
import { PriorityQueue } from "@/components/dashboard/priority-queue";
|
|
5
|
+
|
|
6
|
+
describe("dashboard accessibility surfaces", () => {
|
|
7
|
+
it("marks the priority queue updates as a polite live region", () => {
|
|
8
|
+
const { container } = render(
|
|
9
|
+
<PriorityQueue
|
|
10
|
+
tasks={[
|
|
11
|
+
{
|
|
12
|
+
id: "task-1",
|
|
13
|
+
title: "Fix runtime mismatch",
|
|
14
|
+
status: "failed",
|
|
15
|
+
priority: 0,
|
|
16
|
+
projectName: "Stagent",
|
|
17
|
+
},
|
|
18
|
+
]}
|
|
19
|
+
/>
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
expect(container.querySelector('.space-y-1[aria-live="polite"]')).not.toBeNull();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("marks the activity feed updates as a polite live region", () => {
|
|
26
|
+
const { container } = render(
|
|
27
|
+
<ActivityFeed
|
|
28
|
+
entries={[
|
|
29
|
+
{
|
|
30
|
+
id: "entry-1",
|
|
31
|
+
event: "completed",
|
|
32
|
+
payload: "Wrote a summary",
|
|
33
|
+
timestamp: new Date("2026-03-12T09:00:00.000Z").toISOString(),
|
|
34
|
+
taskTitle: "Summarize roadmap",
|
|
35
|
+
},
|
|
36
|
+
]}
|
|
37
|
+
/>
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
expect(container.querySelector('.space-y-1[aria-live="polite"]')).not.toBeNull();
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
2
|
+
import { useRef, useState } from "react";
|
|
3
|
+
|
|
4
|
+
import { DocumentUploadDialog } from "@/components/documents/document-upload-dialog";
|
|
5
|
+
|
|
6
|
+
vi.mock("sonner", () => ({
|
|
7
|
+
toast: {
|
|
8
|
+
success: vi.fn(),
|
|
9
|
+
error: vi.fn(),
|
|
10
|
+
},
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
function DocumentUploadHarness() {
|
|
14
|
+
const [open, setOpen] = useState(false);
|
|
15
|
+
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<>
|
|
19
|
+
<button ref={triggerRef} type="button" onClick={() => setOpen(true)}>
|
|
20
|
+
Upload documents
|
|
21
|
+
</button>
|
|
22
|
+
<DocumentUploadDialog
|
|
23
|
+
open={open}
|
|
24
|
+
onClose={() => setOpen(false)}
|
|
25
|
+
onUploaded={() => {}}
|
|
26
|
+
restoreFocusElement={triggerRef.current}
|
|
27
|
+
/>
|
|
28
|
+
</>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("document upload dialog accessibility", () => {
|
|
33
|
+
it("returns focus to the opener when the dialog closes", async () => {
|
|
34
|
+
render(<DocumentUploadHarness />);
|
|
35
|
+
|
|
36
|
+
const trigger = screen.getByRole("button", { name: "Upload documents" });
|
|
37
|
+
fireEvent.click(trigger);
|
|
38
|
+
|
|
39
|
+
const doneButton = await screen.findByRole("button", { name: "Done" });
|
|
40
|
+
fireEvent.click(doneButton);
|
|
41
|
+
|
|
42
|
+
await waitFor(() => {
|
|
43
|
+
expect(trigger).toHaveFocus();
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
2
|
+
|
|
3
|
+
import { PendingApprovalHost } from "@/components/notifications/pending-approval-host";
|
|
4
|
+
|
|
5
|
+
const { push } = vi.hoisted(() => ({
|
|
6
|
+
push: vi.fn(),
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
vi.mock("next/navigation", () => ({
|
|
10
|
+
useRouter: () => ({
|
|
11
|
+
push,
|
|
12
|
+
}),
|
|
13
|
+
usePathname: () => "/dashboard",
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
class EventSourceMock {
|
|
17
|
+
onerror: ((event: Event) => void) | null = null;
|
|
18
|
+
onmessage: ((event: MessageEvent) => void) | null = null;
|
|
19
|
+
|
|
20
|
+
close() {}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const approvals = [
|
|
24
|
+
{
|
|
25
|
+
channel: "in_app" as const,
|
|
26
|
+
notificationId: "notif-1",
|
|
27
|
+
taskId: "task-1",
|
|
28
|
+
workflowId: "workflow-1",
|
|
29
|
+
toolName: "Bash",
|
|
30
|
+
permissionLabel: "Bash",
|
|
31
|
+
compactSummary: "npm run build",
|
|
32
|
+
deepLink: "/tasks/task-1",
|
|
33
|
+
supportedActionIds: ["allow_once", "always_allow", "deny", "open_inbox"],
|
|
34
|
+
title: "Permission required",
|
|
35
|
+
body: "The agent wants to run the build before publishing.",
|
|
36
|
+
taskTitle: "Review workspace",
|
|
37
|
+
workflowName: "Workspace sync",
|
|
38
|
+
toolInput: { command: "npm run build" },
|
|
39
|
+
createdAt: "2026-03-12T15:00:00.000Z",
|
|
40
|
+
read: false,
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
channel: "in_app" as const,
|
|
44
|
+
notificationId: "notif-2",
|
|
45
|
+
taskId: "task-2",
|
|
46
|
+
workflowId: null,
|
|
47
|
+
toolName: "Write",
|
|
48
|
+
permissionLabel: "Write",
|
|
49
|
+
compactSummary: "/tmp/report.md",
|
|
50
|
+
deepLink: "/tasks/task-2",
|
|
51
|
+
supportedActionIds: ["allow_once", "always_allow", "deny", "open_inbox"],
|
|
52
|
+
title: "Permission required",
|
|
53
|
+
body: null,
|
|
54
|
+
taskTitle: "Write release brief",
|
|
55
|
+
workflowName: null,
|
|
56
|
+
toolInput: { path: "/tmp/report.md" },
|
|
57
|
+
createdAt: "2026-03-12T14:59:00.000Z",
|
|
58
|
+
read: false,
|
|
59
|
+
},
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
describe("pending approval host", () => {
|
|
63
|
+
beforeEach(() => {
|
|
64
|
+
Object.defineProperty(window, "innerWidth", {
|
|
65
|
+
configurable: true,
|
|
66
|
+
value: 1280,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
vi.stubGlobal(
|
|
70
|
+
"matchMedia",
|
|
71
|
+
vi.fn().mockImplementation(() => ({
|
|
72
|
+
matches: false,
|
|
73
|
+
addEventListener: vi.fn(),
|
|
74
|
+
removeEventListener: vi.fn(),
|
|
75
|
+
}))
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
vi.stubGlobal("EventSource", EventSourceMock);
|
|
79
|
+
vi.stubGlobal(
|
|
80
|
+
"fetch",
|
|
81
|
+
vi.fn().mockResolvedValue({
|
|
82
|
+
ok: true,
|
|
83
|
+
json: vi.fn().mockResolvedValue(approvals),
|
|
84
|
+
})
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
afterEach(() => {
|
|
89
|
+
vi.unstubAllGlobals();
|
|
90
|
+
vi.clearAllMocks();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("shows the primary approval toast and exposes overflow requests in the detail dialog", async () => {
|
|
94
|
+
render(<PendingApprovalHost />);
|
|
95
|
+
|
|
96
|
+
await screen.findByText("Workspace sync · Review workspace");
|
|
97
|
+
expect(screen.getByText("+1 more")).toBeInTheDocument();
|
|
98
|
+
expect(screen.getByText("npm run build")).toBeInTheDocument();
|
|
99
|
+
|
|
100
|
+
const trigger = screen.getByRole("button", {
|
|
101
|
+
name: /Workspace sync · Review workspace/i,
|
|
102
|
+
});
|
|
103
|
+
fireEvent.click(trigger);
|
|
104
|
+
|
|
105
|
+
const dialog = await screen.findByRole("dialog");
|
|
106
|
+
expect(dialog).toHaveTextContent("Permission required");
|
|
107
|
+
expect(dialog).toHaveTextContent("Also pending");
|
|
108
|
+
expect(dialog).toHaveTextContent("Write release brief");
|
|
109
|
+
|
|
110
|
+
fireEvent.click(screen.getByRole("button", { name: /Write release brief/i }));
|
|
111
|
+
|
|
112
|
+
await waitFor(() => {
|
|
113
|
+
expect(dialog).toHaveTextContent("/tmp/report.md");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
|
117
|
+
|
|
118
|
+
await waitFor(() => {
|
|
119
|
+
expect(trigger).toHaveFocus();
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
2
|
+
|
|
3
|
+
import { PermissionResponseActions } from "@/components/notifications/permission-response-actions";
|
|
4
|
+
|
|
5
|
+
const { toastError } = vi.hoisted(() => ({
|
|
6
|
+
toastError: vi.fn(),
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
vi.mock("sonner", () => ({
|
|
10
|
+
toast: {
|
|
11
|
+
error: toastError,
|
|
12
|
+
},
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
describe("permission response actions", () => {
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
vi.stubGlobal(
|
|
18
|
+
"fetch",
|
|
19
|
+
vi.fn().mockResolvedValue({
|
|
20
|
+
ok: true,
|
|
21
|
+
json: vi.fn().mockResolvedValue({ success: true }),
|
|
22
|
+
})
|
|
23
|
+
);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
vi.unstubAllGlobals();
|
|
28
|
+
vi.clearAllMocks();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("sends the persisted permission pattern for always-allow approvals", async () => {
|
|
32
|
+
const onResponded = vi.fn();
|
|
33
|
+
|
|
34
|
+
render(
|
|
35
|
+
<PermissionResponseActions
|
|
36
|
+
taskId="task-1"
|
|
37
|
+
notificationId="notif-1"
|
|
38
|
+
toolName="Bash"
|
|
39
|
+
toolInput={{ command: "npm run build" }}
|
|
40
|
+
responded={false}
|
|
41
|
+
response={null}
|
|
42
|
+
onResponded={onResponded}
|
|
43
|
+
/>
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
fireEvent.click(screen.getByRole("button", { name: "Always Allow" }));
|
|
47
|
+
|
|
48
|
+
await waitFor(() => {
|
|
49
|
+
expect(fetch).toHaveBeenCalledWith("/api/tasks/task-1/respond", {
|
|
50
|
+
method: "POST",
|
|
51
|
+
headers: { "Content-Type": "application/json" },
|
|
52
|
+
body: JSON.stringify({
|
|
53
|
+
notificationId: "notif-1",
|
|
54
|
+
behavior: "allow",
|
|
55
|
+
updatedInput: { command: "npm run build" },
|
|
56
|
+
message: undefined,
|
|
57
|
+
alwaysAllow: true,
|
|
58
|
+
permissionPattern: "Bash(command:npm *)",
|
|
59
|
+
}),
|
|
60
|
+
});
|
|
61
|
+
expect(onResponded).toHaveBeenCalled();
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("renders the resolved state label when a response already exists", () => {
|
|
66
|
+
render(
|
|
67
|
+
<PermissionResponseActions
|
|
68
|
+
taskId="task-1"
|
|
69
|
+
notificationId="notif-1"
|
|
70
|
+
toolName="Bash"
|
|
71
|
+
toolInput={{ command: "npm run build" }}
|
|
72
|
+
responded
|
|
73
|
+
response={JSON.stringify({ behavior: "allow", alwaysAllow: true })}
|
|
74
|
+
/>
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
expect(screen.getByText("Always allowed")).toBeInTheDocument();
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
} from "lucide-react";
|
|
13
13
|
|
|
14
14
|
import { PermissionResponseActions } from "@/components/notifications/permission-response-actions";
|
|
15
|
+
import { ContextProposalReview } from "@/components/profiles/context-proposal-review";
|
|
15
16
|
import { Badge } from "@/components/ui/badge";
|
|
16
17
|
import {
|
|
17
18
|
Dialog,
|
|
@@ -136,23 +137,34 @@ function PendingApprovalDetail({
|
|
|
136
137
|
</p>
|
|
137
138
|
</div>
|
|
138
139
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
toolInput={selected.toolInput}
|
|
142
|
-
/>
|
|
143
|
-
|
|
144
|
-
{selected.taskId && selected.toolName && selected.toolInput && (
|
|
145
|
-
<PermissionResponseActions
|
|
146
|
-
taskId={selected.taskId}
|
|
140
|
+
{selected.notificationType === "context_proposal" ? (
|
|
141
|
+
<ContextProposalReview
|
|
147
142
|
notificationId={selected.notificationId}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
responded={false}
|
|
151
|
-
response={null}
|
|
143
|
+
profileId={selected.toolName ?? ""}
|
|
144
|
+
proposedAdditions={selected.body ?? ""}
|
|
152
145
|
onResponded={onResponded}
|
|
153
|
-
buttonSize="default"
|
|
154
|
-
layout="stacked"
|
|
155
146
|
/>
|
|
147
|
+
) : (
|
|
148
|
+
<>
|
|
149
|
+
<PermissionDetailFields
|
|
150
|
+
toolName={selected.toolName}
|
|
151
|
+
toolInput={selected.toolInput}
|
|
152
|
+
/>
|
|
153
|
+
|
|
154
|
+
{selected.taskId && selected.toolName && selected.toolInput && (
|
|
155
|
+
<PermissionResponseActions
|
|
156
|
+
taskId={selected.taskId}
|
|
157
|
+
notificationId={selected.notificationId}
|
|
158
|
+
toolName={selected.toolName}
|
|
159
|
+
toolInput={selected.toolInput}
|
|
160
|
+
responded={false}
|
|
161
|
+
response={null}
|
|
162
|
+
onResponded={onResponded}
|
|
163
|
+
buttonSize="default"
|
|
164
|
+
layout="stacked"
|
|
165
|
+
/>
|
|
166
|
+
)}
|
|
167
|
+
</>
|
|
156
168
|
)}
|
|
157
169
|
|
|
158
170
|
<div className="flex flex-wrap items-center gap-2">
|
|
@@ -393,17 +405,29 @@ export function PendingApprovalHost() {
|
|
|
393
405
|
</div>
|
|
394
406
|
</button>
|
|
395
407
|
|
|
396
|
-
{primary.
|
|
397
|
-
<
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
408
|
+
{primary.notificationType === "context_proposal" ? (
|
|
409
|
+
<div className="mt-3">
|
|
410
|
+
<ContextProposalReview
|
|
411
|
+
notificationId={primary.notificationId}
|
|
412
|
+
profileId={primary.toolName ?? ""}
|
|
413
|
+
proposedAdditions={primary.body ?? ""}
|
|
414
|
+
onResponded={() => removeNotification(primary.notificationId)}
|
|
415
|
+
compact
|
|
416
|
+
/>
|
|
417
|
+
</div>
|
|
418
|
+
) : (
|
|
419
|
+
primary.taskId && primary.toolName && primary.toolInput && (
|
|
420
|
+
<PermissionResponseActions
|
|
421
|
+
taskId={primary.taskId}
|
|
422
|
+
notificationId={primary.notificationId}
|
|
423
|
+
toolName={primary.toolName}
|
|
424
|
+
toolInput={primary.toolInput}
|
|
425
|
+
responded={false}
|
|
426
|
+
response={null}
|
|
427
|
+
onResponded={() => removeNotification(primary.notificationId)}
|
|
428
|
+
className="mt-3"
|
|
429
|
+
/>
|
|
430
|
+
)
|
|
407
431
|
)}
|
|
408
432
|
|
|
409
433
|
<div className="mt-3 flex items-center justify-between gap-3">
|