diff --git a/frontend/src/views/ProjectView.vue b/frontend/src/views/ProjectView.vue
index 34509a2..4409972 100644
--- a/frontend/src/views/ProjectView.vue
+++ b/frontend/src/views/ProjectView.vue
@@ -474,6 +474,11 @@ function formatDate(dateStr?: string | null): string {
| null = null;
const dirty = ref(false);
const saving = ref(false);
+
+// Sub-tasks
+interface SubTask { id: number; title: string; status: string }
+const subTasks = ref([]);
+const subTasksLoading = ref(false);
+const addingSubTask = ref(false);
+const newSubTaskTitle = ref("");
+
+async function loadSubTasks() {
+ if (!taskId.value) return;
+ subTasksLoading.value = true;
+ try {
+ const data = await apiGet<{ notes: SubTask[] }>(
+ `/api/notes?parent_id=${taskId.value}&is_task=true&limit=100`
+ );
+ subTasks.value = data.notes;
+ } catch {
+ // silent
+ } finally {
+ subTasksLoading.value = false;
+ }
+}
+
+async function createSubTask() {
+ const title = newSubTaskTitle.value.trim();
+ if (!title || !taskId.value) return;
+ try {
+ const created = await apiPost("/api/notes", {
+ title,
+ status: "todo",
+ priority: "none",
+ project_id: projectId.value,
+ parent_id: taskId.value,
+ });
+ subTasks.value = [...subTasks.value, created];
+ newSubTaskTitle.value = "";
+ addingSubTask.value = false;
+ } catch {
+ toast.show("Failed to create sub-task", "error");
+ }
+}
+
+async function toggleSubTask(sub: SubTask) {
+ const newStatus = sub.status === "done" ? "todo" : "done";
+ try {
+ await apiPatch(`/api/notes/${sub.id}`, { status: newStatus });
+ sub.status = newStatus;
+ } catch {
+ toast.show("Failed to update sub-task", "error");
+ }
+}
const showPreview = ref(false);
const editorRef = ref | null>(null);
const tiptapEditor = computed(() => {
@@ -255,6 +306,12 @@ onMounted(async () => {
savedMilestoneId = milestoneId.value;
savedParentId = parentId.value;
}
+ loadSubTasks();
+ } else {
+ // Pre-fill from query params when creating a new task
+ if (route.query.projectId) projectId.value = Number(route.query.projectId);
+ if (route.query.milestoneId) milestoneId.value = Number(route.query.milestoneId);
+ if (route.query.parentId) parentId.value = Number(route.query.parentId);
}
});
@@ -479,6 +536,41 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
+
+
+
+
Loading...
+
+
+
+
+ {{ sub.title || "Untitled" }}
+
+
+ No sub-tasks yet.
+
+
+
+
+
+
+
+
window.removeEventListener("beforeunload", onBeforeUnload));
cursor: default;
}
.parent-empty:hover { background: none; }
+
+/* Sub-tasks */
+.subtasks-section {
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-sm);
+ padding: 0.6rem 0.75rem;
+ background: var(--color-bg-secondary);
+}
+.subtasks-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 0.4rem;
+}
+.subtasks-label {
+ font-size: 0.8rem;
+ font-weight: 600;
+ color: var(--color-text-secondary);
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+.btn-add-subtask {
+ background: none;
+ border: none;
+ cursor: pointer;
+ color: var(--color-primary);
+ font-size: 0.8rem;
+ font-family: inherit;
+ padding: 0.1rem 0.3rem;
+}
+.btn-add-subtask:hover { opacity: 0.8; }
+.subtasks-loading {
+ font-size: 0.8rem;
+ color: var(--color-text-muted);
+ padding: 0.25rem 0;
+}
+.subtask-row {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.25rem 0;
+}
+.subtask-checkbox { flex-shrink: 0; cursor: pointer; }
+.subtask-title {
+ font-size: 0.875rem;
+ color: var(--color-text);
+ text-decoration: none;
+ flex: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.subtask-title:hover { color: var(--color-primary); }
+.subtask-title.done {
+ text-decoration: line-through;
+ color: var(--color-text-muted);
+}
+.subtasks-empty {
+ font-size: 0.8rem;
+ color: var(--color-text-muted);
+ margin: 0;
+ padding: 0.25rem 0;
+}
+.subtask-add-row {
+ display: flex;
+ align-items: center;
+ gap: 0.35rem;
+ margin-top: 0.35rem;
+}
+.subtask-input {
+ flex: 1;
+ padding: 0.3rem 0.5rem;
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-sm);
+ background: var(--color-bg);
+ color: var(--color-text);
+ font-size: 0.875rem;
+ font-family: inherit;
+}
+.subtask-input:focus { outline: none; border-color: var(--color-primary); }
+.btn-subtask-confirm {
+ padding: 0.3rem 0.6rem;
+ background: var(--color-primary);
+ color: #fff;
+ border: none;
+ border-radius: var(--radius-sm);
+ cursor: pointer;
+ font-size: 0.8rem;
+ font-family: inherit;
+}
+.btn-subtask-confirm:disabled { opacity: 0.5; cursor: default; }
+.btn-subtask-cancel {
+ padding: 0.3rem 0.6rem;
+ background: none;
+ border: 1px solid var(--color-border);
+ color: var(--color-text-secondary);
+ border-radius: var(--radius-sm);
+ cursor: pointer;
+ font-size: 0.8rem;
+ font-family: inherit;
+}
\ No newline at end of file
diff --git a/src/fabledassistant/routes/notes.py b/src/fabledassistant/routes/notes.py
index d52f88a..b507359 100644
--- a/src/fabledassistant/routes/notes.py
+++ b/src/fabledassistant/routes/notes.py
@@ -54,8 +54,21 @@ async def list_notes_route():
elif request.args.get("is_task", "").lower() == "true":
is_task = True
+ project_id = request.args.get("project_id", type=int)
+ milestone_id = request.args.get("milestone_id", type=int)
+ parent_id = request.args.get("parent_id", type=int)
+
+ # type= shorthand used by web frontend (?type=task or ?type=note)
+ type_param = request.args.get("type")
+ if type_param == "task":
+ is_task = True
+ elif type_param == "note":
+ is_task = False
+
notes, total = await list_notes(
- uid, q=q, tags=tag or None, is_task=is_task, sort=sort, order=order, limit=limit, offset=offset
+ uid, q=q, tags=tag or None, is_task=is_task, sort=sort, order=order,
+ limit=limit, offset=offset,
+ project_id=project_id, milestone_id=milestone_id, parent_id=parent_id,
)
return jsonify({"notes": [n.to_dict() for n in notes], "total": total})
diff --git a/src/fabledassistant/services/milestones.py b/src/fabledassistant/services/milestones.py
index bc5de4b..f4d2ebf 100644
--- a/src/fabledassistant/services/milestones.py
+++ b/src/fabledassistant/services/milestones.py
@@ -53,6 +53,18 @@ async def get_milestone_by_title(user_id: int, project_id: int, title: str) -> M
return result.scalars().first()
+async def find_milestone_by_title(user_id: int, title: str) -> Milestone | None:
+ """Find a milestone by title across ALL projects for this user (case-insensitive)."""
+ async with async_session() as session:
+ result = await session.execute(
+ select(Milestone).where(
+ Milestone.user_id == user_id,
+ func.lower(Milestone.title) == func.lower(title.strip()),
+ ).order_by(Milestone.id).limit(1)
+ )
+ return result.scalars().first()
+
+
async def get_or_create_milestone(user_id: int, project_id: int, title: str) -> Milestone:
milestone = await get_milestone_by_title(user_id, project_id, title)
if milestone:
diff --git a/src/fabledassistant/services/notes.py b/src/fabledassistant/services/notes.py
index d8894e1..547d66d 100644
--- a/src/fabledassistant/services/notes.py
+++ b/src/fabledassistant/services/notes.py
@@ -71,6 +71,7 @@ async def list_notes(
due_after: date | None = None,
project_id: int | None = None,
milestone_id: int | None = None,
+ parent_id: int | None = None,
sort: str = "updated_at",
order: str = "desc",
limit: int = 50,
@@ -131,6 +132,10 @@ async def list_notes(
query = query.where(Note.milestone_id == milestone_id)
count_query = count_query.where(Note.milestone_id == milestone_id)
+ if parent_id is not None:
+ query = query.where(Note.parent_id == parent_id)
+ count_query = count_query.where(Note.parent_id == parent_id)
+
sort_col = getattr(Note, sort, Note.updated_at)
if order == "asc":
query = query.order_by(sort_col.asc())
diff --git a/src/fabledassistant/services/tools.py b/src/fabledassistant/services/tools.py
index 25d83ff..8f06c78 100644
--- a/src/fabledassistant/services/tools.py
+++ b/src/fabledassistant/services/tools.py
@@ -216,7 +216,7 @@ _CORE_TOOLS = [
"name": "list_notes",
"description": (
"Browse the user's notes (not tasks). Use this when the user asks to see, list, or browse "
- "their notes — by recency, keyword, or tag. For tasks, use list_tasks instead."
+ "their notes — by recency, keyword, tag, or project. For tasks, use list_tasks instead."
),
"parameters": {
"type": "object",
@@ -230,6 +230,10 @@ _CORE_TOOLS = [
"items": {"type": "string"},
"description": "Filter notes that have ALL of these tags",
},
+ "project": {
+ "type": "string",
+ "description": "Filter notes by project name",
+ },
"sort": {
"type": "string",
"enum": ["updated_at", "created_at", "title"],
@@ -904,6 +908,7 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
project_id = None
milestone_id = None
project_name = arguments.get("project")
+ milestone_name = arguments.get("milestone")
if project_name:
from fabledassistant.services.projects import get_project_by_title, list_projects as _lp
proj = await get_project_by_title(user_id, project_name)
@@ -914,12 +919,17 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
proj = matches[0]
if proj:
project_id = proj.id
- milestone_name = arguments.get("milestone")
if milestone_name:
from fabledassistant.services.milestones import get_milestone_by_title
ms = await get_milestone_by_title(user_id, proj.id, milestone_name)
if ms:
milestone_id = ms.id
+ elif milestone_name:
+ # No project specified — search milestone by name across all projects
+ from fabledassistant.services.milestones import find_milestone_by_title
+ ms = await find_milestone_by_title(user_id, milestone_name)
+ if ms:
+ milestone_id = ms.id
notes, total = await list_notes(
user_id=user_id,
is_task=True,
@@ -1097,11 +1107,24 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
elif tool_name == "list_notes":
tags_raw = arguments.get("tags")
tags = tags_raw if isinstance(tags_raw, list) else None
+ project_id = None
+ project_name = arguments.get("project")
+ if project_name:
+ from fabledassistant.services.projects import get_project_by_title as _gpbt, list_projects as _lp
+ proj = await _gpbt(user_id, project_name)
+ if proj is None:
+ all_p = await _lp(user_id)
+ matches = [p for p in all_p if project_name.lower() in p.title.lower()]
+ proj = matches[0] if matches else None
+ if proj is None:
+ return {"success": False, "error": f"Project '{project_name}' not found."}
+ project_id = proj.id
notes, total = await list_notes(
user_id=user_id,
q=arguments.get("q") or arguments.get("query"),
tags=tags,
is_task=False,
+ project_id=project_id,
sort=arguments.get("sort", "updated_at"),
order="desc",
limit=int(arguments.get("limit", 10)),
@@ -1111,6 +1134,7 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
"id": n.id,
"title": n.title,
"tags": n.tags or [],
+ "updated_at": n.updated_at.isoformat(),
"preview": (n.body[:200] + "...") if n.body and len(n.body) > 200 else (n.body or ""),
}
for n in notes