Phase 20: Dedicated tag field — chip input, explicit tags array

Tags are now a first-class field rather than being auto-extracted from
the note body. A new TagInput.vue chip component handles tag entry in
both editor views with autocomplete, Enter/comma/backspace UX, and
space-to-hyphen sanitization.

Backend:
- routes/notes.py: create reads tags from JSON; update accepts explicit
  tags (omit = keep existing); append_tag writes to tags array with
  dedup; suggest-tags accepts current_tags filter; remove extract_tags
- routes/tasks.py: same — explicit tags on create/update; remove extract_tags
- services/tag_suggestions.py: current_tags param replaces body extraction
- services/tools.py: create_note tool schema adds tags param; executor passes it
- services/llm.py: system prompt tells LLM to use tags param, not embed #tag in body

Frontend:
- components/TagInput.vue: new chip-based tag input (autocomplete, keyboard UX)
- NoteEditorView.vue / TaskEditorView.vue: tags ref loaded from note.tags;
  TagInput placed between title and body; save/autosave include tags; suggest
  now adds chips; fetchTagSuggestions passes current_tags; dirty tracks tags
- TiptapEditor.vue: remove fetchTags prop and TagSuggestion extension;
  keep TagDecoration for legacy inline #tag highlighting

No DB migration needed — tags column already correct.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-26 06:36:35 -05:00
parent 7947134e22
commit 316a85e13b
12 changed files with 315 additions and 55 deletions
+25 -5
View File
@@ -9,6 +9,7 @@ import { apiPost } from "@/api/client";
import type { Editor } from "@tiptap/vue-3";
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
import TiptapEditor from "@/components/TiptapEditor.vue";
import TagInput from "@/components/TagInput.vue";
const route = useRoute();
const router = useRouter();
@@ -17,6 +18,7 @@ const toast = useToastStore();
const title = ref("");
const body = ref("");
const tags = ref<string[]>([]);
const dirty = ref(false);
const saving = ref(false);
const showPreview = ref(false);
@@ -106,6 +108,7 @@ async function fetchTagSuggestions() {
const res = await apiPost<{ suggested_tags: string[] }>("/api/notes/suggest-tags", {
title: title.value,
body: body.value,
current_tags: tags.value,
});
suggestedTags.value = res.suggested_tags;
} catch {
@@ -117,7 +120,9 @@ async function fetchTagSuggestions() {
function applyTagSuggestion(tag: string) {
if (appliedTags.value.has(tag)) return;
body.value = body.value.trimEnd() + `\n#${tag}`;
if (!tags.value.includes(tag)) {
tags.value = [...tags.value, tag];
}
appliedTags.value.add(tag);
markDirty();
}
@@ -130,9 +135,13 @@ function dismissTagSuggestions() {
// Track saved state for dirty detection
let savedTitle = "";
let savedBody = "";
let savedTags: string[] = [];
function markDirty() {
dirty.value = title.value !== savedTitle || body.value !== savedBody;
dirty.value =
title.value !== savedTitle ||
body.value !== savedBody ||
JSON.stringify(tags.value) !== JSON.stringify(savedTags);
}
function onBodyUpdate(newVal: string) {
@@ -146,8 +155,10 @@ onMounted(async () => {
if (store.currentNote) {
title.value = store.currentNote.title;
body.value = store.currentNote.body;
tags.value = [...(store.currentNote.tags || [])];
savedTitle = title.value;
savedBody = body.value;
savedTags = [...tags.value];
}
}
});
@@ -160,15 +171,18 @@ async function save() {
await store.updateNote(noteId.value!, {
title: title.value,
body: body.value,
tags: tags.value,
});
savedTitle = title.value;
savedBody = body.value;
savedTags = [...tags.value];
dirty.value = false;
toast.show("Note saved");
} else {
const note = await store.createNote({
title: title.value,
body: body.value,
tags: tags.value,
});
dirty.value = false;
toast.show("Note created");
@@ -209,9 +223,10 @@ 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 });
await store.updateNote(noteId.value!, { title: title.value, body: body.value, tags: tags.value });
savedTitle = title.value;
savedBody = body.value;
savedTags = [...tags.value];
dirty.value = false;
toast.show("Auto-saved");
} catch {
@@ -275,6 +290,12 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
@input="markDirty"
/>
<TagInput
v-model="tags"
:fetchTags="(q: string) => store.fetchAllTags(q)"
@update:modelValue="markDirty"
/>
<div class="tag-suggest-row">
<button class="btn-suggest-tags" @click="fetchTagSuggestions" :disabled="suggestingTags">
{{ suggestingTags ? "Suggesting..." : "Suggest tags" }}
@@ -318,8 +339,7 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
<TiptapEditor
ref="editorRef"
:modelValue="body"
placeholder="Write your note in Markdown... Use #tags inline"
:fetchTags="(q: string) => store.fetchAllTags(q)"
placeholder="Write your note in Markdown..."
@update:modelValue="onBodyUpdate"
@selectionChange="onSelectionChange"
/>
+21 -3
View File
@@ -11,6 +11,7 @@ import type { TaskStatus, TaskPriority } from "@/types/task";
import type { Editor } from "@tiptap/vue-3";
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
import TiptapEditor from "@/components/TiptapEditor.vue";
import TagInput from "@/components/TagInput.vue";
const route = useRoute();
const router = useRouter();
@@ -20,6 +21,7 @@ const toast = useToastStore();
const title = ref("");
const body = ref("");
const tags = ref<string[]>([]);
const status = ref<TaskStatus>("todo");
const priority = ref<TaskPriority>("none");
const dueDate = ref("");
@@ -112,6 +114,7 @@ async function fetchTagSuggestions() {
const res = await apiPost<{ suggested_tags: string[] }>("/api/notes/suggest-tags", {
title: title.value,
body: body.value,
current_tags: tags.value,
});
suggestedTags.value = res.suggested_tags;
} catch {
@@ -123,7 +126,9 @@ async function fetchTagSuggestions() {
function applyTagSuggestion(tag: string) {
if (appliedTags.value.has(tag)) return;
body.value = body.value.trimEnd() + `\n#${tag}`;
if (!tags.value.includes(tag)) {
tags.value = [...tags.value, tag];
}
appliedTags.value.add(tag);
markDirty();
}
@@ -135,6 +140,7 @@ function dismissTagSuggestions() {
let savedTitle = "";
let savedBody = "";
let savedTags: string[] = [];
let savedStatus: TaskStatus = "todo";
let savedPriority: TaskPriority = "none";
let savedDueDate = "";
@@ -143,6 +149,7 @@ function markDirty() {
dirty.value =
title.value !== savedTitle ||
body.value !== savedBody ||
JSON.stringify(tags.value) !== JSON.stringify(savedTags) ||
status.value !== savedStatus ||
priority.value !== savedPriority ||
dueDate.value !== savedDueDate;
@@ -159,11 +166,13 @@ onMounted(async () => {
if (store.currentTask) {
title.value = store.currentTask.title;
body.value = store.currentTask.body;
tags.value = [...(store.currentTask.tags || [])];
status.value = store.currentTask.status as TaskStatus;
priority.value = store.currentTask.priority as TaskPriority;
dueDate.value = store.currentTask.due_date || "";
savedTitle = title.value;
savedBody = body.value;
savedTags = [...tags.value];
savedStatus = status.value;
savedPriority = priority.value;
savedDueDate = dueDate.value;
@@ -178,6 +187,7 @@ async function save() {
const data = {
title: title.value,
body: body.value,
tags: tags.value,
status: status.value,
priority: priority.value,
due_date: dueDate.value || null,
@@ -186,6 +196,7 @@ async function save() {
await store.updateTask(taskId.value!, data);
savedTitle = title.value;
savedBody = body.value;
savedTags = [...tags.value];
savedStatus = status.value;
savedPriority = priority.value;
savedDueDate = dueDate.value;
@@ -235,12 +246,14 @@ async function autoSave() {
await store.updateTask(taskId.value!, {
title: title.value,
body: body.value,
tags: tags.value,
status: status.value,
priority: priority.value,
due_date: dueDate.value || null,
});
savedTitle = title.value;
savedBody = body.value;
savedTags = [...tags.value];
savedStatus = status.value;
savedPriority = priority.value;
savedDueDate = dueDate.value;
@@ -333,6 +346,12 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
</div>
</div>
<TagInput
v-model="tags"
:fetchTags="(q: string) => notesStore.fetchAllTags(q)"
@update:modelValue="markDirty"
/>
<div class="tag-suggest-row">
<button class="btn-suggest-tags" @click="fetchTagSuggestions" :disabled="suggestingTags">
{{ suggestingTags ? "Suggesting..." : "Suggest tags" }}
@@ -376,8 +395,7 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
<TiptapEditor
ref="editorRef"
:modelValue="body"
placeholder="Describe this task... Use #tags inline"
:fetchTags="(q: string) => notesStore.fetchAllTags(q)"
placeholder="Describe this task..."
@update:modelValue="onBodyUpdate"
@selectionChange="onSelectionChange"
/>