From 126bad7d99ecfd578a8b7b8b843eedd53f102e80 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 8 Apr 2026 23:43:25 -0400 Subject: [PATCH 01/11] =?UTF-8?q?fix(header):=20darken=20nav=20text=20in?= =?UTF-8?q?=20light=20mode=20=E2=80=94=20use=20text-secondary=20for=20inac?= =?UTF-8?q?tive,=20primary-solid=20for=20active?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/AppHeader.vue | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/AppHeader.vue b/frontend/src/components/AppHeader.vue index 9f52698..4be5a93 100644 --- a/frontend/src/components/AppHeader.vue +++ b/frontend/src/components/AppHeader.vue @@ -208,7 +208,7 @@ router.afterEach(() => { } .nav-link { - color: var(--color-text-muted); + color: var(--color-text-secondary); text-decoration: none; font-size: 0.82rem; padding: 0.3rem 0.75rem; @@ -216,11 +216,11 @@ router.afterEach(() => { transition: background 0.15s, color 0.15s; } .nav-link:hover { - color: var(--color-text-secondary); + color: var(--color-text); background: var(--color-primary-tint); } .nav-link.router-link-active { - color: #c4b5fd; + color: var(--color-primary-solid); font-weight: 600; background: rgba(124, 58, 237, 0.25); box-shadow: 0 0 16px rgba(124, 58, 237, 0.3); From 4c3d67994bbcc00911120a307176fa59d6959c91 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 9 Apr 2026 08:18:46 -0400 Subject: [PATCH 02/11] fix: back button returns to project when note/task has a project, otherwise to Knowledge --- frontend/src/views/NoteEditorView.vue | 4 ++-- frontend/src/views/TaskEditorView.vue | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/views/NoteEditorView.vue b/frontend/src/views/NoteEditorView.vue index 524f887..ba2d9eb 100644 --- a/frontend/src/views/NoteEditorView.vue +++ b/frontend/src/views/NoteEditorView.vue @@ -402,7 +402,7 @@ async function confirmDelete() { await store.deleteNote(noteId.value); dirty.value = false; toast.show("Note deleted"); - router.push("/notes"); + router.push(projectId.value ? `/projects/${projectId.value}` : "/"); } catch { toast.show("Failed to delete note", "error"); } @@ -445,7 +445,7 @@ onUnmounted(() => assist.clearSelection());
- ← Notes + ← {{ projectId ? 'Project' : 'Knowledge' }} diff --git a/frontend/src/views/TaskEditorView.vue b/frontend/src/views/TaskEditorView.vue index cbba5da..f7baeed 100644 --- a/frontend/src/views/TaskEditorView.vue +++ b/frontend/src/views/TaskEditorView.vue @@ -362,7 +362,7 @@ async function confirmDelete() { await store.deleteTask(taskId.value); dirty.value = false; toast.show("Task deleted"); - router.push("/tasks"); + router.push(projectId.value ? `/projects/${projectId.value}` : "/"); } catch { toast.show("Failed to delete task", "error"); } @@ -410,7 +410,7 @@ useEditorGuards(dirty, save);
- ← Tasks + ← {{ projectId ? 'Project' : 'Knowledge' }} From d12fc5d282d5ff37d6bfc463b2509755cb39c57e Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 9 Apr 2026 08:37:08 -0400 Subject: [PATCH 03/11] feat: paused project status with auto-pause (14 days), auto-reactivate on activity; fix back button context --- frontend/src/views/ProjectListView.vue | 5 +-- frontend/src/views/ProjectView.vue | 4 ++- .../services/briefing_scheduler.py | 32 +++++++++++++++++++ src/fabledassistant/services/notes.py | 18 +++++++++++ 4 files changed, 56 insertions(+), 3 deletions(-) diff --git a/frontend/src/views/ProjectListView.vue b/frontend/src/views/ProjectListView.vue index d21b3f3..f3ed7ad 100644 --- a/frontend/src/views/ProjectListView.vue +++ b/frontend/src/views/ProjectListView.vue @@ -20,7 +20,7 @@ interface Project { title: string; description: string | null; goal: string | null; - status: "active" | "completed" | "archived"; + status: "active" | "paused" | "completed" | "archived"; color: string | null; auto_summary: string | null; permission?: string; @@ -40,7 +40,7 @@ const toast = useToastStore(); const projects = ref([]); const loading = ref(false); const error = ref(null); -const activeTab = ref<"all" | "active" | "completed" | "archived">("all"); +const activeTab = ref<"all" | "active" | "paused" | "completed" | "archived">("all"); // New project modal const showNewProjectModal = ref(false); @@ -103,6 +103,7 @@ async function createProject() { function statusLabel(status: Project["status"]): string { if (status === "active") return "Active"; + if (status === "paused") return "Paused"; if (status === "completed") return "Completed"; if (status === "archived") return "Archived"; return status; diff --git a/frontend/src/views/ProjectView.vue b/frontend/src/views/ProjectView.vue index 69f4f6a..83ab26a 100644 --- a/frontend/src/views/ProjectView.vue +++ b/frontend/src/views/ProjectView.vue @@ -23,7 +23,7 @@ interface Project { title: string; description: string | null; goal: string | null; - status: "active" | "completed" | "archived"; + status: "active" | "paused" | "completed" | "archived"; color: string | null; auto_summary: string | null; permission?: string; @@ -395,6 +395,7 @@ async function confirmDelete() { @@ -752,6 +753,7 @@ async function confirmDelete() { flex-shrink: 0; } .status-active { background: color-mix(in srgb, var(--color-success) 14%, transparent); color: var(--color-success); border: 1px solid color-mix(in srgb, var(--color-success) 30%, transparent); } +.status-paused { background: color-mix(in srgb, var(--color-accent-warm) 14%, transparent); color: var(--color-accent-warm); border: 1px solid color-mix(in srgb, var(--color-accent-warm) 30%, transparent); } .status-completed { background: color-mix(in srgb, var(--color-primary) 14%, transparent); color: var(--color-primary); border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent); } .status-archived { background: color-mix(in srgb, var(--color-text-muted) 14%, transparent); color: var(--color-text-muted); border: 1px solid color-mix(in srgb, var(--color-text-muted) 30%, transparent); } diff --git a/src/fabledassistant/services/briefing_scheduler.py b/src/fabledassistant/services/briefing_scheduler.py index e69fec4..d38e388 100644 --- a/src/fabledassistant/services/briefing_scheduler.py +++ b/src/fabledassistant/services/briefing_scheduler.py @@ -162,6 +162,35 @@ def update_user_schedule(user_id: int, config: dict, tz_override: str | None = N # ── Job execution ───────────────────────────────────────────────────────────── +async def _auto_pause_stale_projects(user_id: int) -> list[str]: + """Pause active projects with no note/task activity in 14+ days. Returns paused project titles.""" + from sqlalchemy import select as _sel, func as _func + from fabledassistant.models.project import Project + from fabledassistant.models.note import Note + from fabledassistant.models import async_session as _session + + paused: list[str] = [] + threshold = datetime.now(timezone.utc) - __import__('datetime').timedelta(days=14) + try: + async with _session() as session: + projects = (await session.execute( + _sel(Project).where(Project.user_id == user_id, Project.status == "active") + )).scalars().all() + for p in projects: + latest = (await session.execute( + _sel(_func.max(Note.updated_at)).where(Note.project_id == p.id) + )).scalar() + if latest is None or latest < threshold: + p.status = "paused" + paused.append(p.title or "Untitled") + if paused: + await session.commit() + logger.info("Auto-paused %d stale projects for user %d: %s", len(paused), user_id, paused) + except Exception: + logger.debug("Auto-pause check failed for user %d", user_id, exc_info=True) + return paused + + async def _run_slot_for_user(user_id: int, slot: str) -> None: """Execute one slot job for one user.""" from fabledassistant.services.briefing_conversations import ( @@ -192,6 +221,9 @@ async def _run_slot_for_user(user_id: int, slot: str) -> None: model = await get_setting(user_id, "default_model", Config.OLLAMA_MODEL) if slot == "compilation": + # Auto-pause stale projects before compiling the briefing + auto_paused = await _auto_pause_stale_projects(user_id) + # Refresh external data first try: import json diff --git a/src/fabledassistant/services/notes.py b/src/fabledassistant/services/notes.py index cd0c492..2e2e052 100644 --- a/src/fabledassistant/services/notes.py +++ b/src/fabledassistant/services/notes.py @@ -22,6 +22,22 @@ def _normalize_tags(tags: list[str]) -> list[str]: return out +async def _maybe_reactivate_project(project_id: int) -> None: + """If a project is paused, reactivate it — activity indicates resumed work.""" + from fabledassistant.models.project import Project + try: + async with async_session() as session: + project = (await session.execute( + select(Project).where(Project.id == project_id) + )).scalars().first() + if project and project.status == "paused": + project.status = "active" + await session.commit() + logger.info("Auto-reactivated paused project %d (%s)", project_id, project.title) + except Exception: + logger.debug("_maybe_reactivate_project failed for project %d", project_id, exc_info=True) + + async def _maybe_trigger_project_summary(user_id: int, project_id: int) -> None: """Fire generate_project_summary() if the project summary is missing or >1h old.""" import asyncio @@ -92,6 +108,7 @@ async def create_note( await session.refresh(note) if project_id is not None: + await _maybe_reactivate_project(project_id) await _maybe_trigger_project_summary(user_id, project_id) return note @@ -284,6 +301,7 @@ async def update_note(user_id: int, note_id: int, **fields: object) -> Note | No await create_version(user_id, note_id, old_body, old_title, old_tags) if note.project_id is not None: + await _maybe_reactivate_project(note.project_id) await _maybe_trigger_project_summary(user_id, note.project_id) return note From ab0b9c31992c002ab42cb29d8464e6abd8aa6185 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 9 Apr 2026 08:46:34 -0400 Subject: [PATCH 04/11] fix(lint): add timezone import, use timedelta directly, remove unused variable --- src/fabledassistant/services/briefing_scheduler.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/fabledassistant/services/briefing_scheduler.py b/src/fabledassistant/services/briefing_scheduler.py index d38e388..5753c9b 100644 --- a/src/fabledassistant/services/briefing_scheduler.py +++ b/src/fabledassistant/services/briefing_scheduler.py @@ -13,7 +13,7 @@ functions wrapped with asyncio.run_coroutine_threadsafe(). import asyncio import logging -from datetime import date, datetime, time, timedelta +from datetime import date, datetime, time, timedelta, timezone from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from apscheduler.schedulers.background import BackgroundScheduler @@ -170,7 +170,7 @@ async def _auto_pause_stale_projects(user_id: int) -> list[str]: from fabledassistant.models import async_session as _session paused: list[str] = [] - threshold = datetime.now(timezone.utc) - __import__('datetime').timedelta(days=14) + threshold = datetime.now(timezone.utc) - timedelta(days=14) try: async with _session() as session: projects = (await session.execute( @@ -222,7 +222,7 @@ async def _run_slot_for_user(user_id: int, slot: str) -> None: if slot == "compilation": # Auto-pause stale projects before compiling the briefing - auto_paused = await _auto_pause_stale_projects(user_id) + await _auto_pause_stale_projects(user_id) # Refresh external data first try: From 43231f44d2aff38880a80b3e407a3fc56d1f3897 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 9 Apr 2026 12:28:55 -0400 Subject: [PATCH 05/11] =?UTF-8?q?fix(search):=20hybrid=20keyword=20+=20sem?= =?UTF-8?q?antic=20search=20=E2=80=94=20exact=20title/body=20matches=20ran?= =?UTF-8?q?k=20first?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/fabledassistant/services/knowledge.py | 72 ++++++++++++++++++----- 1 file changed, 56 insertions(+), 16 deletions(-) diff --git a/src/fabledassistant/services/knowledge.py b/src/fabledassistant/services/knowledge.py index 741a919..83432cd 100644 --- a/src/fabledassistant/services/knowledge.py +++ b/src/fabledassistant/services/knowledge.py @@ -122,34 +122,74 @@ async def _semantic_knowledge_search( limit: int, offset: int, ) -> tuple[list[dict], int]: - """Semantic search over knowledge objects, with SQL filters applied post-rank.""" + """Hybrid search: keyword matches first (title/body ILIKE), then semantic results. + + Exact keyword matches always rank above semantic-only matches so that + searching for a name like "Weston" surfaces the note with that title + before conceptually related notes. + """ + # 1. Keyword search — title and body ILIKE + keyword_notes: list[Note] = [] + try: + async with async_session() as session: + pattern = f"%{q}%" + base = ( + select(Note) + .where(Note.user_id == user_id) + .where(Note.title.ilike(pattern) | Note.body.ilike(pattern)) + ) + if note_type == "task": + base = base.where(Note.status.isnot(None)) + elif note_type: + base = base.where(Note.note_type == note_type).where(Note.status.is_(None)) + for tag in tags: + base = base.where(Note.tags.contains([tag])) + # Title matches first, then body-only matches, newest first within each + base = base.order_by( + Note.title.ilike(pattern).desc(), + Note.updated_at.desc(), + ).limit(limit * 2) + keyword_notes = list((await session.execute(base)).scalars().all()) + except Exception: + logger.warning("Keyword search failed", exc_info=True) + + # 2. Semantic search — conceptual similarity + semantic_notes: list[Note] = [] try: from fabledassistant.services.embeddings import semantic_search_notes - # Fetch a larger candidate set to allow for filtering is_task_filter = True if note_type == "task" else (False if note_type else None) candidates = await semantic_search_notes( user_id=user_id, query=q, - limit=min(200, limit * 8), + limit=min(200, limit * 4), threshold=0.3, is_task=is_task_filter, ) + for _score, note in candidates: + if note_type == "task" and not note.is_task: + continue + elif note_type and note_type != "task" and note.entity_type != note_type: + continue + if tags and not all(t in (note.tags or []) for t in tags): + continue + semantic_notes.append(note) except Exception: - logger.warning("Semantic search unavailable, falling back to SQL", exc_info=True) - return await query_knowledge(user_id, note_type, tags, "modified", None, limit, offset) + logger.warning("Semantic search unavailable, using keyword results only", exc_info=True) - results = [] - for _score, note in candidates: - if note_type == "task" and not note.is_task: - continue - elif note_type and note_type != "task" and note.entity_type != note_type: - continue - if tags and not all(t in (note.tags or []) for t in tags): - continue - results.append(note) + # 3. Merge — keyword matches first, then semantic (deduplicated) + seen_ids: set[int] = set() + merged: list[Note] = [] + for note in keyword_notes: + if note.id not in seen_ids: + seen_ids.add(note.id) + merged.append(note) + for note in semantic_notes: + if note.id not in seen_ids: + seen_ids.add(note.id) + merged.append(note) - total = len(results) - page_items = results[offset: offset + limit] + total = len(merged) + page_items = merged[offset: offset + limit] return [_note_to_item(n) for n in page_items], total From e5821ef3f8ac1f5f39af33d212e8a9b49c934b86 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 9 Apr 2026 18:17:48 -0400 Subject: [PATCH 06/11] fix: allow 'paused' in project status validation --- src/fabledassistant/routes/projects.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/fabledassistant/routes/projects.py b/src/fabledassistant/routes/projects.py index 37d6e36..6e1e9c6 100644 --- a/src/fabledassistant/routes/projects.py +++ b/src/fabledassistant/routes/projects.py @@ -52,7 +52,7 @@ async def create_project_route(): if not data.get("title"): return jsonify({"error": "title is required"}), 400 status = data.get("status", "active") - if status not in ("active", "completed", "archived"): + if status not in ("active", "paused", "completed", "archived"): return jsonify({"error": "status must be 'active', 'completed', or 'archived'"}), 400 project = await create_project( uid, @@ -89,7 +89,7 @@ async def update_project_route(project_id: int): data = await request.get_json() allowed = {"title", "description", "goal", "status", "color"} fields = {k: v for k, v in data.items() if k in allowed} - if "status" in fields and fields["status"] not in ("active", "completed", "archived"): + if "status" in fields and fields["status"] not in ("active", "paused", "completed", "archived"): return jsonify({"error": "status must be 'active', 'completed', or 'archived'"}), 400 project = await update_project(uid, project_id, **fields) if project is None: From 7d5611d00bcf0d7b32b10ed2c0a5ede6016bef94 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 9 Apr 2026 19:33:50 -0400 Subject: [PATCH 07/11] fix: add 'paused' tab to project list filter --- frontend/src/views/ProjectListView.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/views/ProjectListView.vue b/frontend/src/views/ProjectListView.vue index f3ed7ad..f0e7079 100644 --- a/frontend/src/views/ProjectListView.vue +++ b/frontend/src/views/ProjectListView.vue @@ -125,7 +125,7 @@ function truncate(text: string | null, max = 120): string {