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
+158 -8
View File
@@ -229,6 +229,51 @@ async function newConversation() {
await startNewConversation();
}
// Bulk selection state
const selectMode = ref(false);
const selectedIds = ref<Set<number>>(new Set());
const bulkConfirm = ref(false);
const bulkDeleting = ref(false);
function toggleSelectMode() {
selectMode.value = !selectMode.value;
selectedIds.value = new Set();
bulkConfirm.value = false;
}
function toggleSelectConv(id: number) {
if (selectedIds.value.has(id)) {
selectedIds.value.delete(id);
} else {
selectedIds.value.add(id);
}
selectedIds.value = new Set(selectedIds.value); // trigger reactivity
}
function selectAll() {
selectedIds.value = new Set(store.conversations.map((c) => c.id));
}
function clearSelection() {
selectedIds.value = new Set();
bulkConfirm.value = false;
}
async function bulkDelete() {
if (!bulkConfirm.value) { bulkConfirm.value = true; return; }
bulkDeleting.value = true;
try {
const ids = [...selectedIds.value];
await store.bulkDeleteConversations(ids);
if (ids.includes(convId.value ?? -1)) router.push("/chat");
selectedIds.value = new Set();
bulkConfirm.value = false;
selectMode.value = false;
} finally {
bulkDeleting.value = false;
}
}
async function removeConversation(id: number) {
await store.deleteConversation(id);
if (convId.value === id) {
@@ -441,9 +486,31 @@ onUnmounted(() => {
@click="sidebarOpen = false"
></div>
<aside class="chat-sidebar" :class="{ open: sidebarOpen }">
<button class="btn-new-conv" @click="newConversation">
+ New Chat
</button>
<div class="sidebar-top-bar">
<button class="btn-new-conv" @click="newConversation">+ New Chat</button>
<button
class="btn-select-mode"
:class="{ active: selectMode }"
@click="toggleSelectMode"
title="Select conversations"
>Select</button>
</div>
<!-- Bulk action bar -->
<div v-if="selectMode" class="bulk-bar">
<span class="bulk-count">{{ selectedIds.size }} selected</span>
<button class="bulk-link" @click="selectAll">All</button>
<button class="bulk-link" @click="clearSelection">None</button>
<button
v-if="selectedIds.size"
class="bulk-delete-btn"
:class="{ confirm: bulkConfirm }"
:disabled="bulkDeleting"
@click="bulkDelete"
>
{{ bulkDeleting ? '...' : bulkConfirm ? `Delete ${selectedIds.size}?` : `Delete (${selectedIds.size})` }}
</button>
</div>
<!-- RAG project scope -->
<div class="rag-scope-section">
@@ -459,14 +526,22 @@ onUnmounted(() => {
v-for="conv in group.convs"
:key="conv.id"
class="conv-item"
:class="{ active: convId === conv.id }"
@click="selectConversation(conv.id)"
:class="{ active: convId === conv.id, selected: selectedIds.has(conv.id) }"
@click="selectMode ? toggleSelectConv(conv.id) : selectConversation(conv.id)"
>
<input
v-if="selectMode"
type="checkbox"
class="conv-checkbox"
:checked="selectedIds.has(conv.id)"
@click.stop="toggleSelectConv(conv.id)"
/>
<div class="conv-info">
<span class="conv-title">{{ conv.title || "Untitled" }}</span>
<span class="conv-date">{{ formatConvDate(conv.updated_at) }}</span>
</div>
<button
v-if="!selectMode"
class="btn-delete-conv"
@click.stop="removeConversation(conv.id)"
title="Delete conversation"
@@ -484,7 +559,7 @@ onUnmounted(() => {
<section class="chat-main">
<template v-if="store.currentConversation">
<div class="chat-header">
<button class="btn-sidebar-toggle hide-desktop" @click="toggleSidebar">
<button class="btn-sidebar-toggle hide-desktop" aria-label="Toggle sidebar" @click="toggleSidebar">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="3" y1="6" x2="21" y2="6"/>
<line x1="3" y1="12" x2="21" y2="12"/>
@@ -726,7 +801,7 @@ onUnmounted(() => {
</template>
<div v-else class="no-conversation">
<button class="btn-sidebar-toggle no-conv-toggle hide-desktop" @click="toggleSidebar">
<button class="btn-sidebar-toggle no-conv-toggle hide-desktop" aria-label="Toggle sidebar" @click="toggleSidebar">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="3" y1="6" x2="21" y2="6"/>
<line x1="3" y1="12" x2="21" y2="12"/>
@@ -758,8 +833,14 @@ onUnmounted(() => {
background: var(--color-bg-secondary);
}
.sidebar-top-bar {
display: flex;
gap: 0.5rem;
padding: 0.75rem;
}
.btn-new-conv {
margin: 0.75rem;
flex: 1;
padding: 0.5rem;
background: var(--color-primary);
color: #fff;
@@ -772,6 +853,75 @@ onUnmounted(() => {
opacity: 0.9;
}
.btn-select-mode {
background: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text-muted);
font-size: 0.82rem;
padding: 0.4rem 0.6rem;
cursor: pointer;
white-space: nowrap;
}
.btn-select-mode:hover,
.btn-select-mode.active { border-color: var(--color-primary); color: var(--color-primary); }
.bulk-bar {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.3rem 0.75rem;
background: color-mix(in srgb, var(--color-primary) 6%, var(--color-bg-secondary));
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
flex-wrap: wrap;
}
.bulk-count {
font-size: 0.75rem;
color: var(--color-text-muted);
flex-shrink: 0;
}
.bulk-link {
background: none;
border: none;
color: var(--color-primary);
font-size: 0.75rem;
cursor: pointer;
padding: 0;
text-decoration: underline;
}
.bulk-delete-btn {
margin-left: auto;
background: none;
border: 1px solid var(--color-danger, #e74c3c);
color: var(--color-danger, #e74c3c);
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
padding: 0.2rem 0.55rem;
cursor: pointer;
}
.bulk-delete-btn:disabled { opacity: 0.5; cursor: default; }
.bulk-delete-btn.confirm {
background: var(--color-danger, #e74c3c);
color: #fff;
}
.conv-checkbox {
flex-shrink: 0;
accent-color: var(--color-primary);
cursor: pointer;
width: 0.9rem;
height: 0.9rem;
}
.conv-item.selected {
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-bg-secondary));
}
.rag-scope-section {
padding: 0.5rem 0.75rem 0.25rem;
border-bottom: 1px solid var(--color-border);
+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>
+58 -15
View File
@@ -13,6 +13,7 @@ import { apiGet, apiPost } from "@/api/client";
import type { Editor } from "@tiptap/vue-3";
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
import TiptapEditor from "@/components/TiptapEditor.vue";
import WordCount from "@/components/WordCount.vue";
import TagInput from "@/components/TagInput.vue";
import ProjectSelector from "@/components/ProjectSelector.vue";
import MilestoneSelector from "@/components/MilestoneSelector.vue";
@@ -147,6 +148,8 @@ watch(body, () => {
if (projectId.value) scheduleLinkCheck();
});
watch(noteId, () => { showPreview.value = false; });
watch(projectId, (pid) => {
if (pid) fetchLinkSuggestions();
else linkSuggestions.value = [];
@@ -324,6 +327,7 @@ onUnmounted(() => assist.clearSelection());
</button>
<button v-if="isEditing" class="btn-delete" @click="remove">Delete</button>
<button v-if="isEditing" class="btn-history" @click="showHistory = true">History</button>
<WordCount :body="body" />
</div>
<input
ref="titleRef"
@@ -363,21 +367,23 @@ onUnmounted(() => assist.clearSelection());
<!-- Normal editor -->
<template v-else>
<div v-show="!showPreview" class="body-editor-wrap">
<TiptapEditor
ref="editorRef"
:modelValue="body"
placeholder="Write your note in Markdown..."
@update:modelValue="onBodyUpdate"
@selectionChange="onSelectionChange"
@escape="titleRef?.focus()"
<Transition name="tab-fade" mode="out-in">
<div v-if="!showPreview" class="body-editor-wrap">
<TiptapEditor
ref="editorRef"
:modelValue="body"
placeholder="Write your note in Markdown..."
@update:modelValue="onBodyUpdate"
@selectionChange="onSelectionChange"
@escape="titleRef?.focus()"
/>
</div>
<div
v-else
class="preview-pane prose"
v-html="renderedPreview"
/>
</div>
<div
v-show="showPreview"
class="preview-pane prose"
v-html="renderedPreview"
/>
</Transition>
</template>
<!-- Error from assist -->
@@ -431,7 +437,7 @@ onUnmounted(() => assist.clearSelection());
#{{ tag }}
<span v-if="appliedTags.has(tag)" class="tag-check">&#10003;</span>
</button>
<button class="btn-dismiss-tags" @click="dismissTagSuggestions">&times;</button>
<button class="btn-dismiss-tags" aria-label="Dismiss tag suggestions" @click="dismissTagSuggestions">&times;</button>
</template>
</div>
@@ -573,6 +579,43 @@ onUnmounted(() => assist.clearSelection());
gap: 0.25rem;
}
.editor-tabs {
display: inline-flex;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 2px;
gap: 2px;
align-self: flex-start;
}
.tab {
background: none;
border: none;
border-radius: 6px;
padding: 0.22rem 0.75rem;
font-size: 0.78rem;
font-weight: 500;
color: var(--color-text-muted);
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.tab:hover { color: var(--color-text); }
.tab.active {
background: var(--color-surface);
color: var(--color-text);
box-shadow: 0 1px 3px rgba(0,0,0,0.12);
}
.tab-fade-enter-active,
.tab-fade-leave-active {
transition: opacity 0.12s ease;
}
.tab-fade-enter-from,
.tab-fade-leave-to {
opacity: 0;
}
.body-editor-wrap {
min-height: 200px;
}
+80
View File
@@ -28,6 +28,19 @@ const exporting = ref(false);
const restoring = ref(false);
const restoreFileInput = ref<HTMLInputElement | null>(null);
// Chat retention
const chatRetentionDays = ref(90);
const savingRetention = ref(false);
async function saveRetention() {
savingRetention.value = true;
try {
await apiPut("/api/settings", { chat_retention_days: String(chatRetentionDays.value) });
} finally {
savingRetention.value = false;
}
}
// Notification preferences
const notifyTaskReminders = ref(true);
const notifySecurityAlerts = ref(true);
@@ -92,6 +105,9 @@ onMounted(async () => {
// Load notification preferences from user settings
const allSettings = await apiGet<Record<string, string>>("/api/settings");
defaultModel.value = allSettings.default_model ?? "";
chatRetentionDays.value = allSettings.chat_retention_days !== undefined
? Number(allSettings.chat_retention_days)
: 90;
if (allSettings.notify_task_reminders !== undefined) {
notifyTaskReminders.value = allSettings.notify_task_reminders !== "false";
}
@@ -234,6 +250,29 @@ async function exportData(scope: "user" | "full") {
}
}
const exportingNotes = ref(false);
async function exportNotes(format: "markdown" | "json") {
exportingNotes.value = true;
try {
const res = await fetch(`/api/export?format=${format}`);
if (!res.ok) throw new Error(`Error ${res.status}`);
const blob = await res.blob();
const ext = format === "json" ? "json" : "zip";
const stamp = new Date().toISOString().slice(0, 10);
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `fabledassistant-${stamp}.${ext}`;
a.click();
URL.revokeObjectURL(a.href);
toastStore.show("Export downloaded");
} catch (e) {
toastStore.show("Export failed: " + (e as Error).message, "error");
} finally {
exportingNotes.value = false;
}
}
function triggerRestoreUpload() {
restoreFileInput.value?.click();
}
@@ -561,11 +600,41 @@ function hostname(url: string): string {
</div>
</section>
<!-- Chat Retention half width -->
<section class="settings-section">
<h2>Chat History</h2>
<p class="section-desc">
Conversations older than this many days are automatically deleted when you load the chat.
Set to <strong>0</strong> to keep conversations forever.
</p>
<div class="field retention-field">
<label class="field-label">Retention period (days)</label>
<div class="retention-row">
<input
v-model.number="chatRetentionDays"
type="number"
min="0"
max="3650"
class="input retention-input"
/>
<button class="btn-primary" :disabled="savingRetention" @click="saveRetention">
{{ savingRetention ? 'Saving...' : 'Save' }}
</button>
</div>
</div>
</section>
<!-- Data half width -->
<section class="settings-section">
<h2>Data</h2>
<p class="section-desc">Export your data or restore from a backup.</p>
<div class="data-actions">
<button class="btn-secondary" @click="exportNotes('markdown')" :disabled="exportingNotes">
{{ exportingNotes ? "Exporting..." : "Export as Markdown" }}
</button>
<button class="btn-secondary" @click="exportNotes('json')" :disabled="exportingNotes">
{{ exportingNotes ? "Exporting..." : "Export as JSON" }}
</button>
<button class="btn-secondary" @click="exportData('user')" :disabled="exporting">
{{ exporting ? "Exporting..." : "Export My Data" }}
</button>
@@ -960,6 +1029,17 @@ function hostname(url: string): string {
color: var(--color-danger);
}
.retention-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.4rem;
}
.retention-input {
width: 6rem;
}
/* Data buttons */
.data-actions {
display: flex;
+4 -2
View File
@@ -15,6 +15,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 WordCount from "@/components/WordCount.vue";
import TagInput from "@/components/TagInput.vue";
import ProjectSelector from "@/components/ProjectSelector.vue";
import MilestoneSelector from "@/components/MilestoneSelector.vue";
@@ -388,6 +389,7 @@ useEditorGuards(dirty, save);
<button class="btn-assist-toggle" :class="{ active: assistOpen }" @click="toggleAssist">
Assist
</button>
<WordCount :body="body" />
</div>
<input
v-model="title"
@@ -579,7 +581,7 @@ useEditorGuards(dirty, save);
#{{ tag }}
<span v-if="appliedTags.has(tag)" class="tag-check">&#10003;</span>
</button>
<button class="btn-dismiss-tags" @click="dismissTagSuggestions">&times;</button>
<button class="btn-dismiss-tags" aria-label="Dismiss tag suggestions" @click="dismissTagSuggestions">&times;</button>
</template>
</div>
@@ -591,7 +593,7 @@ useEditorGuards(dirty, save);
<div class="assist-panel-header">
<span class="assist-panel-title"> AI Assist</span>
<button class="btn-proofread" @click="assist.proofread()" :disabled="assist.state.value === 'streaming'">Proofread</button>
<button class="btn-close-assist" @click="toggleAssist">×</button>
<button class="btn-close-assist" aria-label="Close assist panel" @click="toggleAssist">×</button>
</div>
<div class="assist-panel-body">
<div v-if="assist.state.value === 'idle'" class="assist-idle">