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
+212
View File
@@ -0,0 +1,212 @@
<script setup lang="ts">
import { ref, nextTick } from "vue";
const props = defineProps<{
modelValue: string[];
fetchTags: (q: string) => Promise<string[]>;
}>();
const emit = defineEmits<{
"update:modelValue": [value: string[]];
}>();
const inputValue = ref("");
const suggestions = ref<string[]>([]);
const showSuggestions = ref(false);
const inputRef = ref<HTMLInputElement | null>(null);
let fetchDebounce: ReturnType<typeof setTimeout> | null = null;
function sanitize(raw: string): string {
return raw.trim().replace(/\s+/g, "-").replace(/^#+/, "");
}
function addTag(raw: string) {
const tag = sanitize(raw);
if (!tag) return;
if (!props.modelValue.includes(tag)) {
emit("update:modelValue", [...props.modelValue, tag]);
}
inputValue.value = "";
suggestions.value = [];
showSuggestions.value = false;
}
function removeTag(tag: string) {
emit("update:modelValue", props.modelValue.filter((t) => t !== tag));
}
function onKeydown(e: KeyboardEvent) {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
const val = inputValue.value.replace(/,$/, "");
if (val.trim()) {
addTag(val);
}
} else if (e.key === "Backspace" && !inputValue.value) {
const tags = props.modelValue;
if (tags.length > 0) {
emit("update:modelValue", tags.slice(0, -1));
}
} else if (e.key === "Escape") {
showSuggestions.value = false;
}
}
function onInput() {
const q = inputValue.value.trim().replace(/^#+/, "");
if (fetchDebounce !== null) clearTimeout(fetchDebounce);
if (!q) {
suggestions.value = [];
showSuggestions.value = false;
return;
}
fetchDebounce = setTimeout(async () => {
try {
const results = await props.fetchTags(q);
suggestions.value = results.filter((t) => !props.modelValue.includes(t));
showSuggestions.value = suggestions.value.length > 0;
} catch {
suggestions.value = [];
}
}, 200);
}
function onBlur() {
// Small delay to allow click on suggestion
setTimeout(() => {
showSuggestions.value = false;
// Commit any pending text on blur
if (inputValue.value.trim()) {
addTag(inputValue.value);
}
}, 150);
}
function focusInput() {
nextTick(() => inputRef.value?.focus());
}
</script>
<template>
<div class="tag-input-wrapper" @click="focusInput">
<span
v-for="tag in modelValue"
:key="tag"
class="tag-chip"
>
#{{ tag }}
<button
type="button"
class="tag-chip-remove"
@click.stop="removeTag(tag)"
tabindex="-1"
>&times;</button>
</span>
<div class="tag-input-inner">
<input
ref="inputRef"
v-model="inputValue"
type="text"
class="tag-input-field"
placeholder="Add tag..."
@keydown="onKeydown"
@input="onInput"
@blur="onBlur"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
/>
<ul v-if="showSuggestions" class="tag-autocomplete">
<li
v-for="s in suggestions"
:key="s"
class="tag-autocomplete-item"
@mousedown.prevent="addTag(s)"
>
#{{ s }}
</li>
</ul>
</div>
</div>
</template>
<style scoped>
.tag-input-wrapper {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem;
padding: 0.35rem 0.6rem;
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
background: var(--color-bg-card);
cursor: text;
min-height: 2.25rem;
}
.tag-chip {
display: inline-flex;
align-items: center;
gap: 0.2rem;
padding: 0.15rem 0.5rem;
border-radius: 999px;
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
border: 1px solid var(--color-primary);
color: var(--color-primary);
font-size: 0.8rem;
white-space: nowrap;
}
.tag-chip-remove {
border: none;
background: none;
color: inherit;
cursor: pointer;
padding: 0;
line-height: 1;
font-size: 0.85rem;
opacity: 0.7;
}
.tag-chip-remove:hover {
opacity: 1;
}
.tag-input-inner {
position: relative;
flex: 1;
min-width: 80px;
}
.tag-input-field {
width: 100%;
border: none;
outline: none;
background: transparent;
color: var(--color-text);
font-size: 0.875rem;
padding: 0;
}
.tag-autocomplete {
position: absolute;
top: calc(100% + 4px);
left: 0;
min-width: 160px;
max-width: 280px;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
list-style: none;
margin: 0;
padding: 0.25rem 0;
z-index: 100;
}
.tag-autocomplete-item {
padding: 0.35rem 0.75rem;
font-size: 0.85rem;
cursor: pointer;
color: var(--color-text);
}
.tag-autocomplete-item:hover {
background: var(--color-bg-hover, color-mix(in srgb, var(--color-primary) 8%, transparent));
color: var(--color-primary);
}
</style>
-6
View File
@@ -9,18 +9,15 @@ import DOMPurify from "dompurify";
import { serializeToMarkdown } from "@/utils/markdownSerializer";
import { TagDecoration } from "@/extensions/TagDecoration";
import { WikilinkDecoration } from "@/extensions/WikilinkDecoration";
import { TagSuggestion } from "@/extensions/TagSuggestion";
import { WikilinkSuggestion } from "@/extensions/WikilinkSuggestion";
const props = withDefaults(
defineProps<{
modelValue: string;
placeholder?: string;
fetchTags?: (query: string) => Promise<string[]>;
}>(),
{
placeholder: "",
fetchTags: undefined,
}
);
@@ -72,9 +69,6 @@ try {
}),
TagDecoration,
WikilinkDecoration,
TagSuggestion.configure({
fetchTags: props.fetchTags ?? (async () => []),
}),
WikilinkSuggestion,
],
onUpdate({ editor: ed }) {