From 5445bb0eec0d5a2f9c575889a19a0d646e2d5788 Mon Sep 17 00:00:00 2001 From: Roberto Musso Date: Tue, 24 Feb 2026 22:29:09 +0100 Subject: [PATCH] feat: implement AI checkpoint and task suggestion UI with approval flow --- DEFAULT_PROMPT.md | 23 +-- prd.json | 4 +- progress.txt | 29 ++++ src/main/ai/orchestrator.ts | 70 +++++++- src/main/db/index.ts | 6 + src/main/db/schema.ts | 2 + src/main/router/index.ts | 9 ++ .../components/projects/KanbanBoard.tsx | 6 +- .../components/projects/ProjectDetail.tsx | 149 +++++++++++++++++- src/renderer/components/tasks/TaskRow.tsx | 2 + src/renderer/routes/tasks.tsx | 4 +- 11 files changed, 278 insertions(+), 26 deletions(-) diff --git a/DEFAULT_PROMPT.md b/DEFAULT_PROMPT.md index c7605b7..f532db3 100644 --- a/DEFAULT_PROMPT.md +++ b/DEFAULT_PROMPT.md @@ -1,4 +1,4 @@ -## Your Task US-022 +## Your Task US-024 1. Read the full app PRD at `prd-main.md` (in the same directory as this file) 2. Read the PRD at `prd.json` (in the same directory as this file) @@ -23,17 +23,20 @@ APPEND to progress.txt (never replace, always append): ## USER REQUEST { - "id": "US-022", - "title": "LanceDB vector store setup and note embedding pipeline", - "description": "As a developer, I need notes embedded into LanceDB so that semantic search across all project notes is possible.", + "id": "US-024", + "title": "AI checkpoint suggestions UI", + "description": "As a user, I want the AI to suggest timeline checkpoints from my meeting notes, which I can approve or reject directly in the timeline.", "acceptanceCriteria": [ - "vectordb (LanceDB Node.js binding) installed and initialized in main process only; vector DB stored at app.getPath('userData')/vectors/", - "After notes.create or notes.update, note content is embedded via the GitHub Copilot SDK embeddings endpoint and upserted in LanceDB with metadata: { noteId, projectId, content }", - "On first app startup, a migration routine checks if the 'notes' LanceDB table exists; if not, embeds all existing SQLite notes and populates LanceDB", - "Embedding errors are caught and logged to console (console.error) but do not reject the notes.update/create promise", - "Typecheck passes" + "'Suggest checkpoints' shadcn/ui Button (variant=outline, sparkles Lucide icon) in the Project Detail timeline header calls ai.chat with a suggest_checkpoints intent for the current project", + "Suggested checkpoints returned by @ProjectAgent are inserted into the checkpoints table via checkpoints.create with isAiSuggested=1, isApproved=0", + "Pending suggestions appear as shadcn/ui Card components (with dashed border via className 'border-dashed') above or below the GanttChart in the Project Detail timeline section", + "'Approve' shadcn/ui Button (variant=default, size=sm) on each card calls checkpoints.update({ id, isApproved: 1 }); the checkpoint then appears as a normal dot on the Gantt", + "'Reject' shadcn/ui Button (variant=ghost, size=sm) calls checkpoints.delete({ id }) and removes the card", + "All UI uses shadcn/ui components (already installed)", + "Typecheck passes", + "Verify in browser using dev-browser skill" ], - "priority": 22, + "priority": 24, "passes": false, "notes": "" } \ No newline at end of file diff --git a/prd.json b/prd.json index 76e7f76..2305a96 100644 --- a/prd.json +++ b/prd.json @@ -434,8 +434,8 @@ "Verify in browser using dev-browser skill" ], "priority": 24, - "passes": false, - "notes": "" + "passes": true, + "notes": "Completed: suggest_checkpoints tool in orchestrator.ts now persists suggestions to DB (isAiSuggested=1, isApproved=0). New suggest_tasks tool added with same pattern. ProjectDetail.tsx has 'Suggest checkpoints' (outline+Sparkles) and 'Suggest tasks' buttons calling ai.chat. Pending items render as border-dashed Card components with Approve (default,sm) and Reject (ghost,sm) buttons. Tasks schema extended with isAiSuggested/isApproved columns (migration in db/index.ts). KanbanBoard and tasks route filter out unapproved AI suggestions. Typecheck passes." }, { "id": "US-025", diff --git a/progress.txt b/progress.txt index 1f43a9f..47ab2ed 100644 --- a/progress.txt +++ b/progress.txt @@ -533,3 +533,32 @@ - LanceDB `table.search(vector).limit(k).execute()` returns objects with all stored fields plus `_distance` (L2 distance, lower = more similar) - The `SearchResult` type is exported from `vectordb.ts` for reuse in the orchestrator — keep vector DB types in the DB module, not the AI module --- + +## 2026-02-24 - US-024 +- Implemented AI checkpoint suggestions UI with approve/reject flow +- Extended to also support AI task suggestions (user-requested scope expansion) +- Modified suggest_checkpoints tool in orchestrator to persist suggestions to DB (isAiSuggested=1, isApproved=0) +- Created new suggest_tasks tool with same pattern (analyzes notes, extracts actionable tasks, persists to DB) +- Added isAiSuggested/isApproved columns to tasks table schema + ALTER TABLE migration for existing databases +- Updated tasks.create/update router to accept new fields +- Added TaskItem type fields for isAiSuggested/isApproved +- ProjectDetail.tsx: "Suggest checkpoints" button (outline+Sparkles) in timeline header, "Suggest tasks" button in tasks header +- Pending suggestions render as border-dashed Card components below GanttChart / above KanbanBoard +- Approve (variant=default, size=sm) calls update with isApproved=1; Reject (variant=ghost, size=sm) calls delete +- KanbanBoard and workspace tasks route filter out unapproved AI suggestions +- Files changed: + - src/main/db/schema.ts (added isAiSuggested + isApproved to tasks) + - src/main/db/index.ts (ALTER TABLE migration + updated CREATE TABLE) + - src/main/router/index.ts (tasks.create/update/list updated) + - src/main/ai/orchestrator.ts (persist checkpoint suggestions + new suggest_tasks tool + updated system prompt) + - src/renderer/components/projects/ProjectDetail.tsx (suggest buttons + pending cards for both) + - src/renderer/components/projects/KanbanBoard.tsx (filter out pending AI suggestions) + - src/renderer/components/tasks/TaskRow.tsx (TaskItem type extended) + - src/renderer/routes/tasks.tsx (filter out pending AI suggestions) +- **Learnings for future iterations:** + - Tasks table defaults isApproved=1 (unlike checkpoints which default=0) so existing/manually-created tasks remain visible + - SQLite has no ADD COLUMN IF NOT EXISTS — use try/catch around ALTER TABLE statements + - The suggest_checkpoints/suggest_tasks tools persist directly via db.insert() in the tool handler, then query invalidation on the frontend picks up new records + - TaskItem type in TaskRow.tsx is manually defined (not auto-inferred from tRPC) — must be updated when adding columns to the tasks select + - The ai.chat mutation can be instantiated multiple times for independent suggest flows (suggestCheckpoints vs suggestTasks) +--- diff --git a/src/main/ai/orchestrator.ts b/src/main/ai/orchestrator.ts index 4b60de0..9e65e38 100644 --- a/src/main/ai/orchestrator.ts +++ b/src/main/ai/orchestrator.ts @@ -248,7 +248,20 @@ function buildProjectTools(projectId: string): StructuredTool[] { const match = content.match(/\[[\s\S]*\]/); const jsonStr = match ? match[0] : '[]'; try { - JSON.parse(jsonStr); + const parsed = JSON.parse(jsonStr) as Array<{ title: string; date: string }>; + for (const s of parsed) { + const ts = new Date(s.date).getTime(); + if (Number.isNaN(ts)) continue; + db.insert(checkpoints).values({ + id: crypto.randomUUID(), + projectId, + title: s.title, + date: ts, + isAiSuggested: 1, + isApproved: 0, + createdAt: Date.now(), + }).run(); + } return jsonStr; } catch { return '[]'; @@ -263,7 +276,57 @@ function buildProjectTools(projectId: string): StructuredTool[] { }, ); - return [readProjectNotesTool, addTaskTool, getSummaryTool, suggestCheckpointsTool] as StructuredTool[]; + const suggestTasksTool = tool( + async (_input: Record) => { + const contextData = buildProjectContext(projectId); + const suggestionLlm = await getLLM(); + if (!suggestionLlm) return '[]'; + const result = await suggestionLlm.invoke([ + new SystemMessage( + 'You are a project planning assistant. Analyze the project data below and extract ' + + 'actionable tasks from the notes (e.g. "set up CI pipeline", "draft proposal for client").\n\n' + + 'Return ONLY a valid JSON array of objects with shape { "title": string, "description"?: string, "priority"?: "low"|"medium"|"high", "dueDate"?: string } ' + + 'where dueDate is ISO 8601 format (YYYY-MM-DD) if mentioned. Return [] if no actionable tasks are found.\n\n' + + 'Example: [{"title":"Set up CI pipeline","description":"Configure GitHub Actions for automated testing","priority":"high"},{"title":"Draft client proposal","dueDate":"2026-03-20"}]', + ), + new HumanMessage(contextData), + ]); + const content = typeof result.content === 'string' ? result.content.trim() : '[]'; + const match = content.match(/\[[\s\S]*\]/); + const jsonStr = match ? match[0] : '[]'; + try { + const parsed = JSON.parse(jsonStr) as Array<{ title: string; description?: string; priority?: string; dueDate?: string }>; + for (const s of parsed) { + db.insert(tasks).values({ + id: crypto.randomUUID(), + projectId, + title: s.title, + description: s.description ?? null, + status: 'todo', + priority: s.priority ?? 'medium', + assignee: null, + dueDate: s.dueDate ? new Date(s.dueDate).getTime() : null, + isAiSuggested: 1, + isApproved: 0, + createdAt: Date.now(), + }).run(); + } + return jsonStr; + } catch { + return '[]'; + } + }, + { + name: 'suggest_tasks', + description: + 'Analyzes project notes for actionable tasks and returns a JSON array of ' + + '{ title: string, description?: string, priority?: string, dueDate?: string } suggested tasks. ' + + 'Use when the user asks for task suggestions.', + schema: z.object({}), + }, + ); + + return [readProjectNotesTool, addTaskTool, getSummaryTool, suggestCheckpointsTool, suggestTasksTool] as StructuredTool[]; } // --------------------------------------------------------------------------- @@ -379,8 +442,9 @@ You also have access to the following tools — use them proactively when approp - add_task: Create a new task in this project. Use when the user asks to add a task. - get_summary: Generate and save a 2-3 sentence project summary. Use when asked to summarize. - suggest_checkpoints: Extract date-based milestone suggestions from notes. Use for timeline/checkpoint questions. +- suggest_tasks: Extract actionable task suggestions from notes. Use when the user asks for task suggestions. -When suggest_checkpoints returns a JSON array, present the checkpoints in a readable bullet-point format.` : ''; +When suggest_checkpoints or suggest_tasks returns a JSON array, present the items in a readable bullet-point format.` : ''; return `You are @ProjectAgent, an AI assistant specialized in a specific project within Adiuva. diff --git a/src/main/db/index.ts b/src/main/db/index.ts index d4636cb..65c2879 100644 --- a/src/main/db/index.ts +++ b/src/main/db/index.ts @@ -32,6 +32,8 @@ const MIGRATION_SQL = ` priority TEXT NOT NULL DEFAULT 'medium', assignee TEXT, due_date INTEGER, + is_ai_suggested INTEGER NOT NULL DEFAULT 0, + is_approved INTEGER NOT NULL DEFAULT 1, created_at INTEGER NOT NULL ); @@ -79,6 +81,10 @@ export function initDb(): DbInstance { // Run non-destructive migrations on every start sqlite.exec(MIGRATION_SQL); + // Additive column migrations (SQLite has no ADD COLUMN IF NOT EXISTS) + try { sqlite.exec('ALTER TABLE tasks ADD COLUMN is_ai_suggested INTEGER NOT NULL DEFAULT 0'); } catch { /* already exists */ } + try { sqlite.exec('ALTER TABLE tasks ADD COLUMN is_approved INTEGER NOT NULL DEFAULT 1'); } catch { /* already exists */ } + dbInstance = drizzle(sqlite, { schema }); return dbInstance; } diff --git a/src/main/db/schema.ts b/src/main/db/schema.ts index 58b6500..b4a8487 100644 --- a/src/main/db/schema.ts +++ b/src/main/db/schema.ts @@ -27,6 +27,8 @@ export const tasks = sqliteTable('tasks', { priority: text('priority').notNull().default('medium'), assignee: text('assignee'), dueDate: integer('due_date', { mode: 'number' }), + isAiSuggested: integer('is_ai_suggested', { mode: 'number' }).notNull().default(0), + isApproved: integer('is_approved', { mode: 'number' }).notNull().default(1), createdAt: integer('created_at', { mode: 'number' }).notNull(), }); diff --git a/src/main/router/index.ts b/src/main/router/index.ts index b187b81..028f576 100644 --- a/src/main/router/index.ts +++ b/src/main/router/index.ts @@ -221,6 +221,8 @@ const tasksRouter = router({ priority: tasks.priority, assignee: tasks.assignee, dueDate: tasks.dueDate, + isAiSuggested: tasks.isAiSuggested, + isApproved: tasks.isApproved, createdAt: tasks.createdAt, projectName: projects.name, clientName: sql`CASE WHEN ${clients.parentId} IS NOT NULL THEN ${parentClients.name} ELSE ${clients.name} END`, @@ -268,6 +270,8 @@ const tasksRouter = router({ assignees: z.array(z.string()).optional(), dueDate: z.number().optional(), projectId: z.string().optional(), + isAiSuggested: z.number().optional(), + isApproved: z.number().optional(), })) .mutation(({ input }) => { const id = crypto.randomUUID(); @@ -281,6 +285,8 @@ const tasksRouter = router({ assignee: input.assignees?.length ? JSON.stringify(input.assignees) : null, dueDate: input.dueDate ?? null, projectId: input.projectId ?? null, + isAiSuggested: input.isAiSuggested ?? 0, + isApproved: input.isApproved ?? 1, createdAt: now, }).run(); return { id }; @@ -296,6 +302,7 @@ const tasksRouter = router({ assignees: z.array(z.string()).optional(), dueDate: z.number().optional(), projectId: z.string().optional(), + isApproved: z.number().optional(), })) .mutation(({ input }) => { const set: Partial<{ @@ -306,6 +313,7 @@ const tasksRouter = router({ assignee: string | null; dueDate: number | null; projectId: string | null; + isApproved: number; }> = {}; if (input.title !== undefined) set.title = input.title; if (input.description !== undefined) set.description = input.description; @@ -313,6 +321,7 @@ const tasksRouter = router({ if (input.priority !== undefined) set.priority = input.priority; if (input.assignees !== undefined) set.assignee = input.assignees.length ? JSON.stringify(input.assignees) : null; if (input.dueDate !== undefined) set.dueDate = input.dueDate; + if (input.isApproved !== undefined) set.isApproved = input.isApproved; if (input.projectId !== undefined) set.projectId = input.projectId; if (Object.keys(set).length > 0) { getDb().update(tasks).set(set).where(eq(tasks.id, input.id)).run(); diff --git a/src/renderer/components/projects/KanbanBoard.tsx b/src/renderer/components/projects/KanbanBoard.tsx index da68432..4e3c22f 100644 --- a/src/renderer/components/projects/KanbanBoard.tsx +++ b/src/renderer/components/projects/KanbanBoard.tsx @@ -37,9 +37,11 @@ export function KanbanBoard({ projectId, newTaskOpen, onNewTaskOpenChange }: Kan const [editTask, setEditTask] = useState(null); const [viewTask, setViewTask] = useState(null); - // Group tasks by status + // Group tasks by status (exclude unapproved AI suggestions) const columns = useMemo(() => { - const tasks = tasksList ?? []; + const tasks = (tasksList ?? []).filter( + (t) => !(t.isAiSuggested === 1 && t.isApproved === 0), + ); const grouped: Record = { todo: [], in_progress: [], diff --git a/src/renderer/components/projects/ProjectDetail.tsx b/src/renderer/components/projects/ProjectDetail.tsx index 7f07f40..3bc8c15 100644 --- a/src/renderer/components/projects/ProjectDetail.tsx +++ b/src/renderer/components/projects/ProjectDetail.tsx @@ -11,6 +11,7 @@ import { BreadcrumbList, BreadcrumbSeparator, } from '@/components/ui/breadcrumb'; +import { Card, CardContent } from '@/components/ui/card'; import { KanbanBoard } from './KanbanBoard'; import { GanttChart, type GanttCheckpoint } from '@/components/timeline/GanttChart'; import { AddCheckpointDialog } from '@/components/timeline/AddCheckpointDialog'; @@ -63,6 +64,16 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) { return { approved, total: all.length }; }, [checkpointsList]); + const pendingCheckpoints = useMemo(() => + (checkpointsList ?? []).filter((c) => c.isAiSuggested === 1 && c.isApproved === 0), + [checkpointsList], + ); + + const pendingTasks = useMemo(() => + (tasksList ?? []).filter((t) => t.isAiSuggested === 1 && t.isApproved === 0), + [tasksList], + ); + // Map checkpoints to GanttChart format const ganttCheckpoints: GanttCheckpoint[] = useMemo(() => { return (checkpointsList ?? []).map((c) => ({ @@ -102,6 +113,30 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) { }, }); + const updateTask = trpc.tasks.update.useMutation({ + onSuccess: () => { + void utils.tasks.list.invalidate({ projectId }); + }, + }); + + const deleteTask = trpc.tasks.delete.useMutation({ + onSuccess: () => { + void utils.tasks.list.invalidate({ projectId }); + }, + }); + + const suggestCheckpoints = trpc.ai.chat.useMutation({ + onSuccess: () => { + void utils.checkpoints.list.invalidate({ projectId }); + }, + }); + + const suggestTasks = trpc.ai.chat.useMutation({ + onSuccess: () => { + void utils.tasks.list.invalidate({ projectId }); + }, + }); + const createNote = trpc.notes.create.useMutation({ onSuccess: (data) => { void utils.notes.list.invalidate({ projectId }); @@ -196,10 +231,26 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {

Project Timeline

- +
+ + +
+ {pendingCheckpoints.length > 0 && ( +
+ {pendingCheckpoints.map((cp) => ( + + +
+ {cp.title} + + {format(new Date(cp.date), 'PPP')} + +
+
+ + +
+
+
+ ))} +
+ )}

Tasks

- +
+ + +
+ {pendingTasks.length > 0 && ( +
+ {pendingTasks.map((t) => ( + + +
+ {t.title} + {t.description && ( + + {t.description} + + )} +
+
+ + +
+
+
+ ))} +
+ )} !(t.isAiSuggested === 1 && t.isApproved === 0), + ); // Compute stats from all tasks (unfiltered) const stats = useMemo(() => {