stagent 0.1.7 → 0.1.9
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/dist/cli.js +16 -24
- package/package.json +1 -1
- package/src/app/api/documents/route.ts +21 -2
- package/src/app/api/tasks/route.ts +16 -3
- package/src/app/api/uploads/route.ts +17 -3
- package/src/app/globals.css +34 -0
- package/src/app/tasks/new/page.tsx +10 -2
- package/src/components/tasks/__tests__/kanban-board-persistence.test.tsx +124 -0
- package/src/components/tasks/__tests__/task-create-panel.test.tsx +58 -0
- package/src/components/tasks/ai-assist-panel.tsx +50 -12
- package/src/components/tasks/kanban-board.tsx +201 -5
- package/src/components/tasks/kanban-column.tsx +156 -5
- package/src/components/tasks/task-card.tsx +186 -44
- package/src/components/tasks/task-create-panel.tsx +3 -2
- package/src/components/tasks/task-detail-view.tsx +58 -1
- package/src/components/tasks/task-edit-dialog.tsx +277 -0
- package/src/hooks/__tests__/use-persisted-state.test.ts +57 -0
- package/src/hooks/use-persisted-state.ts +40 -0
- package/src/lib/agents/claude-agent.ts +17 -7
- package/src/lib/agents/runtime/claude-sdk.ts +20 -6
- package/src/lib/agents/runtime/claude.ts +23 -5
- package/src/lib/agents/runtime/openai-codex.ts +14 -1
- package/src/lib/db/bootstrap.ts +17 -32
- package/src/lib/documents/cleanup.ts +3 -2
- package/src/lib/notifications/permissions.ts +4 -2
- package/src/lib/workflows/engine.ts +2 -2
package/dist/cli.js
CHANGED
|
@@ -276,33 +276,25 @@ function bootstrapStagentDatabase(sqlite2) {
|
|
|
276
276
|
CREATE INDEX IF NOT EXISTS idx_learned_context_profile_version ON learned_context(profile_id, version);
|
|
277
277
|
CREATE INDEX IF NOT EXISTS idx_learned_context_change_type ON learned_context(change_type);
|
|
278
278
|
`);
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
279
|
+
const addColumnIfMissing = (ddl) => {
|
|
280
|
+
try {
|
|
281
|
+
sqlite2.exec(ddl);
|
|
282
|
+
} catch (err) {
|
|
283
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
284
|
+
if (!msg.includes("duplicate column")) {
|
|
285
|
+
console.error("[bootstrap] ALTER TABLE failed:", msg);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
addColumnIfMissing(`ALTER TABLE tasks ADD COLUMN agent_profile TEXT;`);
|
|
283
290
|
sqlite2.exec(`CREATE INDEX IF NOT EXISTS idx_tasks_agent_profile ON tasks(agent_profile);`);
|
|
284
|
-
|
|
285
|
-
sqlite2.exec(`ALTER TABLE tasks ADD COLUMN workflow_id TEXT REFERENCES workflows(id);`);
|
|
286
|
-
} catch {
|
|
287
|
-
}
|
|
291
|
+
addColumnIfMissing(`ALTER TABLE tasks ADD COLUMN workflow_id TEXT REFERENCES workflows(id);`);
|
|
288
292
|
sqlite2.exec(`CREATE INDEX IF NOT EXISTS idx_tasks_workflow_id ON tasks(workflow_id);`);
|
|
289
|
-
|
|
290
|
-
sqlite2.exec(`ALTER TABLE tasks ADD COLUMN schedule_id TEXT REFERENCES schedules(id);`);
|
|
291
|
-
} catch {
|
|
292
|
-
}
|
|
293
|
+
addColumnIfMissing(`ALTER TABLE tasks ADD COLUMN schedule_id TEXT REFERENCES schedules(id);`);
|
|
293
294
|
sqlite2.exec(`CREATE INDEX IF NOT EXISTS idx_tasks_schedule_id ON tasks(schedule_id);`);
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
}
|
|
298
|
-
try {
|
|
299
|
-
sqlite2.exec(`ALTER TABLE schedules ADD COLUMN assigned_agent TEXT;`);
|
|
300
|
-
} catch {
|
|
301
|
-
}
|
|
302
|
-
try {
|
|
303
|
-
sqlite2.exec(`ALTER TABLE documents ADD COLUMN version INTEGER NOT NULL DEFAULT 1;`);
|
|
304
|
-
} catch {
|
|
305
|
-
}
|
|
295
|
+
addColumnIfMissing(`ALTER TABLE projects ADD COLUMN working_directory TEXT;`);
|
|
296
|
+
addColumnIfMissing(`ALTER TABLE schedules ADD COLUMN assigned_agent TEXT;`);
|
|
297
|
+
addColumnIfMissing(`ALTER TABLE documents ADD COLUMN version INTEGER NOT NULL DEFAULT 1;`);
|
|
306
298
|
}
|
|
307
299
|
function hasLegacyStagentTables(sqlite2) {
|
|
308
300
|
const placeholders = STAGENT_TABLES.map(() => "?").join(", ");
|
package/package.json
CHANGED
|
@@ -3,6 +3,11 @@ import { db } from "@/lib/db";
|
|
|
3
3
|
import { documents, tasks, projects } from "@/lib/db/schema";
|
|
4
4
|
import { eq, and, like, or, desc, sql } from "drizzle-orm";
|
|
5
5
|
|
|
6
|
+
const VALID_DOC_STATUSES = ["uploaded", "processing", "ready", "error"] as const;
|
|
7
|
+
const VALID_DOC_DIRECTIONS = ["input", "output"] as const;
|
|
8
|
+
type DocStatus = typeof VALID_DOC_STATUSES[number];
|
|
9
|
+
type DocDirection = typeof VALID_DOC_DIRECTIONS[number];
|
|
10
|
+
|
|
6
11
|
export async function GET(req: NextRequest) {
|
|
7
12
|
const url = new URL(req.url);
|
|
8
13
|
const taskId = url.searchParams.get("taskId");
|
|
@@ -11,12 +16,26 @@ export async function GET(req: NextRequest) {
|
|
|
11
16
|
const direction = url.searchParams.get("direction");
|
|
12
17
|
const search = url.searchParams.get("search");
|
|
13
18
|
|
|
19
|
+
if (status && !VALID_DOC_STATUSES.includes(status as DocStatus)) {
|
|
20
|
+
return NextResponse.json(
|
|
21
|
+
{ error: `Invalid status. Must be one of: ${VALID_DOC_STATUSES.join(", ")}` },
|
|
22
|
+
{ status: 400 }
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (direction && !VALID_DOC_DIRECTIONS.includes(direction as DocDirection)) {
|
|
27
|
+
return NextResponse.json(
|
|
28
|
+
{ error: `Invalid direction. Must be one of: ${VALID_DOC_DIRECTIONS.join(", ")}` },
|
|
29
|
+
{ status: 400 }
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
14
33
|
const conditions = [];
|
|
15
34
|
|
|
16
35
|
if (taskId) conditions.push(eq(documents.taskId, taskId));
|
|
17
36
|
if (projectId) conditions.push(eq(documents.projectId, projectId));
|
|
18
|
-
if (status) conditions.push(eq(documents.status, status as
|
|
19
|
-
if (direction) conditions.push(eq(documents.direction, direction as
|
|
37
|
+
if (status) conditions.push(eq(documents.status, status as DocStatus));
|
|
38
|
+
if (direction) conditions.push(eq(documents.direction, direction as DocDirection));
|
|
20
39
|
|
|
21
40
|
if (search) {
|
|
22
41
|
conditions.push(
|
|
@@ -6,14 +6,24 @@ import { createTaskSchema } from "@/lib/validators/task";
|
|
|
6
6
|
import { processDocument } from "@/lib/documents/processor";
|
|
7
7
|
import { validateRuntimeProfileAssignment } from "@/lib/agents/profiles/assignment-validation";
|
|
8
8
|
|
|
9
|
+
const VALID_TASK_STATUSES = ["planned", "queued", "running", "completed", "failed", "cancelled"] as const;
|
|
10
|
+
type TaskStatus = typeof VALID_TASK_STATUSES[number];
|
|
11
|
+
|
|
9
12
|
export async function GET(req: NextRequest) {
|
|
10
13
|
const url = new URL(req.url);
|
|
11
14
|
const projectId = url.searchParams.get("projectId");
|
|
12
15
|
const status = url.searchParams.get("status");
|
|
13
16
|
|
|
17
|
+
if (status && !VALID_TASK_STATUSES.includes(status as TaskStatus)) {
|
|
18
|
+
return NextResponse.json(
|
|
19
|
+
{ error: `Invalid status. Must be one of: ${VALID_TASK_STATUSES.join(", ")}` },
|
|
20
|
+
{ status: 400 }
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
14
24
|
const conditions = [];
|
|
15
25
|
if (projectId) conditions.push(eq(tasks.projectId, projectId));
|
|
16
|
-
if (status) conditions.push(eq(tasks.status, status as
|
|
26
|
+
if (status) conditions.push(eq(tasks.status, status as TaskStatus));
|
|
17
27
|
|
|
18
28
|
const result = await db
|
|
19
29
|
.select()
|
|
@@ -70,10 +80,13 @@ export async function POST(req: NextRequest) {
|
|
|
70
80
|
.where(eq(documents.id, fileId));
|
|
71
81
|
|
|
72
82
|
// Trigger processing if not already done (fire-and-forget)
|
|
73
|
-
processDocument(fileId).catch(() => {
|
|
83
|
+
processDocument(fileId).catch((err) => {
|
|
84
|
+
console.error(`[tasks] processDocument failed for ${fileId}:`, err);
|
|
85
|
+
});
|
|
74
86
|
}
|
|
75
|
-
} catch {
|
|
87
|
+
} catch (err) {
|
|
76
88
|
// File association is best-effort — don't fail task creation
|
|
89
|
+
console.error("[tasks] File association failed:", err);
|
|
77
90
|
}
|
|
78
91
|
}
|
|
79
92
|
|
|
@@ -3,6 +3,7 @@ import { writeFile, mkdir } from "fs/promises";
|
|
|
3
3
|
import { join } from "path";
|
|
4
4
|
import { db } from "@/lib/db";
|
|
5
5
|
import { documents } from "@/lib/db/schema";
|
|
6
|
+
import { eq } from "drizzle-orm";
|
|
6
7
|
import { processDocument } from "@/lib/documents/processor";
|
|
7
8
|
import { getStagentUploadsDir } from "@/lib/utils/stagent-paths";
|
|
8
9
|
|
|
@@ -48,9 +49,22 @@ export async function POST(req: NextRequest) {
|
|
|
48
49
|
});
|
|
49
50
|
|
|
50
51
|
// Fire-and-forget: trigger async document processing
|
|
51
|
-
processDocument(id).catch((err) =>
|
|
52
|
-
console.error(`[upload] Processing failed for ${id}:`, err)
|
|
53
|
-
|
|
52
|
+
processDocument(id).catch(async (err) => {
|
|
53
|
+
console.error(`[upload] Processing failed for ${id}:`, err);
|
|
54
|
+
// Ensure document doesn't stay stuck in "processing" state
|
|
55
|
+
try {
|
|
56
|
+
await db
|
|
57
|
+
.update(documents)
|
|
58
|
+
.set({
|
|
59
|
+
status: "error",
|
|
60
|
+
processingError: err instanceof Error ? err.message : String(err),
|
|
61
|
+
updatedAt: new Date(),
|
|
62
|
+
})
|
|
63
|
+
.where(eq(documents.id, id));
|
|
64
|
+
} catch (dbErr) {
|
|
65
|
+
console.error(`[upload] Failed to update error status for ${id}:`, dbErr);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
54
68
|
|
|
55
69
|
return NextResponse.json(
|
|
56
70
|
{
|
package/src/app/globals.css
CHANGED
|
@@ -659,6 +659,40 @@
|
|
|
659
659
|
border: 1px solid color-mix(in oklab, var(--border) 75%, transparent);
|
|
660
660
|
}
|
|
661
661
|
|
|
662
|
+
/* --- Progress slide animation (AI Assist) --- */
|
|
663
|
+
@keyframes progress-slide {
|
|
664
|
+
0% { transform: translateX(-100%); }
|
|
665
|
+
50% { transform: translateX(0%); }
|
|
666
|
+
100% { transform: translateX(100%); }
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/* --- Card exit animation (ghost card deletion) --- */
|
|
670
|
+
@keyframes card-exit {
|
|
671
|
+
0% {
|
|
672
|
+
opacity: 1;
|
|
673
|
+
transform: scale(1);
|
|
674
|
+
max-height: 300px;
|
|
675
|
+
}
|
|
676
|
+
40% {
|
|
677
|
+
opacity: 0;
|
|
678
|
+
transform: scale(0.97);
|
|
679
|
+
max-height: 300px;
|
|
680
|
+
}
|
|
681
|
+
100% {
|
|
682
|
+
opacity: 0;
|
|
683
|
+
transform: scale(0.95);
|
|
684
|
+
max-height: 0;
|
|
685
|
+
margin-top: 0;
|
|
686
|
+
margin-bottom: 0;
|
|
687
|
+
overflow: hidden;
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
.animate-card-exit {
|
|
692
|
+
animation: card-exit 500ms ease-out forwards;
|
|
693
|
+
overflow: hidden;
|
|
694
|
+
}
|
|
695
|
+
|
|
662
696
|
/* --- Glass shimmer animation --- */
|
|
663
697
|
@keyframes glass-shimmer {
|
|
664
698
|
0% { background-position: -200% 0; }
|
|
@@ -7,7 +7,12 @@ import { TaskCreatePanel } from "@/components/tasks/task-create-panel";
|
|
|
7
7
|
|
|
8
8
|
export const dynamic = "force-dynamic";
|
|
9
9
|
|
|
10
|
-
export default async function NewTaskPage(
|
|
10
|
+
export default async function NewTaskPage({
|
|
11
|
+
searchParams,
|
|
12
|
+
}: {
|
|
13
|
+
searchParams: Promise<{ project?: string }>;
|
|
14
|
+
}) {
|
|
15
|
+
const params = await searchParams;
|
|
11
16
|
const allProjects = await db
|
|
12
17
|
.select({ id: projects.id, name: projects.name })
|
|
13
18
|
.from(projects)
|
|
@@ -21,7 +26,10 @@ export default async function NewTaskPage() {
|
|
|
21
26
|
Back to Dashboard
|
|
22
27
|
</Button>
|
|
23
28
|
</Link>
|
|
24
|
-
<TaskCreatePanel
|
|
29
|
+
<TaskCreatePanel
|
|
30
|
+
projects={allProjects}
|
|
31
|
+
defaultProjectId={params.project}
|
|
32
|
+
/>
|
|
25
33
|
</div>
|
|
26
34
|
);
|
|
27
35
|
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { KanbanBoard, compareTasks } from "@/components/tasks/kanban-board";
|
|
3
|
+
import type { TaskItem } from "@/components/tasks/task-card";
|
|
4
|
+
|
|
5
|
+
vi.mock("next/navigation", () => ({
|
|
6
|
+
useRouter: () => ({
|
|
7
|
+
push: vi.fn(),
|
|
8
|
+
refresh: vi.fn(),
|
|
9
|
+
}),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
vi.mock("sonner", () => ({
|
|
13
|
+
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() },
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
class ResizeObserverMock {
|
|
17
|
+
observe() {}
|
|
18
|
+
disconnect() {}
|
|
19
|
+
unobserve() {}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const makeTask = (overrides: Partial<TaskItem> = {}): TaskItem => ({
|
|
23
|
+
id: "task-1",
|
|
24
|
+
title: "Test task",
|
|
25
|
+
description: "A test task",
|
|
26
|
+
status: "planned",
|
|
27
|
+
priority: 2,
|
|
28
|
+
assignedAgent: null,
|
|
29
|
+
agentProfile: null,
|
|
30
|
+
projectId: "project-1",
|
|
31
|
+
projectName: "Project A",
|
|
32
|
+
result: null,
|
|
33
|
+
sessionId: null,
|
|
34
|
+
resumeCount: 0,
|
|
35
|
+
createdAt: new Date("2026-03-12T09:00:00Z").toISOString(),
|
|
36
|
+
updatedAt: new Date("2026-03-12T09:00:00Z").toISOString(),
|
|
37
|
+
...overrides,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const defaultProjects = [
|
|
41
|
+
{ id: "project-1", name: "Project A" },
|
|
42
|
+
{ id: "project-2", name: "Project B" },
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
describe("kanban board persistence", () => {
|
|
46
|
+
beforeAll(() => {
|
|
47
|
+
vi.stubGlobal("ResizeObserver", ResizeObserverMock);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
afterAll(() => {
|
|
51
|
+
vi.unstubAllGlobals();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
localStorage.clear();
|
|
56
|
+
sessionStorage.clear();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("resets stale project filter to 'all'", () => {
|
|
60
|
+
// Pre-set a project ID that doesn't exist in the projects list
|
|
61
|
+
localStorage.setItem("stagent-project-filter", "deleted-project-999");
|
|
62
|
+
|
|
63
|
+
render(
|
|
64
|
+
<KanbanBoard
|
|
65
|
+
initialTasks={[makeTask()]}
|
|
66
|
+
projects={defaultProjects}
|
|
67
|
+
/>
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
// The task should be visible (not filtered out by a stale project ID)
|
|
71
|
+
expect(screen.getByText("Test task")).toBeInTheDocument();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("includes ?project= in New Task link when project is filtered", () => {
|
|
75
|
+
localStorage.setItem("stagent-project-filter", "project-1");
|
|
76
|
+
|
|
77
|
+
render(
|
|
78
|
+
<KanbanBoard
|
|
79
|
+
initialTasks={[makeTask()]}
|
|
80
|
+
projects={defaultProjects}
|
|
81
|
+
/>
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
const newTaskLink = screen.getByRole("link", { name: /new task/i });
|
|
85
|
+
expect(newTaskLink).toHaveAttribute("href", "/tasks/new?project=project-1");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("has plain /tasks/new link when filter is 'all'", () => {
|
|
89
|
+
render(
|
|
90
|
+
<KanbanBoard
|
|
91
|
+
initialTasks={[makeTask()]}
|
|
92
|
+
projects={defaultProjects}
|
|
93
|
+
/>
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const newTaskLink = screen.getByRole("link", { name: /new task/i });
|
|
97
|
+
expect(newTaskLink).toHaveAttribute("href", "/tasks/new");
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("compareTasks", () => {
|
|
102
|
+
const taskA = makeTask({ id: "a", title: "Alpha", priority: 0, createdAt: new Date("2026-03-10").toISOString() });
|
|
103
|
+
const taskB = makeTask({ id: "b", title: "Bravo", priority: 2, createdAt: new Date("2026-03-12").toISOString() });
|
|
104
|
+
|
|
105
|
+
it("sorts by priority ascending", () => {
|
|
106
|
+
expect(compareTasks(taskA, taskB, "priority")).toBeLessThan(0);
|
|
107
|
+
expect(compareTasks(taskB, taskA, "priority")).toBeGreaterThan(0);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("sorts by created-desc (newer first)", () => {
|
|
111
|
+
expect(compareTasks(taskB, taskA, "created-desc")).toBeLessThan(0);
|
|
112
|
+
expect(compareTasks(taskA, taskB, "created-desc")).toBeGreaterThan(0);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("sorts by created-asc (older first)", () => {
|
|
116
|
+
expect(compareTasks(taskA, taskB, "created-asc")).toBeLessThan(0);
|
|
117
|
+
expect(compareTasks(taskB, taskA, "created-asc")).toBeGreaterThan(0);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("sorts by title-asc (alphabetical)", () => {
|
|
121
|
+
expect(compareTasks(taskA, taskB, "title-asc")).toBeLessThan(0);
|
|
122
|
+
expect(compareTasks(taskB, taskA, "title-asc")).toBeGreaterThan(0);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { TaskCreatePanel } from "@/components/tasks/task-create-panel";
|
|
3
|
+
|
|
4
|
+
vi.mock("next/navigation", () => ({
|
|
5
|
+
useRouter: () => ({
|
|
6
|
+
push: vi.fn(),
|
|
7
|
+
refresh: vi.fn(),
|
|
8
|
+
}),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
vi.mock("sonner", () => ({
|
|
12
|
+
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() },
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
const projects = [
|
|
16
|
+
{ id: "proj-1", name: "Alpha Project" },
|
|
17
|
+
{ id: "proj-2", name: "Beta Project" },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
describe("TaskCreatePanel defaultProjectId", () => {
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
// Mock the /api/profiles fetch
|
|
23
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
24
|
+
ok: true,
|
|
25
|
+
json: () => Promise.resolve([]),
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
vi.restoreAllMocks();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("pre-selects project when defaultProjectId is provided", () => {
|
|
34
|
+
render(
|
|
35
|
+
<TaskCreatePanel projects={projects} defaultProjectId="proj-1" />
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
// The select-value span should show the project name
|
|
39
|
+
const selectValues = screen.getAllByText("Alpha Project");
|
|
40
|
+
const triggerValue = selectValues.find(
|
|
41
|
+
(el) => el.getAttribute("data-slot") === "select-value"
|
|
42
|
+
);
|
|
43
|
+
expect(triggerValue).toBeDefined();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("shows 'None' when no defaultProjectId is provided", () => {
|
|
47
|
+
render(
|
|
48
|
+
<TaskCreatePanel projects={projects} />
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
// The select-value span should show "None"
|
|
52
|
+
const noneElements = screen.getAllByText("None");
|
|
53
|
+
const triggerValue = noneElements.find(
|
|
54
|
+
(el) => el.getAttribute("data-slot") === "select-value"
|
|
55
|
+
);
|
|
56
|
+
expect(triggerValue).toBeDefined();
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useState } from "react";
|
|
3
|
+
import { useState, useEffect } from "react";
|
|
4
4
|
import { Button } from "@/components/ui/button";
|
|
5
5
|
import { Card, CardContent } from "@/components/ui/card";
|
|
6
6
|
import { Badge } from "@/components/ui/badge";
|
|
7
|
-
import { Sparkles,
|
|
7
|
+
import { Sparkles, Check, X } from "lucide-react";
|
|
8
8
|
|
|
9
9
|
interface TaskSuggestion {
|
|
10
10
|
title: string;
|
|
@@ -42,6 +42,45 @@ const complexityColors: Record<string, string> = {
|
|
|
42
42
|
complex: "text-complexity-complex",
|
|
43
43
|
};
|
|
44
44
|
|
|
45
|
+
const ACTIVITY_MESSAGES = [
|
|
46
|
+
"Connecting to AI...",
|
|
47
|
+
"Analyzing task complexity...",
|
|
48
|
+
"Generating suggestions...",
|
|
49
|
+
"Finalizing...",
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
function ProgressBar({ loading }: { loading: boolean }) {
|
|
53
|
+
const [messageIndex, setMessageIndex] = useState(0);
|
|
54
|
+
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
if (!loading) {
|
|
57
|
+
setMessageIndex(0);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const interval = setInterval(() => {
|
|
62
|
+
setMessageIndex((prev) =>
|
|
63
|
+
prev < ACTIVITY_MESSAGES.length - 1 ? prev + 1 : prev
|
|
64
|
+
);
|
|
65
|
+
}, 3000);
|
|
66
|
+
|
|
67
|
+
return () => clearInterval(interval);
|
|
68
|
+
}, [loading]);
|
|
69
|
+
|
|
70
|
+
if (!loading) return null;
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<div className="space-y-1.5">
|
|
74
|
+
<div className="h-1.5 w-full rounded-full bg-muted overflow-hidden">
|
|
75
|
+
<div className="h-full w-full rounded-full bg-primary animate-[progress-slide_1.5s_ease-in-out_infinite]" />
|
|
76
|
+
</div>
|
|
77
|
+
<p className="text-xs text-muted-foreground text-center">
|
|
78
|
+
{ACTIVITY_MESSAGES[messageIndex]}
|
|
79
|
+
</p>
|
|
80
|
+
</div>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
45
84
|
export function AIAssistPanel({
|
|
46
85
|
title,
|
|
47
86
|
description,
|
|
@@ -87,7 +126,7 @@ export function AIAssistPanel({
|
|
|
87
126
|
|
|
88
127
|
if (!result) {
|
|
89
128
|
return (
|
|
90
|
-
<div className="pt-2">
|
|
129
|
+
<div className="pt-2 space-y-2">
|
|
91
130
|
<Button
|
|
92
131
|
type="button"
|
|
93
132
|
variant="outline"
|
|
@@ -96,17 +135,16 @@ export function AIAssistPanel({
|
|
|
96
135
|
disabled={loading || (!title.trim() && !description.trim())}
|
|
97
136
|
className="w-full"
|
|
98
137
|
>
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
) : (
|
|
102
|
-
<Sparkles className="h-3 w-3 mr-1" />
|
|
103
|
-
)}
|
|
104
|
-
{loading ? "Analyzing..." : "AI Assist"}
|
|
138
|
+
<Sparkles className="h-3 w-3 mr-1" />
|
|
139
|
+
AI Assist
|
|
105
140
|
</Button>
|
|
141
|
+
<ProgressBar loading={loading} />
|
|
106
142
|
{error && <p className="text-xs text-destructive mt-1">{error}</p>}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
143
|
+
{!loading && (
|
|
144
|
+
<p className="text-xs text-muted-foreground text-center mt-1.5">
|
|
145
|
+
Suggests improved descriptions, sub-task breakdowns, and workflow patterns
|
|
146
|
+
</p>
|
|
147
|
+
)}
|
|
110
148
|
</div>
|
|
111
149
|
);
|
|
112
150
|
}
|