diff --git a/DEFAULT_PROMPT.md b/DEFAULT_PROMPT.md index baed5b6..c7605b7 100644 --- a/DEFAULT_PROMPT.md +++ b/DEFAULT_PROMPT.md @@ -1,4 +1,4 @@ -## Your Task US-021 +## Your Task US-022 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,18 +23,17 @@ APPEND to progress.txt (never replace, always append): ## USER REQUEST { - "id": "US-021", - "title": "@ProjectAgent with project action tools", - "description": "As a user, I want the AI to answer project-specific questions and take actions like adding tasks, summarizing the project, and suggesting checkpoints.", + "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.", "acceptanceCriteria": [ - "read_project_notes tool: fetches all notes for the scoped projectId from SQLite and returns combined content to the model", - "add_task tool: creates a task in the project via tasks.create and confirms with 'Task added: [title]' in the chat response", - "get_summary tool: calls the SDK to generate a 2-3 sentence summary of the project based on its notes and tasks, then calls projects.update to persist the result in project.aiSummary", - "suggest_checkpoints tool: returns a JSON array of { title: string, date: string } proposed checkpoints based on date-anchored commitments found in notes", - "@Orchestrator routes project-context messages to @ProjectAgent", + "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" ], - "priority": 21, + "priority": 22, "passes": false, "notes": "" } \ No newline at end of file diff --git a/src/renderer/components/notes/MilkdownEditor.tsx b/src/renderer/components/notes/MilkdownEditor.tsx index 39718ed..e33db1f 100644 --- a/src/renderer/components/notes/MilkdownEditor.tsx +++ b/src/renderer/components/notes/MilkdownEditor.tsx @@ -1,9 +1,19 @@ import { useEffect, useRef } from 'react'; import { Crepe, CrepeFeature } from '@milkdown/crepe'; +import { upload, uploadConfig } from '@milkdown/plugin-upload'; import '@milkdown/crepe/theme/common/style.css'; import '@milkdown/crepe/theme/nord.css'; +function fileToDataUrl(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = reject; + reader.readAsDataURL(file); + }); +} + interface MilkdownEditorProps { initialContent: string; onChange: (markdown: string) => void; @@ -25,9 +35,35 @@ export function MilkdownEditor({ initialContent, onChange }: MilkdownEditorProps [CrepeFeature.Placeholder]: { text: 'Start writing...', }, + [CrepeFeature.ImageBlock]: { + onUpload: fileToDataUrl, + inlineOnUpload: fileToDataUrl, + blockOnUpload: fileToDataUrl, + }, }, }); + // Add upload plugin to handle Ctrl+V and drag-drop of image files + crepe.editor + .config((ctx) => { + ctx.update(uploadConfig.key, (prev) => ({ + ...prev, + uploader: async (files: FileList, schema: import('@milkdown/prose/model').Schema) => { + const results: import('@milkdown/prose/model').Node[] = []; + for (let i = 0; i < files.length; i++) { + const file = files.item(i); + if (!file?.type.includes('image')) continue; + const src = await fileToDataUrl(file); + const node = schema.nodes.image?.createAndFill({ src, alt: file.name }); + if (node) results.push(node); + } + return results; + }, + enableHtmlFileUploader: true, + })); + }) + .use(upload); + crepe.on((listener) => { listener.markdownUpdated((_ctx, markdown, prevMarkdown) => { if (markdown !== prevMarkdown) { diff --git a/src/renderer/components/projects/KanbanBoard.tsx b/src/renderer/components/projects/KanbanBoard.tsx index 0037a26..da68432 100644 --- a/src/renderer/components/projects/KanbanBoard.tsx +++ b/src/renderer/components/projects/KanbanBoard.tsx @@ -5,6 +5,7 @@ import { Badge } from '@/components/ui/badge'; import { TaskRow, type TaskItem } from '@/components/tasks/TaskRow'; import { NewTaskDialog } from '@/components/tasks/NewTaskDialog'; import { EditTaskDialog } from '@/components/tasks/EditTaskDialog'; +import { TaskDetailDialog } from '@/components/tasks/TaskDetailDialog'; const COLUMNS = [ { id: 'todo', label: 'To Do' }, @@ -32,8 +33,9 @@ export function KanbanBoard({ projectId, newTaskOpen, onNewTaskOpenChange }: Kan onSuccess: () => void utils.tasks.list.invalidate(), }); - // Edit task dialog state + // Edit / view task dialog state const [editTask, setEditTask] = useState(null); + const [viewTask, setViewTask] = useState(null); // Group tasks by status const columns = useMemo(() => { @@ -119,6 +121,7 @@ export function KanbanBoard({ projectId, newTaskOpen, onNewTaskOpenChange }: Kan onToggle={handleToggle} onEdit={setEditTask} onDelete={(id) => deleteTask.mutate({ id })} + onClick={setViewTask} hideBreadcrumb /> @@ -144,6 +147,13 @@ export function KanbanBoard({ projectId, newTaskOpen, onNewTaskOpenChange }: Kan open={!!editTask} onOpenChange={(open) => { if (!open) setEditTask(null); }} /> + { if (!open) setViewTask(null); }} + onEdit={(task) => { setViewTask(null); setEditTask(task); }} + onDelete={(id) => { deleteTask.mutate({ id }); setViewTask(null); }} + /> ); } diff --git a/src/renderer/components/projects/ProjectDetail.tsx b/src/renderer/components/projects/ProjectDetail.tsx index d12d324..7f07f40 100644 --- a/src/renderer/components/projects/ProjectDetail.tsx +++ b/src/renderer/components/projects/ProjectDetail.tsx @@ -1,5 +1,5 @@ import { Fragment, useMemo, useState } from 'react'; -import { Sparkles, FileText, CheckCircle2, Milestone, Plus, SquareDashed } from 'lucide-react'; +import { Sparkles, FileText, CheckCircle2, Milestone, Plus } from 'lucide-react'; import { format } from 'date-fns'; import { useNavigate } from '@tanstack/react-router'; import { trpc } from '@/lib/trpc'; @@ -266,7 +266,7 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) { } > - + {note.title} diff --git a/src/renderer/components/tasks/EditTaskDialog.tsx b/src/renderer/components/tasks/EditTaskDialog.tsx index 38aa17d..91e5e8d 100644 --- a/src/renderer/components/tasks/EditTaskDialog.tsx +++ b/src/renderer/components/tasks/EditTaskDialog.tsx @@ -1,5 +1,6 @@ import { useState, useEffect } from 'react'; import { format } from 'date-fns'; +import { TZDate } from 'react-day-picker'; import { Calendar as CalendarIcon, X, UserPlus, Check } from 'lucide-react'; import { trpc } from '@/lib/trpc'; import { Button } from '@/components/ui/button'; @@ -27,6 +28,9 @@ import { ScrollArea } from '@/components/ui/scroll-area'; import { cn } from '@/lib/utils'; import type { TaskItem } from './TaskRow'; +const HOURS = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0')); +const MINUTES = Array.from({ length: 60 }, (_, i) => String(i).padStart(2, '0')); + function parseAssigneesLocal(raw: string | null): string[] { if (!raw) return []; try { @@ -47,8 +51,10 @@ export function EditTaskDialog({ task, open, onOpenChange }: EditTaskDialogProps const [description, setDescription] = useState(''); const [priority, setPriority] = useState('medium'); const [status, setStatus] = useState('todo'); - const [dueDate, setDueDate] = useState(); - const [dueTime, setDueTime] = useState(''); + const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + const [dueDate, setDueDate] = useState(); + const [dueHour, setDueHour] = useState(''); + const [dueMinute, setDueMinute] = useState(''); const [projectId, setProjectId] = useState(''); const [assignees, setAssignees] = useState([]); const [assigneeInput, setAssigneeInput] = useState(''); @@ -62,14 +68,14 @@ export function EditTaskDialog({ task, open, onOpenChange }: EditTaskDialogProps setPriority(task.priority ?? 'medium'); setStatus(task.status ?? 'todo'); if (task.dueDate) { - const d = new Date(task.dueDate); + const d = new TZDate(task.dueDate, timezone); setDueDate(d); - setDueTime( - `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`, - ); + setDueHour(String(d.getHours()).padStart(2, '0')); + setDueMinute(String(d.getMinutes()).padStart(2, '0')); } else { setDueDate(undefined); - setDueTime(''); + setDueHour(''); + setDueMinute(''); } setProjectId(task.projectId ?? ''); setAssignees(parseAssigneesLocal(task.assignee)); @@ -111,14 +117,16 @@ export function EditTaskDialog({ task, open, onOpenChange }: EditTaskDialogProps let resolvedDueDate: number | undefined; if (dueDate) { - const d = new Date(dueDate); - if (dueTime) { - const parts = dueTime.split(':'); - const h = parseInt(parts[0] ?? '0', 10); - const m = parseInt(parts[1] ?? '0', 10); - d.setHours(h, m, 0, 0); - } - resolvedDueDate = d.getTime(); + const h = dueHour !== '' ? parseInt(dueHour, 10) : 0; + const m = dueMinute !== '' ? parseInt(dueMinute, 10) : 0; + const tzDate = new TZDate( + dueDate.getFullYear(), + dueDate.getMonth(), + dueDate.getDate(), + h, m, 0, 0, + timezone, + ); + resolvedDueDate = tzDate.getTime(); } updateTask.mutate({ @@ -194,29 +202,62 @@ export function EditTaskDialog({ task, open, onOpenChange }: EditTaskDialogProps )} > - {dueDate ? format(dueDate, 'PPP') : 'Pick a due date'} + {dueDate + ? `${format(dueDate, 'PPP')}${dueHour !== '' && dueMinute !== '' ? ` ${dueHour}:${dueMinute}` : ''}` + : 'Pick a due date'} setDueDate(d as TZDate | undefined)} + timeZone={timezone} /> -
- - setDueTime(e.target.value)} - className="h-8 text-sm" - /> +
+
+ +
+ + : + + {(dueHour !== '' || dueMinute !== '') && ( + + )} +
+
- {dueDate && dueTime && ( + {dueDate && dueHour !== '' && dueMinute !== '' && (

- Due: {format(dueDate, 'PPP')} at {dueTime} + Due: {format(dueDate, 'PPP')} at {dueHour}:{dueMinute}

)}
diff --git a/src/renderer/components/tasks/NewTaskDialog.tsx b/src/renderer/components/tasks/NewTaskDialog.tsx index a50a643..edb8002 100644 --- a/src/renderer/components/tasks/NewTaskDialog.tsx +++ b/src/renderer/components/tasks/NewTaskDialog.tsx @@ -1,5 +1,6 @@ import { useState, useMemo } from 'react'; import { format } from 'date-fns'; +import { TZDate } from 'react-day-picker'; import { Calendar as CalendarIcon, X, UserPlus, Check, Plus } from 'lucide-react'; import { trpc } from '@/lib/trpc'; import { Button } from '@/components/ui/button'; @@ -26,6 +27,9 @@ import { Separator } from '@/components/ui/separator'; import { ScrollArea } from '@/components/ui/scroll-area'; import { cn } from '@/lib/utils'; +const HOURS = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0')); +const MINUTES = ['00', '05', '10', '15', '20', '25', '30', '35', '40', '45', '50', '55']; + const NO_CLIENT = '__no_client__'; interface NewTaskDialogProps { @@ -40,8 +44,10 @@ export function NewTaskDialog({ open, onOpenChange, defaultProjectId, defaultSta const [description, setDescription] = useState(''); const [priority, setPriority] = useState('medium'); const [status, setStatus] = useState(defaultStatus ?? 'todo'); - const [dueDate, setDueDate] = useState(); - const [dueTime, setDueTime] = useState(''); + const [dueDate, setDueDate] = useState(); + const [dueHour, setDueHour] = useState(''); + const [dueMinute, setDueMinute] = useState(''); + const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; const [projectId, setProjectId] = useState(defaultProjectId ?? ''); // Multi-assignee state @@ -96,7 +102,8 @@ export function NewTaskDialog({ open, onOpenChange, defaultProjectId, defaultSta setPriority('medium'); setStatus(defaultStatus ?? 'todo'); setDueDate(undefined); - setDueTime(''); + setDueHour(''); + setDueMinute(''); setProjectId(defaultProjectId ?? ''); setAssignees([]); setAssigneeInput(''); @@ -171,17 +178,19 @@ export function NewTaskDialog({ open, onOpenChange, defaultProjectId, defaultSta e.preventDefault(); if (!title.trim()) return; - // Resolve dueDate + optional time + // Resolve dueDate + optional time in the selected timezone let resolvedDueDate: number | undefined; if (dueDate) { - const d = new Date(dueDate); - if (dueTime) { - const parts = dueTime.split(':'); - const h = parseInt(parts[0] ?? '0', 10); - const m = parseInt(parts[1] ?? '0', 10); - d.setHours(h, m, 0, 0); - } - resolvedDueDate = d.getTime(); + const h = dueHour !== '' ? parseInt(dueHour, 10) : 0; + const m = dueMinute !== '' ? parseInt(dueMinute, 10) : 0; + const tzDate = new TZDate( + dueDate.getFullYear(), + dueDate.getMonth(), + dueDate.getDate(), + h, m, 0, 0, + timezone, + ); + resolvedDueDate = tzDate.getTime(); } // If creating a new project inline, do that first @@ -268,7 +277,7 @@ export function NewTaskDialog({ open, onOpenChange, defaultProjectId, defaultSta > {dueDate - ? format(dueDate, dueTime ? 'PPP' : 'PPP') + ? `${format(dueDate, 'PPP')}${dueHour !== '' && dueMinute !== '' ? ` ${dueHour}:${dueMinute}` : ''}` : 'Pick a due date'} @@ -276,22 +285,54 @@ export function NewTaskDialog({ open, onOpenChange, defaultProjectId, defaultSta setDueDate(d as TZDate | undefined)} + timeZone={timezone} /> -
- - setDueTime(e.target.value)} - className="h-8 text-sm" - /> +
+ {/* Time row */} +
+ +
+ + : + + {(dueHour !== '' || dueMinute !== '') && ( + + )} +
+
- {dueDate && dueTime && ( + {dueDate && dueHour !== '' && dueMinute !== '' && (

- Due: {format(dueDate, 'PPP')} at {dueTime} + Due: {format(dueDate, 'PPP')} at {dueHour}:{dueMinute}

)}
diff --git a/src/renderer/components/tasks/TaskDetailDialog.tsx b/src/renderer/components/tasks/TaskDetailDialog.tsx index c18c4b6..01c2068 100644 --- a/src/renderer/components/tasks/TaskDetailDialog.tsx +++ b/src/renderer/components/tasks/TaskDetailDialog.tsx @@ -29,7 +29,11 @@ import { parseAssignees, type TaskItem } from './TaskRow'; function formatDate(timestamp: number): string { const d = new Date(timestamp); const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; - return `${months[d.getMonth()]} ${String(d.getDate()).padStart(2, '0')}, ${d.getFullYear()}`; + const date = `${months[d.getMonth()]} ${String(d.getDate()).padStart(2, '0')}, ${d.getFullYear()}`; + if (d.getHours() === 0 && d.getMinutes() === 0) return date; + const h = String(d.getHours()).padStart(2, '0'); + const m = String(d.getMinutes()).padStart(2, '0'); + return `${date} ${h}:${m}`; } function relativeTime(timestamp: number): string { diff --git a/src/renderer/routes/notes.$noteId.tsx b/src/renderer/routes/notes.$noteId.tsx index ab7b5d3..7d18c48 100644 --- a/src/renderer/routes/notes.$noteId.tsx +++ b/src/renderer/routes/notes.$noteId.tsx @@ -16,6 +16,7 @@ import { AlertDialogTitle, AlertDialogTrigger, } from '@/components/ui/alert-dialog'; +import { ScrollArea } from '@/components/ui/scroll-area'; import { trpc } from '@/lib/trpc'; import { MilkdownEditor } from '@/components/notes/MilkdownEditor'; @@ -187,13 +188,15 @@ function NoteDetailPage() { {/* Editor */} -
- -
+ +
+ +
+
); }