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
+262 -1
View File
@@ -6,6 +6,7 @@ import { zoom, zoomIdentity } from "d3-zoom";
import { drag } from "d3-drag";
import { select } from "d3-selection";
import { apiGet } from "@/api/client";
import { renderMarkdown } from "@/utils/markdown";
const router = useRouter();
@@ -62,6 +63,52 @@ const tooltip = ref<{ visible: boolean; x: number; y: number; node: GraphNode |
node: null,
});
// peek panel state
const peekNode = ref<GraphNode | null>(null);
const peekBody = ref("");
const peekLoading = ref(false);
const peekLinkedNodes = computed<GraphNode[]>(() => {
if (!peekNode.value) return [];
const pid = String(peekNode.value.id);
const linkedIds = new Set<string>();
for (const e of edges.value) {
const sid = String(typeof e.source === "object" ? (e.source as GraphNode).id : e.source);
const tid = String(typeof e.target === "object" ? (e.target as GraphNode).id : e.target);
if (sid === pid) linkedIds.add(tid);
if (tid === pid) linkedIds.add(sid);
}
return nodes.value.filter(
(n) => linkedIds.has(String(n.id)) && n.type !== "tag"
);
});
async function openPeek(node: GraphNode) {
peekNode.value = node;
peekBody.value = "";
peekLoading.value = true;
try {
const endpoint = node.type === "task" ? `/api/tasks/${node.id}` : `/api/notes/${node.id}`;
const data = await apiGet<{ body?: string }>(endpoint);
peekBody.value = data.body ?? "";
} catch {
peekBody.value = "";
} finally {
peekLoading.value = false;
}
}
function closePeek() {
peekNode.value = null;
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape" && peekNode.value) {
closePeek();
e.stopPropagation();
}
}
let simulation: Simulation<GraphNode, undefined> | null = null;
let resizeObserver: ResizeObserver | null = null;
@@ -208,7 +255,7 @@ function initGraph() {
if (d.type === "tag") {
router.push(`/notes?tags=${encodeURIComponent(d.title)}`);
} else {
router.push(d.type === "task" ? `/tasks/${d.id}` : `/notes/${d.id}`);
openPeek(d);
}
})
.on("mouseover", (event: MouseEvent, d: GraphNode) => {
@@ -391,11 +438,13 @@ onMounted(async () => {
resizeObserver = new ResizeObserver(handleResize);
if (containerRef.value) resizeObserver.observe(containerRef.value);
window.addEventListener("keydown", handleKeyDown);
});
onUnmounted(() => {
simulation?.stop();
resizeObserver?.disconnect();
window.removeEventListener("keydown", handleKeyDown);
});
</script>
@@ -466,6 +515,57 @@ onUnmounted(() => {
<p>No notes to display. Create some notes to see the graph.</p>
</div>
<!-- Peek panel -->
<Transition name="peek-slide">
<div v-if="peekNode" class="graph-peek-panel">
<div class="peek-header">
<span :class="['peek-type-badge', `peek-type-${peekNode.type}`]">{{ peekNode.type }}</span>
<div class="peek-actions">
<router-link
:to="peekNode.type === 'task' ? `/tasks/${peekNode.id}` : `/notes/${peekNode.id}`"
class="peek-link"
>Open</router-link>
<router-link
:to="peekNode.type === 'task' ? `/tasks/${peekNode.id}/edit` : `/notes/${peekNode.id}/edit`"
class="peek-link"
>Edit</router-link>
<button class="peek-close" @click="closePeek" title="Close (Esc)"></button>
</div>
</div>
<h2 class="peek-title">{{ peekNode.title }}</h2>
<div v-if="peekNode.tags?.length" class="peek-tags">
<span v-for="tag in peekNode.tags" :key="tag" class="tag-chip">#{{ tag }}</span>
</div>
<div class="peek-body-wrap">
<div v-if="peekLoading" class="peek-loading">Loading...</div>
<div
v-else-if="peekBody"
class="prose peek-prose"
v-html="renderMarkdown(peekBody)"
/>
<div v-else class="peek-empty">No content.</div>
</div>
<div v-if="peekLinkedNodes.length" class="peek-linked">
<div class="peek-linked-label">Linked notes</div>
<ul class="peek-linked-list">
<li
v-for="n in peekLinkedNodes"
:key="n.id"
class="peek-linked-item"
@click="openPeek(n)"
>
<span :class="['peek-linked-type', `peek-type-${n.type}`]">{{ n.type[0].toUpperCase() }}</span>
{{ n.title }}
</li>
</ul>
</div>
</div>
</Transition>
<div
v-if="tooltip.visible && tooltip.node"
class="graph-tooltip"
@@ -669,4 +769,165 @@ onUnmounted(() => {
color: var(--color-text-muted);
text-transform: capitalize;
}
/* ── Peek panel ── */
.graph-peek-panel {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 340px;
background: var(--color-bg-card);
border-left: 1px solid var(--color-border);
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
overflow: hidden;
z-index: 20;
}
.peek-slide-enter-active,
.peek-slide-leave-active {
transition: transform 0.2s ease;
}
.peek-slide-enter-from,
.peek-slide-leave-to {
transform: translateX(100%);
}
.peek-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 0.75rem;
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
.peek-type-badge {
font-size: 0.68rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.15rem 0.45rem;
border-radius: 10px;
border: 1px solid var(--color-border);
color: var(--color-text-muted);
}
.peek-type-task { border-color: var(--color-primary); color: var(--color-primary); }
.peek-actions {
display: flex;
align-items: center;
gap: 0.5rem;
margin-left: auto;
}
.peek-link {
font-size: 0.78rem;
color: var(--color-primary);
text-decoration: none;
}
.peek-link:hover { text-decoration: underline; }
.peek-close {
background: none;
border: none;
color: var(--color-text-muted);
font-size: 0.8rem;
cursor: pointer;
padding: 0.1rem 0.25rem;
border-radius: 3px;
}
.peek-close:hover { color: var(--color-text); }
.peek-title {
margin: 0;
padding: 0.75rem 0.75rem 0.4rem;
font-size: 1rem;
font-weight: 600;
color: var(--color-text);
flex-shrink: 0;
}
.peek-tags {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
padding: 0 0.75rem 0.5rem;
flex-shrink: 0;
}
.peek-body-wrap {
flex: 1;
overflow-y: auto;
padding: 0 0.75rem 0.5rem;
border-bottom: 1px solid var(--color-border);
}
.peek-prose {
font-size: 0.85rem;
}
.peek-loading,
.peek-empty {
font-size: 0.82rem;
color: var(--color-text-muted);
padding-top: 0.5rem;
}
.peek-linked {
flex-shrink: 0;
padding: 0.5rem 0.75rem 0.75rem;
}
.peek-linked-label {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
margin-bottom: 0.4rem;
}
.peek-linked-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.2rem;
max-height: 160px;
overflow-y: auto;
}
.peek-linked-item {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.82rem;
color: var(--color-text);
cursor: pointer;
padding: 0.25rem 0.4rem;
border-radius: 4px;
}
.peek-linked-item:hover {
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-bg-card));
color: var(--color-primary);
}
.peek-linked-type {
font-size: 0.65rem;
font-weight: 700;
width: 1.1rem;
height: 1.1rem;
border-radius: 50%;
background: var(--color-bg);
border: 1px solid var(--color-border);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--color-text-muted);
}
</style>