Improve tools reliability and add milestone management to ProjectView
tools.py: - Default tag_mode changed from 'replace' to 'add' — existing tags are preserved unless the user explicitly requests replacement - create_task, create_note, update_note, create_milestone: project and milestone lookup now uses get_by_title with fuzzy fallback; returns a clear error (with a hint to use list_projects/list_milestones) instead of silently creating duplicate projects or milestones via get_or_create - create_task: project/milestone resolved before note creation so a bad project name fails fast without leaving an orphaned task behind - update_note return value now includes item_type, tags, project_id, and an 'updated' summary string so the LLM can confirm what was modified - research_topic stub now logs an error and returns failure instead of silently returning success when reached via wrong code path ProjectView.vue: - Milestone headers now show edit (✎) and delete (✕) action buttons on hover - Edit triggers inline rename input; Enter/blur commits, Escape cancels - Delete opens a confirmation modal clarifying tasks are unlinked not deleted Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -65,6 +65,11 @@ const showNewMilestone = ref(false);
|
|||||||
const newMilestoneTitle = ref("");
|
const newMilestoneTitle = ref("");
|
||||||
const creatingMilestone = ref(false);
|
const creatingMilestone = ref(false);
|
||||||
|
|
||||||
|
// Milestone edit/delete state
|
||||||
|
const renamingMilestoneId = ref<number | null>(null);
|
||||||
|
const renamingMilestoneTitle = ref("");
|
||||||
|
const deletingMilestone = ref<Milestone | null>(null);
|
||||||
|
|
||||||
// Edit state
|
// Edit state
|
||||||
const editTitle = ref("");
|
const editTitle = ref("");
|
||||||
const editDescription = ref("");
|
const editDescription = ref("");
|
||||||
@@ -156,6 +161,38 @@ async function createMilestone() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function startRenameMilestone(ms: Milestone) {
|
||||||
|
renamingMilestoneId.value = ms.id;
|
||||||
|
renamingMilestoneTitle.value = ms.title;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function commitRenameMilestone(ms: Milestone) {
|
||||||
|
const newTitle = renamingMilestoneTitle.value.trim();
|
||||||
|
renamingMilestoneId.value = null;
|
||||||
|
if (!newTitle || newTitle === ms.title) return;
|
||||||
|
try {
|
||||||
|
await apiPatch(`/api/projects/${projectId.value}/milestones/${ms.id}`, { title: newTitle });
|
||||||
|
await loadMilestones();
|
||||||
|
toast.show("Milestone renamed");
|
||||||
|
} catch {
|
||||||
|
toast.show("Failed to rename milestone", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDeleteMilestone() {
|
||||||
|
const ms = deletingMilestone.value;
|
||||||
|
if (!ms) return;
|
||||||
|
deletingMilestone.value = null;
|
||||||
|
try {
|
||||||
|
await apiDelete(`/api/projects/${projectId.value}/milestones/${ms.id}`);
|
||||||
|
await loadMilestones();
|
||||||
|
await loadTasks();
|
||||||
|
toast.show("Milestone deleted");
|
||||||
|
} catch {
|
||||||
|
toast.show("Failed to delete milestone", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadTasks() {
|
async function loadTasks() {
|
||||||
tasksLoading.value = true;
|
tasksLoading.value = true;
|
||||||
try {
|
try {
|
||||||
@@ -384,13 +421,25 @@ function formatDate(dateStr?: string | null): string {
|
|||||||
<!-- Milestone header -->
|
<!-- Milestone header -->
|
||||||
<div
|
<div
|
||||||
class="milestone-header"
|
class="milestone-header"
|
||||||
@click="group.milestone && toggleMilestoneCollapse(group.milestone.id)"
|
@click="group.milestone && renamingMilestoneId !== group.milestone.id && toggleMilestoneCollapse(group.milestone.id)"
|
||||||
:class="{ clickable: !!group.milestone }"
|
:class="{ clickable: !!group.milestone && renamingMilestoneId !== group.milestone?.id }"
|
||||||
>
|
>
|
||||||
<span class="ms-chevron" v-if="group.milestone">
|
<span class="ms-chevron" v-if="group.milestone">
|
||||||
{{ collapsedMilestones.has(group.milestone.id) ? '▶' : '▼' }}
|
{{ collapsedMilestones.has(group.milestone.id) ? '▶' : '▼' }}
|
||||||
</span>
|
</span>
|
||||||
<span class="ms-name">{{ group.milestone?.title ?? "No Milestone" }}</span>
|
<!-- Inline rename input -->
|
||||||
|
<template v-if="group.milestone && renamingMilestoneId === group.milestone.id">
|
||||||
|
<input
|
||||||
|
class="ms-rename-input"
|
||||||
|
v-model="renamingMilestoneTitle"
|
||||||
|
@click.stop
|
||||||
|
@keydown.enter.stop="commitRenameMilestone(group.milestone)"
|
||||||
|
@keydown.escape.stop="renamingMilestoneId = null"
|
||||||
|
@blur="commitRenameMilestone(group.milestone)"
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<span v-else class="ms-name">{{ group.milestone?.title ?? "No Milestone" }}</span>
|
||||||
<span class="ms-count">{{ group.tasks.length }}</span>
|
<span class="ms-count">{{ group.tasks.length }}</span>
|
||||||
<template v-if="group.milestone">
|
<template v-if="group.milestone">
|
||||||
<div class="ms-progress-track">
|
<div class="ms-progress-track">
|
||||||
@@ -400,6 +449,19 @@ function formatDate(dateStr?: string | null): string {
|
|||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<span class="ms-pct">{{ group.milestone.pct }}%</span>
|
<span class="ms-pct">{{ group.milestone.pct }}%</span>
|
||||||
|
<!-- Edit / delete actions -->
|
||||||
|
<div class="ms-actions" @click.stop>
|
||||||
|
<button
|
||||||
|
class="ms-action-btn"
|
||||||
|
title="Rename milestone"
|
||||||
|
@click="startRenameMilestone(group.milestone)"
|
||||||
|
>✎</button>
|
||||||
|
<button
|
||||||
|
class="ms-action-btn ms-action-delete"
|
||||||
|
title="Delete milestone"
|
||||||
|
@click="deletingMilestone = group.milestone"
|
||||||
|
>✕</button>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -497,7 +559,24 @@ function formatDate(dateStr?: string | null): string {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Delete confirmation modal -->
|
<!-- Milestone delete confirmation modal -->
|
||||||
|
<teleport to="body">
|
||||||
|
<div v-if="deletingMilestone" class="modal-overlay" @click.self="deletingMilestone = null">
|
||||||
|
<div class="modal-card">
|
||||||
|
<h3 class="modal-title">Delete Milestone</h3>
|
||||||
|
<p class="modal-message">
|
||||||
|
Delete <strong>{{ deletingMilestone.title }}</strong>?
|
||||||
|
Tasks will be unlinked from this milestone but not deleted.
|
||||||
|
</p>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="modal-btn" @click="deletingMilestone = null">Cancel</button>
|
||||||
|
<button class="modal-btn modal-btn-danger" @click="confirmDeleteMilestone">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</teleport>
|
||||||
|
|
||||||
|
<!-- Project delete confirmation modal -->
|
||||||
<teleport to="body">
|
<teleport to="body">
|
||||||
<div v-if="showDeleteConfirm" class="modal-overlay" @click.self="showDeleteConfirm = false">
|
<div v-if="showDeleteConfirm" class="modal-overlay" @click.self="showDeleteConfirm = false">
|
||||||
<div class="modal-card">
|
<div class="modal-card">
|
||||||
@@ -1044,6 +1123,53 @@ function formatDate(dateStr?: string | null): string {
|
|||||||
}
|
}
|
||||||
.modal-btn-danger:hover { opacity: 0.9; }
|
.modal-btn-danger:hover { opacity: 0.9; }
|
||||||
|
|
||||||
|
/* Milestone rename input */
|
||||||
|
.ms-rename-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0.1rem 0.35rem;
|
||||||
|
border: 1px solid var(--color-primary);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-family: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.ms-rename-input:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Milestone action buttons */
|
||||||
|
.ms-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.15rem;
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.milestone-header:hover .ms-actions {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.ms-action-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.15rem 0.3rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
line-height: 1;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.ms-action-btn:hover {
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
.ms-action-delete:hover {
|
||||||
|
color: var(--color-danger, #e74c3c);
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.project-body {
|
.project-body {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
@@ -156,8 +156,8 @@ _CORE_TOOLS = [
|
|||||||
},
|
},
|
||||||
"tag_mode": {
|
"tag_mode": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": ["replace", "add", "remove"],
|
"enum": ["add", "remove", "replace"],
|
||||||
"description": "'replace' sets tags to exactly this list, 'add' appends without duplicates, 'remove' removes listed tags (default: replace)",
|
"description": "'add' appends tags without duplicates (default), 'remove' removes listed tags, 'replace' sets tags to exactly this list (destructive — avoid unless explicitly requested)",
|
||||||
},
|
},
|
||||||
"project": {
|
"project": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -716,6 +716,28 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
|
|||||||
return {"success": False, "error": "title must be a string. Call create_task once per task."}
|
return {"success": False, "error": "title must be a string. Call create_task once per task."}
|
||||||
if not isinstance(task_body, str):
|
if not isinstance(task_body, str):
|
||||||
task_body = ""
|
task_body = ""
|
||||||
|
# Resolve project and milestone BEFORE creating to fail fast on not-found
|
||||||
|
project_name = arguments.get("project")
|
||||||
|
milestone_name = arguments.get("milestone")
|
||||||
|
parent_task_name = arguments.get("parent_task")
|
||||||
|
pre_fields: dict = {}
|
||||||
|
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. Use list_projects to see available projects or create_project to create one."}
|
||||||
|
pre_fields["project_id"] = proj.id
|
||||||
|
if milestone_name:
|
||||||
|
from fabledassistant.services.milestones import get_milestone_by_title as _gmbt
|
||||||
|
ms = await _gmbt(user_id, proj.id, milestone_name)
|
||||||
|
if ms is None:
|
||||||
|
return {"success": False, "error": f"Milestone '{milestone_name}' not found in project '{proj.title}'. Use list_milestones to see available milestones."}
|
||||||
|
pre_fields["milestone_id"] = ms.id
|
||||||
|
|
||||||
note = await create_note(
|
note = await create_note(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
title=task_title,
|
title=task_title,
|
||||||
@@ -723,28 +745,16 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
|
|||||||
status="todo",
|
status="todo",
|
||||||
priority=arguments.get("priority", "none"),
|
priority=arguments.get("priority", "none"),
|
||||||
due_date=_parse_due_date(arguments.get("due_date")),
|
due_date=_parse_due_date(arguments.get("due_date")),
|
||||||
|
project_id=pre_fields.get("project_id"),
|
||||||
|
milestone_id=pre_fields.get("milestone_id"),
|
||||||
)
|
)
|
||||||
suggested = await suggest_tags(user_id, task_title, task_body)
|
suggested = await suggest_tags(user_id, task_title, task_body)
|
||||||
_schedule_embedding(note.id, user_id, task_title, task_body)
|
_schedule_embedding(note.id, user_id, task_title, task_body)
|
||||||
# Handle optional project and parent_task assignment
|
|
||||||
project_name = arguments.get("project")
|
|
||||||
parent_task_name = arguments.get("parent_task")
|
|
||||||
update_fields: dict = {}
|
update_fields: dict = {}
|
||||||
if project_name:
|
|
||||||
from fabledassistant.services.projects import get_or_create_project
|
|
||||||
proj = await get_or_create_project(user_id, project_name)
|
|
||||||
update_fields["project_id"] = proj.id
|
|
||||||
if parent_task_name:
|
if parent_task_name:
|
||||||
parent_notes, _ = await list_notes(user_id=user_id, q=parent_task_name, is_task=True, limit=1)
|
parent_notes, _ = await list_notes(user_id=user_id, q=parent_task_name, is_task=True, limit=1)
|
||||||
if parent_notes:
|
if parent_notes:
|
||||||
update_fields["parent_id"] = parent_notes[0].id
|
update_fields["parent_id"] = parent_notes[0].id
|
||||||
milestone_name = arguments.get("milestone")
|
|
||||||
if milestone_name and project_name:
|
|
||||||
from fabledassistant.services.projects import get_or_create_project as _gocp
|
|
||||||
from fabledassistant.services.milestones import get_or_create_milestone
|
|
||||||
proj = await _gocp(user_id, project_name)
|
|
||||||
ms = await get_or_create_milestone(user_id, proj.id, milestone_name)
|
|
||||||
update_fields["milestone_id"] = ms.id
|
|
||||||
if update_fields:
|
if update_fields:
|
||||||
note = await update_note(user_id, note.id, **update_fields)
|
note = await update_note(user_id, note.id, **update_fields)
|
||||||
return {
|
return {
|
||||||
@@ -771,20 +781,29 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
|
|||||||
note_body = ""
|
note_body = ""
|
||||||
if not isinstance(note_tags, list):
|
if not isinstance(note_tags, list):
|
||||||
note_tags = []
|
note_tags = []
|
||||||
|
# Resolve project BEFORE creating to fail fast on not-found
|
||||||
|
project_name = arguments.get("project")
|
||||||
|
project_id = None
|
||||||
|
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. Use list_projects to see available projects or create_project to create one."}
|
||||||
|
project_id = proj.id
|
||||||
|
|
||||||
note = await create_note(
|
note = await create_note(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
title=note_title,
|
title=note_title,
|
||||||
body=note_body,
|
body=note_body,
|
||||||
tags=note_tags,
|
tags=note_tags,
|
||||||
|
project_id=project_id,
|
||||||
)
|
)
|
||||||
suggested = await suggest_tags(user_id, note_title, note_body)
|
suggested = await suggest_tags(user_id, note_title, note_body)
|
||||||
_schedule_embedding(note.id, user_id, note_title, note_body)
|
_schedule_embedding(note.id, user_id, note_title, note_body)
|
||||||
# Handle optional project assignment
|
|
||||||
project_name = arguments.get("project")
|
|
||||||
if project_name:
|
|
||||||
from fabledassistant.services.projects import get_or_create_project
|
|
||||||
proj = await get_or_create_project(user_id, project_name)
|
|
||||||
note = await update_note(user_id, note.id, project_id=proj.id)
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"type": "note",
|
"type": "note",
|
||||||
@@ -831,7 +850,7 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
|
|||||||
if "tags" in arguments:
|
if "tags" in arguments:
|
||||||
new_tags = arguments["tags"]
|
new_tags = arguments["tags"]
|
||||||
if isinstance(new_tags, list):
|
if isinstance(new_tags, list):
|
||||||
tag_mode = arguments.get("tag_mode", "replace")
|
tag_mode = arguments.get("tag_mode", "add")
|
||||||
if tag_mode == "add":
|
if tag_mode == "add":
|
||||||
existing = list(note.tags or [])
|
existing = list(note.tags or [])
|
||||||
for t in new_tags:
|
for t in new_tags:
|
||||||
@@ -847,8 +866,14 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
|
|||||||
if "project" in arguments:
|
if "project" in arguments:
|
||||||
project_name = arguments["project"]
|
project_name = arguments["project"]
|
||||||
if project_name:
|
if project_name:
|
||||||
from fabledassistant.services.projects import get_or_create_project
|
from fabledassistant.services.projects import get_project_by_title as _gpbt, list_projects as _lp
|
||||||
proj = await get_or_create_project(user_id, project_name)
|
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. Use list_projects to see available projects."}
|
||||||
update_fields["project_id"] = proj.id
|
update_fields["project_id"] = proj.id
|
||||||
else:
|
else:
|
||||||
update_fields["project_id"] = None
|
update_fields["project_id"] = None
|
||||||
@@ -859,12 +884,18 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
|
|||||||
|
|
||||||
suggested = await suggest_tags(user_id, updated.title, updated.body or "")
|
suggested = await suggest_tags(user_id, updated.title, updated.body or "")
|
||||||
_schedule_embedding(updated.id, user_id, updated.title, updated.body or "")
|
_schedule_embedding(updated.id, user_id, updated.title, updated.body or "")
|
||||||
|
item_type = "task" if updated.status is not None else "note"
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"type": "note_updated",
|
"type": "note_updated",
|
||||||
|
"updated": f"{item_type} '{updated.title}' (id: {updated.id})",
|
||||||
"data": {
|
"data": {
|
||||||
"id": updated.id,
|
"id": updated.id,
|
||||||
"title": updated.title,
|
"title": updated.title,
|
||||||
|
"item_type": item_type,
|
||||||
|
"status": updated.status,
|
||||||
|
"tags": updated.tags or [],
|
||||||
|
"project_id": updated.project_id,
|
||||||
},
|
},
|
||||||
"suggested_tags": suggested,
|
"suggested_tags": suggested,
|
||||||
}
|
}
|
||||||
@@ -1133,12 +1164,18 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
elif tool_name == "create_milestone":
|
elif tool_name == "create_milestone":
|
||||||
from fabledassistant.services.projects import get_or_create_project as _gocp
|
from fabledassistant.services.projects import get_project_by_title as _gpbt, list_projects as _lp
|
||||||
from fabledassistant.services.milestones import create_milestone as _cm, get_project_milestone_summary
|
from fabledassistant.services.milestones import create_milestone as _cm, get_project_milestone_summary
|
||||||
project_name = arguments.get("project", "")
|
project_name = arguments.get("project", "")
|
||||||
if not project_name:
|
if not project_name:
|
||||||
return {"success": False, "error": "project is required"}
|
return {"success": False, "error": "project is required"}
|
||||||
proj = await _gocp(user_id, project_name)
|
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. Use list_projects to see available projects or create_project to create one."}
|
||||||
ms = await _cm(
|
ms = await _cm(
|
||||||
user_id,
|
user_id,
|
||||||
proj.id,
|
proj.id,
|
||||||
@@ -1182,13 +1219,13 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
elif tool_name == "research_topic":
|
elif tool_name == "research_topic":
|
||||||
# Research is always handled upstream in generation_task.py (round 0).
|
# Research is always intercepted in generation_task.py (round 0) before execute_tool.
|
||||||
# This fallback exists in case it somehow reaches execute_tool.
|
# Reaching here indicates a code path regression.
|
||||||
topic = arguments.get("topic", "")
|
topic = arguments.get("topic", "")
|
||||||
|
logger.error("research_topic reached execute_tool — should have been intercepted upstream in generation_task.py. topic=%r", topic)
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": False,
|
||||||
"type": "research_pending",
|
"error": "Research could not be started. Please try again.",
|
||||||
"data": {"topic": topic},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
elif tool_name == "create_project":
|
elif tool_name == "create_project":
|
||||||
|
|||||||
Reference in New Issue
Block a user