From 4714a84d35c5614d6fe91a78fd6a943646933dba Mon Sep 17 00:00:00 2001 From: Isaiah Date: Mon, 18 May 2026 09:16:10 -0400 Subject: [PATCH] feat: replace AI Planner with agentic AI Assistant Replaces the form-based AI Planner with a natural language AI Assistant powered by Claude tool use. Users describe what they want in plain text and the AI decides what to create, update, or delete across all projects. - Agentic tool-use loop: create/update/delete sprints, work items, projects - Cross-project support: loads all projects on open, can move items between projects - move_work_item tool handles the delete+recreate needed for cross-project moves - liveContext tracks mid-session creations so Claude can chain operations (e.g. create a project then immediately move items into it) - Single prompt UI replaces multi-field wizard Co-Authored-By: Claude Sonnet 4.6 --- src/App.tsx | 56 ++- src/api/ai.ts | 369 ++++++++++++++++++- src/components/GlobalCreateButton.tsx | 2 +- src/components/dialogs/AiAssistantDialog.tsx | 223 +++++++++++ 4 files changed, 639 insertions(+), 11 deletions(-) create mode 100644 src/components/dialogs/AiAssistantDialog.tsx diff --git a/src/App.tsx b/src/App.tsx index 7c03e4b..45119ee 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,7 +13,7 @@ import GlobalCreateButton from './components/GlobalCreateButton' import WorkItemDialog from './components/dialogs/WorkItemDialog' import NewSprintDialog from './components/dialogs/NewSprintDialog' import NewProjectDialog from './components/dialogs/NewProjectDialog' -import AiProjectPlannerDialog from './components/dialogs/AiProjectPlannerDialog' +import AiAssistantDialog from './components/dialogs/AiAssistantDialog' export default function App() { const [token, setToken] = useState(loadToken) @@ -127,17 +127,49 @@ export default function App() { if (projectId === selectedId) setSprints(s => [...s, sprint]) } - async function handleAiSprintCreate(data: Omit, projectId: string): Promise { + async function handleAiItemCreate(projectId: string, data: Omit): Promise { + if (!effectiveToken) throw new Error('Not authenticated') + const item = await api.createWorkItem(effectiveToken, projectId, data) + if (projectId === selectedId) setWorkItems(w => [...w, item]) + return item + } + + async function handleAiUpdateItem(item: WorkItem): Promise { + if (!effectiveToken) return + const updated = await api.updateWorkItem(effectiveToken, item.projectId, item) + if (item.projectId === selectedId) setWorkItems(w => w.map(x => x.id === item.id ? updated : x)) + } + + async function handleAiDeleteItem(item: WorkItem): Promise { + if (!effectiveToken) return + await api.deleteWorkItem(effectiveToken, item.projectId, item.id) + if (item.projectId === selectedId) setWorkItems(w => w.filter(x => x.id !== item.id)) + } + + async function handleAiCreateProject(name: string, description: string, color: string): Promise { + if (!effectiveToken) throw new Error('Not authenticated') + const project = await api.createProject(effectiveToken, name, description, color, projects.length) + setProjects(p => [...p, project]) + return project + } + + async function handleAiCreateSprint(projectId: string, data: Omit): Promise { if (!effectiveToken) throw new Error('Not authenticated') const sprint = await api.createSprint(effectiveToken, projectId, data) if (projectId === selectedId) setSprints(s => [...s, sprint]) return sprint } - async function handleAiItemCreate(data: Omit, projectId: string) { + async function handleAiUpdateSprint(sprint: Sprint): Promise { if (!effectiveToken) return - const item = await api.createWorkItem(effectiveToken, projectId, data) - if (projectId === selectedId) setWorkItems(w => [...w, item]) + const updated = await api.updateSprint(effectiveToken, sprint.projectId, sprint) + if (sprint.projectId === selectedId) setSprints(s => s.map(x => x.id === sprint.id ? updated : x)) + } + + async function handleAiDeleteSprint(sprint: Sprint): Promise { + if (!effectiveToken) return + await api.deleteSprint(effectiveToken, sprint.projectId, sprint.id) + if (sprint.projectId === selectedId) setSprints(s => s.filter(x => x.id !== sprint.id)) } function handleSignOut() { @@ -193,11 +225,17 @@ export default function App() { /> )} {createType === 'ai' && selectedId && ( - api.listProjectEvents(effectiveToken!, projectId)} + onCreateProject={handleAiCreateProject} + onCreateSprint={handleAiCreateSprint} + onUpdateSprint={handleAiUpdateSprint} + onDeleteSprint={handleAiDeleteSprint} + onCreateWorkItem={handleAiItemCreate} + onUpdateWorkItem={handleAiUpdateItem} + onDeleteWorkItem={handleAiDeleteItem} onClose={() => setCreateType(null)} /> )} diff --git a/src/api/ai.ts b/src/api/ai.ts index cc473cb..834c7c0 100644 --- a/src/api/ai.ts +++ b/src/api/ai.ts @@ -1,5 +1,5 @@ import Anthropic from '@anthropic-ai/sdk' -import type { Sprint, WorkItem } from '../types' +import type { Project, Sprint, WorkItem, SprintStatus, WorkItemType, WorkItemStatus, Priority } from '../types' export interface AiSprintPlan { sprint: Omit @@ -161,3 +161,370 @@ Respond with ONLY valid JSON — no markdown fences, no explanation: throw new Error('Claude returned invalid JSON. Try again.') } } + +// ─── AI Assistant (agentic tool-use) ───────────────────────────────────────── + +export interface AiCommandContext { + projects: Project[] + sprints: Sprint[] + workItems: WorkItem[] + selectedProjectId: string | null +} + +export interface AiCommandCallbacks { + createProject: (name: string, description: string, color: string) => Promise + createSprint: (projectId: string, data: Omit) => Promise + updateSprint: (sprint: Sprint) => Promise + deleteSprint: (sprint: Sprint) => Promise + createWorkItem: (projectId: string, data: Omit) => Promise + updateWorkItem: (item: WorkItem) => Promise + deleteWorkItem: (item: WorkItem) => Promise + onLog: (message: string) => void +} + +const AI_TOOLS: Anthropic.Tool[] = [ + { + name: 'create_project', + description: 'Create a new project. Call this before moving items into it.', + input_schema: { + type: 'object', + properties: { + name: { type: 'string' }, + description: { type: 'string' }, + color: { type: 'string', description: 'Color ID 1–11 (1=blue 2=green 3=purple 4=red 5=yellow 6=orange 7=teal 8=gray 9=bold-blue 10=bold-green 11=bold-red)' }, + }, + required: ['name'], + }, + }, + { + name: 'create_work_item', + description: 'Create a new work item (task, bug, or feature)', + input_schema: { + type: 'object', + properties: { + projectId: { type: 'string', description: 'Project ID — omit to use the selected project' }, + title: { type: 'string' }, + description: { type: 'string' }, + type: { type: 'string', enum: ['task', 'bug', 'feature'] }, + priority: { type: 'string', enum: ['high', 'medium', 'low'] }, + status: { type: 'string', enum: ['new', 'active', 'resolved', 'closed'] }, + storyPoints: { type: 'number', description: 'Fibonacci: 1 2 3 5 8 13 21' }, + sprintId: { type: 'string', description: 'Sprint ID to assign to, or empty string for backlog' }, + deadline: { type: 'string', description: 'YYYY-MM-DD or empty string' }, + assignee: { type: 'string' }, + }, + required: ['title', 'type', 'priority', 'status'], + }, + }, + { + name: 'update_work_item', + description: 'Update fields on an existing work item', + input_schema: { + type: 'object', + properties: { + workItemId: { type: 'string', description: 'ID of the work item to update' }, + title: { type: 'string' }, + description: { type: 'string' }, + type: { type: 'string', enum: ['task', 'bug', 'feature'] }, + priority: { type: 'string', enum: ['high', 'medium', 'low'] }, + status: { type: 'string', enum: ['new', 'active', 'resolved', 'closed'] }, + storyPoints: { type: 'number' }, + sprintId: { type: 'string' }, + deadline: { type: 'string' }, + assignee: { type: 'string' }, + }, + required: ['workItemId'], + }, + }, + { + name: 'delete_work_item', + description: 'Permanently delete a work item', + input_schema: { + type: 'object', + properties: { + workItemId: { type: 'string', description: 'ID of the work item to delete' }, + }, + required: ['workItemId'], + }, + }, + { + name: 'create_sprint', + description: 'Create a new sprint in a project', + input_schema: { + type: 'object', + properties: { + projectId: { type: 'string', description: 'Project ID — omit to use the selected project' }, + name: { type: 'string' }, + description: { type: 'string' }, + startDate: { type: 'string', description: 'YYYY-MM-DD' }, + endDate: { type: 'string', description: 'YYYY-MM-DD' }, + status: { type: 'string', enum: ['planned', 'active', 'completed'] }, + }, + required: ['name', 'startDate', 'endDate', 'status'], + }, + }, + { + name: 'update_sprint', + description: 'Update fields on an existing sprint', + input_schema: { + type: 'object', + properties: { + sprintId: { type: 'string', description: 'ID of the sprint to update' }, + name: { type: 'string' }, + description: { type: 'string' }, + startDate: { type: 'string' }, + endDate: { type: 'string' }, + status: { type: 'string', enum: ['planned', 'active', 'completed'] }, + }, + required: ['sprintId'], + }, + }, + { + name: 'delete_sprint', + description: 'Permanently delete a sprint', + input_schema: { + type: 'object', + properties: { + sprintId: { type: 'string', description: 'ID of the sprint to delete' }, + }, + required: ['sprintId'], + }, + }, + { + name: 'move_work_item', + description: 'Move a work item to a different project. Use this whenever the target project differs from the item\'s current project — you cannot change a work item\'s project via update_work_item.', + input_schema: { + type: 'object', + properties: { + workItemId: { type: 'string', description: 'ID of the work item to move' }, + targetProjectId: { type: 'string', description: 'ID of the destination project' }, + targetSprintId: { type: 'string', description: 'Sprint ID in the destination project, or empty string for backlog' }, + }, + required: ['workItemId', 'targetProjectId'], + }, + }, +] + +function buildSystemPrompt(context: AiCommandContext): string { + const today = new Date().toISOString().slice(0, 10) + + const projectBlocks = context.projects.map(project => { + const projectSprints = context.sprints.filter(s => s.projectId === project.id) + const projectItems = context.workItems.filter(i => i.projectId === project.id) + const selected = project.id === context.selectedProjectId ? ' [selected]' : '' + + const sprintList = projectSprints.length + ? projectSprints.map(s => ` - "${s.name}" (id: ${s.id}): ${s.status}, ${s.startDate} → ${s.endDate}`).join('\n') + : ' (none)' + + const itemList = projectItems.length + ? projectItems.map(i => { + const sprint = projectSprints.find(s => s.id === i.sprintId) + return ` - "${i.title}" (id: ${i.id}): ${i.type}, ${i.priority}, ${i.status}${sprint ? `, sprint: "${sprint.name}"` : ', backlog'}` + }).join('\n') + : ' (none)' + + return ` Project: "${project.name}" (id: ${project.id})${selected} + Sprints: +${sprintList} + Work items: +${itemList}` + }).join('\n\n') + + return `You are a project management assistant for Plan-IT. Use the provided tools to manage the user's projects. + +Today: ${today} + +${projectBlocks} + +Rules: +- ALWAYS use tools to make changes. Never describe what you would do — call the tools and do it. +- To move a work item to a different project, use move_work_item. You cannot change a work item's project via update_work_item. +- When creating items without a specified project, use the selected project. +- After all tool calls are done, write one short plain-text sentence summarizing what you did. No markdown, no bullet points, no headers.` +} + +export async function executeAiCommand( + prompt: string, + context: AiCommandContext, + callbacks: AiCommandCallbacks, +): Promise { + const apiKey = import.meta.env.VITE_ANTHROPIC_API_KEY + if (!apiKey) throw new Error('VITE_ANTHROPIC_API_KEY is not set in your .env file') + if (!context.selectedProjectId) throw new Error('No project selected') + + const client = new Anthropic({ apiKey, dangerouslyAllowBrowser: true }) + + // Mutable live copy — updated as Claude creates/deletes things so each loop + // iteration's system prompt reflects the current state, not the initial snapshot. + const live: AiCommandContext = { + projects: [...context.projects], + sprints: [...context.sprints], + workItems: [...context.workItems], + selectedProjectId: context.selectedProjectId, + } + const defaultProjectId = context.selectedProjectId! + + async function runTool(name: string, input: Record): Promise { + try { + switch (name) { + case 'create_project': { + const name_ = input.name as string + const desc = (input.description as string) ?? '' + const color = (input.color as string) ?? '1' + callbacks.onLog(`Creating project: "${name_}"`) + const project = await callbacks.createProject(name_, desc, color) + live.projects.push(project) + return JSON.stringify({ success: true, id: project.id, name: project.name }) + } + + case 'create_work_item': { + const targetProjectId = (input.projectId as string | undefined) ?? defaultProjectId + const data: Omit = { + title: input.title as string, + description: (input.description as string) ?? '', + type: (input.type as WorkItemType) ?? 'task', + priority: (input.priority as Priority) ?? 'medium', + status: (input.status as WorkItemStatus) ?? 'new', + storyPoints: (input.storyPoints as number) ?? 3, + sprintId: (input.sprintId as string) ?? '', + deadline: (input.deadline as string) ?? '', + assignee: (input.assignee as string) ?? '', + } + callbacks.onLog(`Creating work item: "${data.title}"`) + const item = await callbacks.createWorkItem(targetProjectId, data) + live.workItems.push(item) + return JSON.stringify({ success: true, id: item.id }) + } + + case 'update_work_item': { + const id = input.workItemId as string + const existing = live.workItems.find(i => i.id === id) + if (!existing) return JSON.stringify({ error: `Work item not found: ${id}` }) + const updated: WorkItem = { + ...existing, + ...(input.title !== undefined && { title: input.title as string }), + ...(input.description !== undefined && { description: input.description as string }), + ...(input.type !== undefined && { type: input.type as WorkItemType }), + ...(input.priority !== undefined && { priority: input.priority as Priority }), + ...(input.status !== undefined && { status: input.status as WorkItemStatus }), + ...(input.storyPoints !== undefined && { storyPoints: input.storyPoints as number }), + ...(input.sprintId !== undefined && { sprintId: input.sprintId as string }), + ...(input.deadline !== undefined && { deadline: input.deadline as string }), + ...(input.assignee !== undefined && { assignee: input.assignee as string }), + } + callbacks.onLog(`Updating work item: "${updated.title}"`) + await callbacks.updateWorkItem(updated) + const idx = live.workItems.findIndex(i => i.id === id) + if (idx >= 0) live.workItems[idx] = updated + return JSON.stringify({ success: true }) + } + + case 'delete_work_item': { + const id = input.workItemId as string + const item = live.workItems.find(i => i.id === id) + if (!item) return JSON.stringify({ error: `Work item not found: ${id}` }) + callbacks.onLog(`Deleting work item: "${item.title}"`) + await callbacks.deleteWorkItem(item) + live.workItems.splice(live.workItems.indexOf(item), 1) + return JSON.stringify({ success: true }) + } + + case 'create_sprint': { + const targetProjectId = (input.projectId as string | undefined) ?? defaultProjectId + const data: Omit = { + name: input.name as string, + description: (input.description as string) ?? '', + startDate: input.startDate as string, + endDate: input.endDate as string, + status: (input.status as SprintStatus) ?? 'planned', + } + callbacks.onLog(`Creating sprint: "${data.name}"`) + const sprint = await callbacks.createSprint(targetProjectId, data) + live.sprints.push(sprint) + return JSON.stringify({ success: true, id: sprint.id }) + } + + case 'update_sprint': { + const id = input.sprintId as string + const existing = live.sprints.find(s => s.id === id) + if (!existing) return JSON.stringify({ error: `Sprint not found: ${id}` }) + const updated: Sprint = { + ...existing, + ...(input.name !== undefined && { name: input.name as string }), + ...(input.description !== undefined && { description: input.description as string }), + ...(input.startDate !== undefined && { startDate: input.startDate as string }), + ...(input.endDate !== undefined && { endDate: input.endDate as string }), + ...(input.status !== undefined && { status: input.status as SprintStatus }), + } + callbacks.onLog(`Updating sprint: "${updated.name}"`) + await callbacks.updateSprint(updated) + const idx = live.sprints.findIndex(s => s.id === id) + if (idx >= 0) live.sprints[idx] = updated + return JSON.stringify({ success: true }) + } + + case 'delete_sprint': { + const id = input.sprintId as string + const sprint = live.sprints.find(s => s.id === id) + if (!sprint) return JSON.stringify({ error: `Sprint not found: ${id}` }) + callbacks.onLog(`Deleting sprint: "${sprint.name}"`) + await callbacks.deleteSprint(sprint) + live.sprints.splice(live.sprints.indexOf(sprint), 1) + return JSON.stringify({ success: true }) + } + + case 'move_work_item': { + const id = input.workItemId as string + const item = live.workItems.find(i => i.id === id) + if (!item) return JSON.stringify({ error: `Work item not found: ${id}` }) + const targetProjectId = input.targetProjectId as string + const targetSprintId = (input.targetSprintId as string) ?? '' + const targetProject = live.projects.find(p => p.id === targetProjectId) + callbacks.onLog(`Moving "${item.title}" to ${targetProject?.name ?? targetProjectId}`) + const newItem = await callbacks.createWorkItem(targetProjectId, { ...item, sprintId: targetSprintId }) + live.workItems.push(newItem) + await callbacks.deleteWorkItem(item) + live.workItems.splice(live.workItems.indexOf(item), 1) + return JSON.stringify({ success: true, newId: newItem.id }) + } + + default: + return JSON.stringify({ error: `Unknown tool: ${name}` }) + } + } catch (e) { + return JSON.stringify({ error: e instanceof Error ? e.message : 'Tool call failed' }) + } + } + + const messages: Anthropic.MessageParam[] = [{ role: 'user', content: prompt }] + + for (let i = 0; i < 10; i++) { + const response = await client.messages.create({ + model: 'claude-haiku-4-5-20251001', + max_tokens: 4096, + system: buildSystemPrompt(live), + tools: AI_TOOLS, + messages, + }) + + messages.push({ role: 'assistant', content: response.content }) + + if (response.stop_reason === 'end_turn') { + return response.content.find(b => b.type === 'text')?.text ?? 'Done.' + } + + if (response.stop_reason === 'tool_use') { + const results: Anthropic.ToolResultBlockParam[] = [] + for (const block of response.content) { + if (block.type === 'tool_use') { + const result = await runTool(block.name, block.input as Record) + results.push({ type: 'tool_result', tool_use_id: block.id, content: result }) + } + } + messages.push({ role: 'user', content: results }) + } + } + + return 'Done.' +} diff --git a/src/components/GlobalCreateButton.tsx b/src/components/GlobalCreateButton.tsx index 9e62d8f..046a700 100644 --- a/src/components/GlobalCreateButton.tsx +++ b/src/components/GlobalCreateButton.tsx @@ -11,7 +11,7 @@ const OPTIONS: { type: CreateType; label: string; description: string }[] = [ { type: 'item', label: 'Work Item', description: 'Task, feature, or bug' }, { type: 'sprint', label: 'Sprint', description: 'Timebox for a set of work' }, { type: 'project', label: 'Project', description: 'New calendar-backed project' }, - { type: 'ai', label: '✨ AI Planner', description: 'Describe a goal, Claude plans the work' }, + { type: 'ai', label: '✨ AI Assistant', description: 'Tell Claude what to add, change, or remove' }, ] export default function GlobalCreateButton({ onCreate, hideAi = false }: Props) { diff --git a/src/components/dialogs/AiAssistantDialog.tsx b/src/components/dialogs/AiAssistantDialog.tsx new file mode 100644 index 0000000..88065de --- /dev/null +++ b/src/components/dialogs/AiAssistantDialog.tsx @@ -0,0 +1,223 @@ +import { useState, useRef, useEffect } from 'react' +import type { Project, Sprint, WorkItem } from '../../types' +import { executeAiCommand } from '../../api/ai' + +interface Props { + projects: Project[] + selectedProjectId: string | null + fetchProjectData: (projectId: string) => Promise<{ sprints: Sprint[]; workItems: WorkItem[] }> + onCreateProject: (name: string, description: string, color: string) => Promise + onCreateSprint: (projectId: string, data: Omit) => Promise + onUpdateSprint: (sprint: Sprint) => Promise + onDeleteSprint: (sprint: Sprint) => Promise + onCreateWorkItem: (projectId: string, data: Omit) => Promise + onUpdateWorkItem: (item: WorkItem) => Promise + onDeleteWorkItem: (item: WorkItem) => Promise + onClose: () => void +} + +type Step = 'loading' | 'prompt' | 'working' | 'done' | 'error' + +export default function AiAssistantDialog({ + projects, selectedProjectId, fetchProjectData, + onCreateProject, onCreateSprint, onUpdateSprint, onDeleteSprint, + onCreateWorkItem, onUpdateWorkItem, onDeleteWorkItem, + onClose, +}: Props) { + const [step, setStep] = useState('loading') + const [allSprints, setAllSprints] = useState([]) + const [allWorkItems, setAllWorkItems] = useState([]) + const [prompt, setPrompt] = useState('') + const [log, setLog] = useState([]) + const [result, setResult] = useState('') + const [errorMsg, setErrorMsg] = useState('') + const logRef = useRef(null) + const textareaRef = useRef(null) + + // Load all projects' data on mount so the AI has full cross-project context + useEffect(() => { + async function loadAll() { + try { + const results = await Promise.all(projects.map(p => fetchProjectData(p.id))) + setAllSprints(results.flatMap(r => r.sprints)) + setAllWorkItems(results.flatMap(r => r.workItems)) + setStep('prompt') + } catch (e) { + setErrorMsg(e instanceof Error ? e.message : 'Failed to load project data') + setStep('error') + } + } + loadAll() + }, []) // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { if (step === 'prompt') textareaRef.current?.focus() }, [step]) + useEffect(() => { + if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight + }, [log]) + + async function run() { + if (!prompt.trim()) return + setStep('working') + setLog([]) + + try { + const summary = await executeAiCommand( + prompt, + { projects, sprints: allSprints, workItems: allWorkItems, selectedProjectId }, + { + createProject: onCreateProject, + createSprint: onCreateSprint, + updateSprint: onUpdateSprint, + deleteSprint: onDeleteSprint, + createWorkItem: onCreateWorkItem, + updateWorkItem: onUpdateWorkItem, + deleteWorkItem: onDeleteWorkItem, + onLog: msg => setLog(l => [...l, msg]), + }, + ) + setResult(summary) + setStep('done') + } catch (e) { + setErrorMsg(e instanceof Error ? e.message : 'Something went wrong') + setStep('error') + } + } + + function handleKey(e: React.KeyboardEvent) { + if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) run() + if (e.key === 'Escape') onClose() + } + + return ( +
{ if (e.target === e.currentTarget) onClose() }}> +
+
+ ✨ AI Assistant + +
+ + {step === 'loading' && ( +
+

Loading project data…

+
+ )} + + {step === 'prompt' && ( + <> +
+

+ Describe what you want to do across any of your {projects.length} project{projects.length !== 1 ? 's' : ''}. + The AI can create, update, or remove sprints and work items. +

+