UI polish pass: word count, slash commands, task lists, graph peek, bulk delete, export

- WordCount component (toggle words/chars vs read time, persisted mode)
- TipTap: TaskList/TaskItem extensions, slash command menu (H1-H3, lists, code, quote, task)
- Markdown serializer: task list → `- [ ]` / `- [x]` roundtrip
- GraphView: slide-in peek panel for note/task nodes (body, tags, linked nodes); tag nodes still navigate
- ChatView: bulk-select conversations with two-click confirm delete + chat retention policy (default 90d)
- NoteEditorView: pill tab bar with animated edit/preview toggle + WordCount in toolbar
- WorkspaceNoteEditor: inline search with tag matching, inline new-note creation, WordCount
- WorkspaceTaskPanel: task body rendered in slide-over + Edit link
- Settings: data export (Markdown ZIP / JSON) via GET /api/export
- Accessibility: skip-to-content link, aria-labels on all icon-only buttons
- Fix: export route used non-existent fabledassistant.database — corrected to async_session()
- Fix: ToolCallRecord.status type now includes "running" (was causing TS build error)
- Dockerfile: upgrade npm to latest before install to suppress major-version notice
- npm audit fix: patched minimatch (ReDoS) and rollup (path traversal) — 0 vulnerabilities

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-07 23:15:01 -05:00
parent de7709039a
commit ef141f07f8
28 changed files with 2980 additions and 160 deletions
+189 -9
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted } from "vue";
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from "vue";
import type { Editor } from "@tiptap/vue-3";
import { apiGet, apiPatch, apiPost, apiDelete } from "@/api/client";
import { useToastStore } from "@/stores/toast";
@@ -9,6 +9,7 @@ import { useNotesStore } from "@/stores/notes";
import TiptapEditor from "@/components/TiptapEditor.vue";
import TagInput from "@/components/TagInput.vue";
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
import WordCount from "@/components/WordCount.vue";
const props = defineProps<{
projectId: number;
@@ -35,6 +36,25 @@ const projectNotes = ref<NoteItem[]>([]);
const listLoading = ref(false);
const deletingId = ref<number | null>(null); // confirming delete for this id
const pendingDelete = ref<number | null>(null); // mid-deletion
const searchQuery = ref("");
const filteredNotes = computed(() => {
const q = searchQuery.value.trim().toLowerCase();
if (!q) return projectNotes.value;
return projectNotes.value.filter(
(n) =>
n.title.toLowerCase().includes(q) ||
n.tags?.some((t) => t.toLowerCase().includes(q))
);
});
function matchedTags(note: NoteItem): string[] {
const q = searchQuery.value.trim().toLowerCase();
if (!q) return [];
return (note.tags ?? []).filter(
(t) => t.toLowerCase().includes(q) && !note.title.toLowerCase().includes(q)
);
}
// Editor state
const editingId = ref<number | null>(null);
@@ -46,6 +66,44 @@ const saving = ref(false);
const tagSuggestions = useTagSuggestions(noteTitle, noteBody, noteTags, () => { dirty.value = true; });
// New note inline creation
const showNewNoteInput = ref(false);
const newNoteTitle = ref("");
const creatingNote = ref(false);
const newNoteTitleRef = ref<HTMLInputElement | null>(null);
async function startNewNote() {
showNewNoteInput.value = true;
newNoteTitle.value = "";
await nextTick();
newNoteTitleRef.value?.focus();
}
function cancelNewNote() {
showNewNoteInput.value = false;
newNoteTitle.value = "";
}
async function createNote() {
const title = newNoteTitle.value.trim();
if (!title || creatingNote.value) return;
creatingNote.value = true;
try {
const note = await apiPost<{ id: number; title: string; body: string; tags: string[]; updated_at: string }>(
"/api/notes",
{ title, project_id: props.projectId, body: "", is_task: false }
);
showNewNoteInput.value = false;
newNoteTitle.value = "";
await loadProjectNotes();
await openNote(note.id);
} catch {
toast.show("Failed to create note", "error");
} finally {
creatingNote.value = false;
}
}
// TipTap editor ref (for MarkdownToolbar)
const titleRef = ref<HTMLInputElement | null>(null);
const editorRef = ref<InstanceType<typeof TiptapEditor> | null>(null);
@@ -168,6 +226,7 @@ async function saveNote() {
function backToList() {
view.value = "list";
deletingId.value = null;
searchQuery.value = "";
}
function requestDelete(id: number, e: Event) {
@@ -245,6 +304,7 @@ onUnmounted(() => { if (linkCheckTimer) clearTimeout(linkCheckTimer); });
<div class="panel-header">
<button class="btn-back-arrow" @click="backToList"> Notes</button>
<div class="editor-header-right">
<WordCount :body="noteBody" />
<span v-if="dirty && !saving" class="unsaved">Unsaved</span>
<span v-if="saving" class="saving-txt">Saving...</span>
<button class="btn-save" :disabled="saving || !dirty" @click="saveNote">Save</button>
@@ -288,7 +348,7 @@ onUnmounted(() => { if (linkCheckTimer) clearTimeout(linkCheckTimer); });
>
#{{ tag }}{{ tagSuggestions.appliedTags.value.has(tag) ? ' ✓' : '' }}
</button>
<button class="btn-dismiss-suggestions" @click="tagSuggestions.dismissTagSuggestions()"></button>
<button class="btn-dismiss-suggestions" aria-label="Dismiss tag suggestions" @click="tagSuggestions.dismissTagSuggestions()"></button>
</div>
<!-- Formatting toolbar -->
@@ -308,7 +368,7 @@ onUnmounted(() => { if (linkCheckTimer) clearTimeout(linkCheckTimer); });
<button class="btn-chip-link" @click="applyLink(s)">[[{{ s.title }}]]</button>
</span>
<button class="btn-link-all" @click="applyAllLinks" title="Link all suggestions">All</button>
<button class="btn-dismiss-suggestions" @click="linkSuggestions = []"></button>
<button class="btn-dismiss-suggestions" aria-label="Dismiss link suggestions" @click="linkSuggestions = []"></button>
</div>
<div class="editor-area" @keydown.ctrl.s.prevent="saveNote" @keydown.ctrl.e.prevent="editorRef?.editor?.commands.focus()">
@@ -319,14 +379,42 @@ onUnmounted(() => { if (linkCheckTimer) clearTimeout(linkCheckTimer); });
<!-- LIST VIEW -->
<template v-else>
<div class="panel-header">
<span class="panel-title">Notes</span>
<template v-if="showNewNoteInput">
<input
ref="newNoteTitleRef"
v-model="newNoteTitle"
class="new-note-input"
placeholder="Note title..."
:disabled="creatingNote"
@keydown.enter="createNote"
@keydown.escape="cancelNewNote"
/>
<button class="btn-new-note-confirm" :disabled="creatingNote || !newNoteTitle.trim()" @click="createNote">
{{ creatingNote ? '...' : '+' }}
</button>
<button class="btn-new-note-cancel" aria-label="Cancel new note" @click="cancelNewNote"></button>
</template>
<template v-else>
<span class="panel-title">Notes</span>
<button class="btn-new-note" @click="startNewNote" title="New note">+ New</button>
</template>
</div>
<div class="note-search-bar">
<input
v-model="searchQuery"
class="note-search-input"
placeholder="Search notes..."
type="search"
/>
<button v-if="searchQuery" class="btn-search-clear" @click="searchQuery = ''" title="Clear search"></button>
</div>
<div v-if="listLoading" class="state-msg">Loading...</div>
<ul v-else class="note-list">
<li
v-for="note in projectNotes"
v-for="note in filteredNotes"
:key="note.id"
class="note-row"
:class="{ active: editingId === note.id }"
@@ -335,7 +423,11 @@ onUnmounted(() => { if (linkCheckTimer) clearTimeout(linkCheckTimer); });
<div class="note-row-main">
<span class="note-row-title">{{ note.title }}</span>
<span v-if="note.tags?.length" class="note-row-tags">
<span v-for="tag in note.tags.slice(0, 3)" :key="tag" class="note-tag-pill">#{{ tag }}</span>
<span
v-for="tag in note.tags.slice(0, 3)"
:key="tag"
:class="['note-tag-pill', { 'tag-match': matchedTags(note).includes(tag) }]"
>#{{ tag }}</span>
<span v-if="note.tags.length > 3" class="note-tag-more">+{{ note.tags.length - 3 }}</span>
</span>
<span class="note-row-age">{{ formatDate(note.updated_at) }}</span>
@@ -350,7 +442,7 @@ onUnmounted(() => { if (linkCheckTimer) clearTimeout(linkCheckTimer); });
>
{{ pendingDelete === note.id ? '...' : 'Delete?' }}
</button>
<button class="btn-cancel-delete" @click="cancelDelete($event)"></button>
<button class="btn-cancel-delete" aria-label="Cancel delete" @click="cancelDelete($event)"></button>
</template>
<button
v-else
@@ -363,8 +455,8 @@ onUnmounted(() => { if (linkCheckTimer) clearTimeout(linkCheckTimer); });
</div>
</li>
<li v-if="projectNotes.length === 0" class="state-msg">
No notes yet ask the agent to create one.
<li v-if="filteredNotes.length === 0" class="state-msg">
{{ searchQuery.trim() ? 'No notes match.' : 'No notes yet ask the agent to create one.' }}
</li>
</ul>
</template>
@@ -588,6 +680,41 @@ onUnmounted(() => { if (linkCheckTimer) clearTimeout(linkCheckTimer); });
padding: 0.5rem 0.6rem;
}
/* Search bar */
.note-search-bar {
display: flex;
align-items: center;
gap: 0.3rem;
padding: 0.4rem 0.6rem;
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
.note-search-input {
flex: 1;
background: var(--color-input-bg, var(--color-bg));
border: 1px solid var(--color-border);
border-radius: 5px;
padding: 0.25rem 0.5rem;
font-size: 0.82rem;
color: var(--color-text);
min-width: 0;
}
.note-search-input:focus { outline: none; border-color: var(--color-primary); }
.note-search-input::-webkit-search-cancel-button { display: none; }
.btn-search-clear {
background: none;
border: none;
color: var(--color-text-muted);
font-size: 0.72rem;
cursor: pointer;
padding: 0.1rem 0.25rem;
flex-shrink: 0;
border-radius: 3px;
}
.btn-search-clear:hover { color: var(--color-text); }
/* List */
.note-list {
list-style: none;
@@ -646,6 +773,11 @@ onUnmounted(() => { if (linkCheckTimer) clearTimeout(linkCheckTimer); });
max-width: 6rem;
}
.note-tag-pill.tag-match {
background: color-mix(in srgb, var(--color-primary) 22%, transparent);
outline: 1px solid var(--color-primary);
}
.note-tag-more {
font-size: 0.62rem;
color: var(--color-text-muted);
@@ -707,4 +839,52 @@ onUnmounted(() => { if (linkCheckTimer) clearTimeout(linkCheckTimer); });
font-size: 0.85rem;
color: var(--color-text-muted);
}
.btn-new-note {
margin-left: auto;
background: none;
border: 1px solid var(--color-border);
border-radius: 5px;
padding: 0.2rem 0.55rem;
font-size: 0.75rem;
color: var(--color-text-muted);
cursor: pointer;
}
.btn-new-note:hover { border-color: var(--color-primary); color: var(--color-primary); }
.new-note-input {
flex: 1;
background: var(--color-input-bg, var(--color-bg));
border: 1px solid var(--color-primary);
border-radius: 5px;
padding: 0.22rem 0.5rem;
font-size: 0.83rem;
color: var(--color-text);
min-width: 0;
}
.new-note-input:focus { outline: none; }
.btn-new-note-confirm {
background: var(--color-primary);
color: #fff;
border: none;
border-radius: 5px;
padding: 0.22rem 0.5rem;
font-size: 0.9rem;
cursor: pointer;
flex-shrink: 0;
line-height: 1;
}
.btn-new-note-confirm:disabled { opacity: 0.4; cursor: default; }
.btn-new-note-cancel {
background: none;
border: none;
color: var(--color-text-muted);
font-size: 0.75rem;
cursor: pointer;
padding: 0.1rem 0.3rem;
flex-shrink: 0;
}
.btn-new-note-cancel:hover { color: var(--color-text); }
</style>