diff --git a/docker-compose.yml b/docker-compose.yml index 816db0d..1879389 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,7 +15,7 @@ services: environment: DATABASE_URL: "postgresql+asyncpg://${POSTGRES_USER:-fabled}:${POSTGRES_PASSWORD:-fabled}@db:5432/${POSTGRES_DB:-fabledassistant}" OLLAMA_URL: "http://ollama:11434" - OLLAMA_MODEL: "${OLLAMA_MODEL:-qwen3:14b}" + OLLAMA_MODEL: "${OLLAMA_MODEL:-llama3.1}" SECRET_KEY: "${SECRET_KEY:-dev-secret-change-me}" # Uncomment and set to enable web research and image search via SearXNG: # SEARXNG_URL: "http://searxng:8080" diff --git a/frontend/src/components/AppHeader.vue b/frontend/src/components/AppHeader.vue index 1ad4dca..bed1775 100644 --- a/frontend/src/components/AppHeader.vue +++ b/frontend/src/components/AppHeader.vue @@ -1,5 +1,5 @@ @@ -105,13 +165,17 @@ router.afterEach(() => { .app-header { background: var(--color-bg-secondary); border-bottom: 1px solid var(--color-border); + position: relative; } .nav { padding: 0.5rem 1.25rem; display: flex; align-items: center; justify-content: space-between; + position: relative; } + +/* Left */ .nav-brand { display: flex; align-items: center; @@ -120,28 +184,27 @@ router.afterEach(() => { font-size: 1.1rem; color: var(--color-primary); text-decoration: none; + flex-shrink: 0; } -.hamburger { - display: none; - background: none; - border: none; - cursor: pointer; - padding: 0.5rem; - flex-direction: column; - gap: 4px; + +/* Center — absolutely positioned so it's truly centered regardless of side widths */ +.nav-center { + position: absolute; + left: 50%; + transform: translateX(-50%); + display: flex; + align-items: center; + gap: 0.1rem; } -.hamburger-line { - display: block; - width: 20px; - height: 2px; - background: var(--color-text); - border-radius: 1px; -} -.nav-links { + +/* Right */ +.nav-right { display: flex; align-items: center; gap: 0.25rem; + flex-shrink: 0; } + .nav-link { color: var(--color-text-secondary); text-decoration: none; @@ -159,12 +222,14 @@ router.afterEach(() => { background: var(--color-bg-card); font-weight: 600; } + +/* Status indicator */ .status-indicator { display: flex; align-items: center; gap: 0.3rem; - margin-left: 0.5rem; cursor: default; + padding: 0 0.25rem; } .status-dot { width: 8px; @@ -177,60 +242,79 @@ router.afterEach(() => { font-weight: 500; color: var(--color-text-muted); } -.status-green .status-dot { - background: var(--color-success, #2ecc71); -} -.status-yellow .status-dot { - background: var(--color-warning, #f59e0b); - animation: pulse-dot 2s infinite; -} -.status-orange .status-dot { - background: #f97316; -} -.status-red .status-dot { - background: var(--color-danger, #e74c3c); -} -.status-gray .status-dot { - background: var(--color-text-muted); - animation: pulse-dot 2s infinite; -} +.status-green .status-dot { background: var(--color-success, #2ecc71); } +.status-yellow .status-dot { background: var(--color-warning, #f59e0b); animation: pulse-dot 2s infinite; } +.status-orange .status-dot { background: #f97316; } +.status-red .status-dot { background: var(--color-danger, #e74c3c); } +.status-gray .status-dot { background: var(--color-text-muted); animation: pulse-dot 2s infinite; } @keyframes pulse-dot { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } } -.btn-shortcuts { + +/* Icon buttons (?, theme, gear) */ +.btn-icon { background: none; border: 1px solid var(--color-border); border-radius: var(--radius-sm); - padding: 0.25rem 0.55rem; + padding: 0.25rem 0.45rem; cursor: pointer; - font-size: 0.9rem; - font-weight: 700; + font-size: 0.95rem; color: var(--color-text-muted); line-height: 1; + display: flex; + align-items: center; + justify-content: center; } -.btn-shortcuts:hover { +.btn-icon:hover, +.btn-icon.active { background: var(--color-bg-card); color: var(--color-text); + border-color: var(--color-primary); } -.theme-toggle { - background: none; + +/* Gear dropdown */ +.gear-menu { + position: relative; +} +.gear-dropdown { + position: absolute; + top: calc(100% + 6px); + right: 0; + background: var(--color-bg-card); border: 1px solid var(--color-border); - border-radius: var(--radius-sm); - padding: 0.25rem 0.5rem; - cursor: pointer; - font-size: 1.1rem; - color: var(--color-text); - line-height: 1; + border-radius: var(--radius-md); + box-shadow: 0 4px 16px var(--color-shadow, rgba(0,0,0,0.15)); + min-width: 140px; + z-index: 200; + overflow: hidden; + display: flex; + flex-direction: column; } -.theme-toggle:hover { - background: var(--color-bg-card); +.gear-item { + padding: 0.55rem 0.9rem; + font-size: 0.875rem; + color: var(--color-text-secondary); + text-decoration: none; + transition: background 0.12s, color 0.12s; } +.gear-item:hover { + background: var(--color-bg-secondary); + color: var(--color-primary); +} +.gear-item.router-link-active { + color: var(--color-primary); + font-weight: 600; +} + +/* User info */ .user-info { display: flex; align-items: center; gap: 0.4rem; - margin-left: 0.5rem; + margin-left: 0.25rem; + padding-left: 0.5rem; + border-left: 1px solid var(--color-border); } .username { font-size: 0.85rem; @@ -255,54 +339,81 @@ router.afterEach(() => { cursor: pointer; font-size: 0.8rem; color: var(--color-text-secondary); + font-family: inherit; } .btn-logout:hover { color: var(--color-danger); border-color: var(--color-danger); } +/* Hamburger — mobile only */ +.hamburger { + display: none; + background: none; + border: none; + cursor: pointer; + padding: 0.5rem; + flex-direction: column; + gap: 4px; + margin-left: 0.25rem; +} +.hamburger-line { + display: block; + width: 20px; + height: 2px; + background: var(--color-text); + border-radius: 1px; +} + +/* Mobile menu */ +.mobile-menu { + display: flex; + flex-direction: column; + padding: 0.5rem 1rem 0.75rem; + border-top: 1px solid var(--color-border); + background: var(--color-bg-secondary); + gap: 0.1rem; +} +.mobile-divider { + height: 1px; + background: var(--color-border); + margin: 0.4rem 0; +} +.mobile-actions { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.25rem 0; +} +.mobile-user { + display: flex; + align-items: center; + gap: 0.5rem; + padding-top: 0.4rem; + border-top: 1px solid var(--color-border); + margin-top: 0.25rem; +} + @media (max-width: 768px) { + .nav-center { + display: none; + } + .status-indicator, + .btn-icon, + .user-info { + display: none; + } .hamburger { display: flex; } - .nav-links { - display: none; - position: absolute; - top: 100%; - left: 0; - right: 0; - background: var(--color-bg-secondary); - border-bottom: 1px solid var(--color-border); - flex-direction: column; - padding: 0.75rem 1rem; - gap: 0.25rem; - z-index: 100; - } - .nav-links.open { - display: flex; - } - .nav-link { + .mobile-menu .nav-link { padding: 0.5rem 0.75rem; min-height: 44px; display: flex; align-items: center; } - .status-indicator { - margin-left: 0; - } - .user-info { - margin-left: 0; - margin-top: 0.25rem; - padding-top: 0.5rem; - border-top: 1px solid var(--color-border); - width: 100%; - } - .theme-toggle, - .btn-logout { - min-height: 44px; - display: flex; - align-items: center; - justify-content: center; + .mobile-user .btn-logout { + min-height: 36px; } } diff --git a/frontend/src/stores/notes.ts b/frontend/src/stores/notes.ts index 4155e4e..2a1cab6 100644 --- a/frontend/src/stores/notes.ts +++ b/frontend/src/stores/notes.ts @@ -79,6 +79,7 @@ export const useNotesStore = defineStore("notes", () => { body: string; tags?: string[]; project_id?: number | null; + milestone_id?: number | null; }): Promise { try { return await apiPost("/api/notes", data); @@ -90,7 +91,7 @@ export const useNotesStore = defineStore("notes", () => { async function updateNote( id: number, - data: Partial> + data: Partial> ): Promise { try { const note = await apiPut(`/api/notes/${id}`, data); diff --git a/frontend/src/views/NoteEditorView.vue b/frontend/src/views/NoteEditorView.vue index b123d8d..8e53ab0 100644 --- a/frontend/src/views/NoteEditorView.vue +++ b/frontend/src/views/NoteEditorView.vue @@ -11,6 +11,7 @@ import MarkdownToolbar from "@/components/MarkdownToolbar.vue"; import TiptapEditor from "@/components/TiptapEditor.vue"; import TagInput from "@/components/TagInput.vue"; import ProjectSelector from "@/components/ProjectSelector.vue"; +import MilestoneSelector from "@/components/MilestoneSelector.vue"; const route = useRoute(); const router = useRouter(); @@ -21,6 +22,7 @@ const title = ref(""); const body = ref(""); const tags = ref([]); const projectId = ref(null); +const milestoneId = ref(null); const dirty = ref(false); const saving = ref(false); const showPreview = ref(false); @@ -139,13 +141,15 @@ let savedTitle = ""; let savedBody = ""; let savedTags: string[] = []; let savedProjectId: number | null = null; +let savedMilestoneId: number | null = null; function markDirty() { dirty.value = title.value !== savedTitle || body.value !== savedBody || JSON.stringify(tags.value) !== JSON.stringify(savedTags) || - projectId.value !== savedProjectId; + projectId.value !== savedProjectId || + milestoneId.value !== savedMilestoneId; } function onBodyUpdate(newVal: string) { @@ -161,10 +165,12 @@ onMounted(async () => { body.value = store.currentNote.body; tags.value = [...(store.currentNote.tags || [])]; projectId.value = store.currentNote.project_id ?? null; + milestoneId.value = store.currentNote.milestone_id ?? null; savedTitle = title.value; savedBody = body.value; savedTags = [...tags.value]; savedProjectId = projectId.value; + savedMilestoneId = milestoneId.value; } } }); @@ -179,11 +185,13 @@ async function save() { body: body.value, tags: tags.value, project_id: projectId.value, + milestone_id: milestoneId.value, }); savedTitle = title.value; savedBody = body.value; savedTags = [...tags.value]; savedProjectId = projectId.value; + savedMilestoneId = milestoneId.value; dirty.value = false; toast.show("Note saved"); } else { @@ -192,6 +200,7 @@ async function save() { body: body.value, tags: tags.value, project_id: projectId.value, + milestone_id: milestoneId.value, }); dirty.value = false; toast.show("Note created"); @@ -232,11 +241,12 @@ async function autoSave() { if (!isEditing.value || !dirty.value || saving.value) return; saving.value = true; try { - await store.updateNote(noteId.value!, { title: title.value, body: body.value, tags: tags.value, project_id: projectId.value } as Record); + await store.updateNote(noteId.value!, { title: title.value, body: body.value, tags: tags.value, project_id: projectId.value, milestone_id: milestoneId.value } as Record); savedTitle = title.value; savedBody = body.value; savedTags = [...tags.value]; savedProjectId = projectId.value; + savedMilestoneId = milestoneId.value; dirty.value = false; toast.show("Auto-saved"); } catch { @@ -303,7 +313,11 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
- + +
+
+ +
diff --git a/frontend/src/views/TaskEditorView.vue b/frontend/src/views/TaskEditorView.vue index 0a051e7..6217974 100644 --- a/frontend/src/views/TaskEditorView.vue +++ b/frontend/src/views/TaskEditorView.vue @@ -70,6 +70,7 @@ async function createSubTask() { status: "todo", priority: "none", project_id: projectId.value, + milestone_id: milestoneId.value, parent_id: taskId.value, }); subTasks.value = [...subTasks.value, created]; diff --git a/src/fabledassistant/routes/notes.py b/src/fabledassistant/routes/notes.py index b507359..0bb911c 100644 --- a/src/fabledassistant/routes/notes.py +++ b/src/fabledassistant/routes/notes.py @@ -215,6 +215,25 @@ async def update_note_route(note_id: int): return jsonify(note.to_dict()) +@notes_bp.route("/", methods=["PATCH"]) +@login_required +async def patch_note_route(note_id: int): + uid = get_current_user_id() + data = await request.get_json() + fields = {} + for key in ("title", "body", "parent_id", "project_id", "milestone_id", "status", "priority"): + if key in data: + fields[key] = data[key] + if "due_date" in data: + fields["due_date"] = date.fromisoformat(data["due_date"]) if data["due_date"] else None + if "tags" in data: + fields["tags"] = data["tags"] + note = await update_note(uid, note_id, **fields) + if note is None: + return jsonify({"error": "Note not found"}), 404 + return jsonify(note.to_dict()) + + @notes_bp.route("/", methods=["DELETE"]) @login_required async def delete_note_route(note_id: int): diff --git a/src/fabledassistant/routes/projects.py b/src/fabledassistant/routes/projects.py index cfcb9fd..b2ff08f 100644 --- a/src/fabledassistant/routes/projects.py +++ b/src/fabledassistant/routes/projects.py @@ -4,6 +4,7 @@ import logging from quart import Blueprint, jsonify, request from fabledassistant.auth import login_required, get_current_user_id +from fabledassistant.services.milestones import list_milestones from fabledassistant.services.notes import list_notes from fabledassistant.services.projects import ( create_project, @@ -101,11 +102,15 @@ async def get_project_notes_route(project_id: int): elif type_filter == "note": is_task = False + ms_list = await list_milestones(uid, project_id) + milestone_ids = [m.id for m in ms_list] + notes, total = await list_notes( uid, is_task=is_task, status=status_filter, project_id=project_id, + milestone_ids=milestone_ids, limit=limit, offset=offset, sort="updated_at", diff --git a/src/fabledassistant/routes/tasks.py b/src/fabledassistant/routes/tasks.py index 5c25f3f..2d058df 100644 --- a/src/fabledassistant/routes/tasks.py +++ b/src/fabledassistant/routes/tasks.py @@ -81,6 +81,9 @@ async def create_task_route(): priority=priority, due_date=due_date, tags=tags, + project_id=data.get("project_id"), + milestone_id=data.get("milestone_id"), + parent_id=data.get("parent_id"), ) return jsonify(task.to_dict()), 201 @@ -92,7 +95,11 @@ async def get_task_route(task_id: int): task = await get_note(uid, task_id) if task is None: return jsonify({"error": "Task not found"}), 404 - return jsonify(task.to_dict()) + data = task.to_dict() + if task.parent_id: + parent = await get_note(uid, task.parent_id) + data["parent_title"] = parent.title if parent else None + return jsonify(data) @tasks_bp.route("/", methods=["PUT"]) @@ -123,6 +130,10 @@ async def update_task_route(task_id: int): if "tags" in data: fields["tags"] = data["tags"] + for key in ("project_id", "milestone_id", "parent_id"): + if key in data: + fields[key] = data[key] + task = await update_note(uid, task_id, **fields) if task is None: return jsonify({"error": "Task not found"}), 404 diff --git a/src/fabledassistant/services/notes.py b/src/fabledassistant/services/notes.py index 547d66d..7791fb1 100644 --- a/src/fabledassistant/services/notes.py +++ b/src/fabledassistant/services/notes.py @@ -33,6 +33,17 @@ async def create_note( priority: str | None = None, due_date: date | None = None, ) -> Note: + # Auto-populate project_id from milestone when not explicitly provided + if milestone_id is not None and project_id is None: + from fabledassistant.models.milestone import Milestone + async with async_session() as lookup: + result = await lookup.execute( + select(Milestone).where(Milestone.id == milestone_id, Milestone.user_id == user_id) + ) + ms = result.scalars().first() + if ms is not None: + project_id = ms.project_id + async with async_session() as session: note = Note( user_id=user_id, @@ -71,6 +82,7 @@ async def list_notes( due_after: date | None = None, project_id: int | None = None, milestone_id: int | None = None, + milestone_ids: list[int] | None = None, parent_id: int | None = None, sort: str = "updated_at", order: str = "desc", @@ -125,8 +137,13 @@ async def list_notes( count_query = count_query.where(Note.due_date >= due_after) if project_id is not None: - query = query.where(Note.project_id == project_id) - count_query = count_query.where(Note.project_id == project_id) + if milestone_ids: + # OR: directly assigned to project, OR assigned to one of the project's milestones + project_filter = or_(Note.project_id == project_id, Note.milestone_id.in_(milestone_ids)) + else: + project_filter = Note.project_id == project_id + query = query.where(project_filter) + count_query = count_query.where(project_filter) if milestone_id is not None: query = query.where(Note.milestone_id == milestone_id) diff --git a/summary.md b/summary.md index ab2166e..1f81c63 100644 --- a/summary.md +++ b/summary.md @@ -12,7 +12,7 @@ > Include file-level details in the commit body when the change is non-trivial. ## Last Updated -2026-03-02 — Projects + Milestones hierarchy; RAG auto-injection; summarization improvements; browser push notifications; PWA manifest; Radicale CalDAV reverted to external; Flutter Android companion app updated +2026-03-03 — Project/milestone association bug fixes (tasks route was silently dropping project_id/milestone_id); PATCH /api/notes endpoint; parent_title in task API response; milestone_id support in NoteEditorView + notes store; sub-tasks inherit milestone; list_notes OR query for project view; AppHeader redesigned (centered nav, gear dropdown for settings) ## Project Overview Fabled Assistant is a self-hosted note-taking and task-tracking application with @@ -354,14 +354,14 @@ fabledassistant/ │ │ ├── HomeView.vue # Chat-first dashboard: quick actions + chat widget (top, full-width), inline response panel, two-column grid (3fr tasks / 2fr notes); task sections: Overdue, Due Today, Due This Week, High Priority, In Progress, Other (capped 10, due-dated first); 8 recent notes; model warming on mount │ │ ├── SettingsView.vue # Settings page: assistant name, chat/intent model dropdowns (populated from installed models), email change (with password confirmation for local-auth users), change password, notifications, CalDAV, SMTP (admin), base URL (admin), data export/restore (admin) │ │ ├── NotesListView.vue # Note list: search, sort, tag filter pills, pagination - │ │ ├── NoteEditorView.vue # Create/edit: Tiptap editor, sticky toolbar, TagInput chip field (between title and body), AI Assist panel (right-side 320px, collapsible), line-level diff view, Proofread action, floating inline ✨ pill on text selection, LLM tag suggestions (adds chips), Ctrl+S, auto-save (5min), unsaved guard; styles from editor-shared.css + │ │ ├── NoteEditorView.vue # Create/edit: Tiptap editor, sticky toolbar, TagInput chip field (between title and body), ProjectSelector + MilestoneSelector (milestone resets when project changes), AI Assist panel (right-side 320px, collapsible), line-level diff view, Proofread action, floating inline ✨ pill on text selection, LLM tag suggestions (adds chips), Ctrl+S, auto-save (5min), unsaved guard; styles from editor-shared.css │ │ ├── NoteViewerView.vue # Markdown render, wikilink auto-create, convert-to-task, backlinks, table of contents sidebar │ │ ├── TasksListView.vue # Task list: search, status/priority filters, sort, pagination │ │ ├── TaskEditorView.vue # Create/edit task: Tiptap editor, sticky toolbar, TagInput chip field (between metadata fields and body), AI Assist panel (right-side 320px, collapsible), line-level diff view, Proofread action, floating inline ✨ pill on text selection, LLM tag suggestions (adds chips), Ctrl+S, auto-save (5min), dirty guard; styles from editor-shared.css + task-specific scoped styles │ │ └── TaskViewerView.vue # Task detail: rendered markdown, badges (isOverdue uses ISO string comparison), convert-to-note, backlinks, table of contents sidebar │ ├── components/ │ │ ├── LogsView.vue # Admin log viewer: stats summary, category/search/date filters, paginated table with IP column + expandable detail rows (expands on ip_address or details) - │ │ ├── AppHeader.vue # Nav bar: brand, nav links (incl. admin Logs), status indicator, theme toggle, user info + logout, hamburger menu (mobile) + │ │ ├── AppHeader.vue # Nav bar: three-zone layout — brand (left), Notes/Projects/Tasks/Chat centered (absolute positioning), right rail: status + ? + theme + gear dropdown (Settings/Users/Logs) + username/signout; hamburger + mobile dropdown on ≤768px │ │ ├── ChatPanel.vue # Slide-out chat panel (right side overlay, receives contextNoteId prop), bubble-style messages, floating dark input, note picker, context pills with promote (+) only (no exclude) │ │ ├── ModelSelector.vue # Model dropdown (v-model pattern): fetches installed + running models, hot/cold indicators │ │ ├── DashboardChatInput.vue # Inline chat bar: ModelSelector + note picker + textarea + send button; emits submit event @@ -407,7 +407,7 @@ fabledassistant/ | PUT | `/api/admin/registration` | Toggle registration open/closed (admin only, body: `{open: bool}`) | | GET | `/api/admin/logs` | List log entries (admin only, params: `category`, `user_id`, `search`, `date_from`, `date_to`, `limit`, `offset`) | | GET | `/api/admin/logs/stats` | Get log category counts (admin only) | -| GET | `/api/notes` | List notes (params: `q`, `tag`, `sort`, `order`, `limit`, `offset`; defaults to `is_task=false` — plain notes only; `?is_task=true` for tasks, `?all=true` for everything) | +| GET | `/api/notes` | List notes (params: `q`, `tag`, `sort`, `order`, `limit`, `offset`, `project_id`, `milestone_id`, `parent_id`, `type`; `type=note` default — plain notes only; `type=task` for tasks, `type=all` for everything) | | POST | `/api/notes` | Create note (body: `{title, body, tags?: string[], status?, priority?, due_date?}` — tags explicit array) | | GET | `/api/notes/tags` | List all tags from notes table (param: `q` for filter) | | POST | `/api/notes/suggest-tags` | LLM-suggest tags (body: `{title, body, current_tags?: string[]}`, returns `{suggested_tags: [...]}`) | @@ -415,7 +415,8 @@ fabledassistant/ | GET | `/api/notes/by-title?title=...` | Resolve note by exact title (case-insensitive) | | POST | `/api/notes/resolve-title` | Get-or-create note by title (for wikilink clicks) | | GET | `/api/notes/:id` | Get single note (response includes `is_task`, `status`, `priority`, `due_date`) | -| PUT | `/api/notes/:id` | Update note (body: `{title?, body?, tags?: string[], status?, priority?, due_date?}` — omitting tags keeps existing) | +| PUT | `/api/notes/:id` | Update note (body: `{title?, body?, tags?: string[], status?, priority?, due_date?, project_id?, milestone_id?, parent_id?}`) | +| PATCH | `/api/notes/:id` | Partial update note — same accepted fields as PUT; used for sub-task status toggle | | DELETE | `/api/notes/:id` | Delete note (simple delete, no cascade) | | POST | `/api/notes/:id/convert-to-task` | Set `status='todo'`, `priority='none'` on note (returns 200) | | POST | `/api/notes/:id/convert-to-note` | Clear `status`, `priority`, `due_date` from note (returns 200) | @@ -569,7 +570,7 @@ When adding a new migration, follow these conventions: - **Keyboard shortcuts overlay:** Press `?` or click `?` in nav bar. State shared via `useShortcuts` composable. ### LLM Chat -- Ollama integration via async HTTP (httpx), default model `qwen3:latest` (better tool support than mistral), auto-pull on startup +- Ollama integration via async HTTP (httpx), default model `qwen3:latest` (`config.py` fallback); docker-compose default is `llama3.1` (override via `OLLAMA_MODEL` env var or user `default_model` setting); auto-pull on startup - Background generation with `GenerationBuffer` (in-memory SSE fan-out, `Last-Event-ID` reconnect, 60s cleanup) - Stop generation with partial content preservation - Note-aware context building: current note + keyword search for related notes + URL fetching @@ -586,8 +587,9 @@ When adding a new migration, follow these conventions: - Save assistant messages as notes (LLM-titled, chat-tagged); summarize conversations - Dedicated `/chat` page with responsive sidebar + slide-out chat panel from header - Model catalog with installed/available tabs, download progress, select, remove -- Per-conversation model selection via ModelSelector dropdown in chat header (persisted via PATCH) +- Per-conversation model selection via ModelSelector dropdown in chat header (persisted via PATCH); user's `default_model` setting is always the source of truth for generation and summarization — `conv.model` (stored at creation time) no longer short-circuits the setting lookup - Dashboard inline chat input: model selector + note picker + textarea; creates conversation and navigates +- **Startup warm-up (fixed):** No longer warms `Config.OLLAMA_MODEL`. At startup, queries all distinct `default_model` values from user settings, cross-references with Ollama's installed models, and warms only the intersection. Models selected by users but not yet installed are skipped with an info log. - Model warming: default model pre-loaded into Ollama on dashboard mount via fire-and-forget POST - **Model load state indicator:** `GET /api/chat/status` now queries `/api/tags` and `/api/ps` in parallel. Model status has three values: `"not_found"` (not installed), `"cold"` (installed but @@ -765,9 +767,17 @@ When adding a new migration, follow these conventions: - **Services**: `services/projects.py` (CRUD + `get_or_create_project` + `get_project_summary`); `services/milestones.py` (CRUD + `get_milestone_progress` + `get_project_milestone_summary`). - **Routes**: `routes/projects.py` under `/api/projects`; `routes/milestones.py` under `/api/projects//milestones`. - **LLM tools**: `create_project`, `list_projects`, `get_project`, `update_project`; `create_milestone`, `list_milestones`; `create_note`/`create_task`/`update_note` accept optional `project` + `milestone` + `parent_task` params. -- **Frontend**: `ProjectListView.vue` (card grid, stacked milestone progress bars per card), `ProjectView.vue` (milestone-grouped kanban + inline milestone management), `ProjectSelector.vue` (dropdown used in NoteEditorView + TaskEditorView), `MilestoneSelector.vue` (used in TaskEditorView). +- **Frontend**: `ProjectListView.vue` (card grid, stacked milestone progress bars per card), `ProjectView.vue` (milestone-grouped kanban + inline milestone management + `+` button in Todo column header → `/tasks/new?projectId=X&milestoneId=Y`), `ProjectSelector.vue` (dropdown used in NoteEditorView + TaskEditorView), `MilestoneSelector.vue` (used in NoteEditorView + TaskEditorView). +- **Sub-tasks in TaskEditorView**: Sub-tasks section fetches child tasks (`parent_id=`), shows toggle + inline create form. URL query params `?projectId=X&milestoneId=Y&parentId=Z` pre-fill editor fields on new task. - **Types**: `frontend/src/types/note.ts` Note interface now includes `project_id: number | null` and `milestone_id: number | null`. - `app.py` registers `projects_bp` and `milestones_bp`; router adds `/projects` and `/projects/:id`. +- **`services/notes.py`**: `list_notes()` accepts `parent_id` filter and new `milestone_ids: list[int]` param — when provided with `project_id`, uses `OR (project_id=X OR milestone_id IN (...))` so tasks assigned to a milestone-but-not-project are still returned. `create_note()` auto-sets `project_id` from the milestone's project when `milestone_id` is provided and `project_id` is omitted. +- **`services/milestones.py`**: `find_milestone_by_title()` for cross-project milestone lookup. +- **`GET /api/notes`** now accepts additional query params: `project_id`, `milestone_id`, `parent_id`, `type` (`note`/`task`/`all`). +- **`routes/projects.py` `GET /api/projects/:id/notes`**: fetches project's milestone IDs and passes them via `milestone_ids` to `list_notes`, so tasks assigned via milestone (but lacking `project_id`) are included. +- **`routes/tasks.py` bug fix**: `POST /api/tasks` and `PUT /api/tasks/:id` were silently dropping `project_id`, `milestone_id`, and `parent_id` from the request body. Both routes now read and pass all three fields. `GET /api/tasks/:id` now includes `parent_title` in the response (secondary lookup when `parent_id` is set). +- **`routes/notes.py`**: Added `PATCH /api/notes/:id` for partial updates (same accepted fields as PUT). Used by sub-task status toggle in the frontend. +- **`services/tools.py`**: `list_notes` tool supports project filter + `updated_at`; `list_tasks` tool supports milestone without requiring project; `tag_mode` defaults to `add`; fail-fast project resolution raises tool error immediately. ### RAG Auto-injection (Phase B) - Notes scoring ≥0.60 cosine similarity are injected automatically into every chat system prompt (max 3, up to 800 chars each) under `--- Relevant Notes ---` heading. @@ -785,9 +795,9 @@ When adding a new migration, follow these conventions: ### Browser Push Notifications (Phase E) - **`models/push_subscription.py`**: PushSubscription — endpoint, p256dh, auth, user_agent; UNIQUE(user_id, endpoint). - **Migration**: `0018_add_push_subscriptions.py` (down_revision="0017"). -- **`services/push.py`**: `vapid_enabled()`, `save_subscription()`, `delete_subscription()`, `send_push_notification()` (lazy-imports pywebpush, fire-and-forget, removes 410 Gone subs). +- **`services/push.py`**: `vapid_enabled()`, `save_subscription()`, `delete_subscription()`, `send_push_notification()` (lazy-imports pywebpush, fire-and-forget, removes 410 Gone subs). `ensure_vapid_keys()` auto-generates an EC P-256 key pair on first boot, saves to `/data/vapid_keys.json` (inside `app_data` volume) so it survives container restarts. - **`routes/push.py`**: `GET /api/push/vapid-public-key`, `POST/DELETE /api/push/subscribe`. -- Config: `VAPID_PRIVATE_KEY`, `VAPID_PUBLIC_KEY`, `VAPID_CLAIMS_SUB` env vars. +- Config: `VAPID_PRIVATE_KEY`, `VAPID_PUBLIC_KEY`, `VAPID_CLAIMS_SUB` env vars — all **optional** (keys are auto-generated at startup if absent). Env vars still take precedence for deployments that manage keys externally. - `generation_task.py`: fires push after `buf.state = COMPLETED` (guarded by `vapid_enabled()`). - `pywebpush>=2.0` added to `pyproject.toml`. - **Frontend**: `frontend/public/sw.js` (push + notificationclick handlers); `frontend/src/stores/push.ts` (isSupported, permission, isSubscribed, subscribe/unsubscribe); SettingsView "Push Notifications" toggle.