Add persistent context sidebar, note title fix, and expanded tool suite

Context sidebar + note title:
- ChatView: replace ephemeral context pills with a persistent right-panel sidebar;
  auto-found notes accumulate across turns; attached note shows with pin icon;
  × button excludes a note from future auto-search; hidden on mobile
- routes/chat.py: batch-fetch note titles via get_notes_by_ids() and inject
  context_note_title into each message dict at conversation load time
- notes.py: add get_notes_by_ids() batch fetch helper
- types/chat.ts: add context_note_title field to Message interface
- stores/chat.ts: sendMessage accepts optional 5th arg contextNoteTitle,
  included in optimistic user message
- ChatMessage.vue: context badge shows note title instead of 'Note #N'

Expanded LLM tool suite (all with intent router rules + ToolCallCard display):
- delete_note / delete_task: permanent delete with user confirmation (write tool),
  type-safe (refuse to delete wrong type), clears note context cache on success
- get_note: fetch full note body by query (search_notes returns only 200-char preview)
- list_notes: browse notes by recency/keyword/tags with limit; notes only
- update_note: add tags + tag_mode (replace/add/remove) parameters
- search_notes: add optional type filter ("note" | "task")
- search_todos (CalDAV): keyword-filter todos, companion to list_todos
- caldav.py: add search_todos() built on top of list_todos()
- generation_task.py: register new tools in _WRITE_TOOLS, _TOOL_LABELS, _TOOL_ACTIONS
- llm.py: update available actions list and guidance in system prompt
- intent.py: routing rules for all new tools

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-19 14:40:34 -05:00
parent d6f4a6dbb6
commit 32e4ee12f2
13 changed files with 578 additions and 187 deletions
+1 -1
View File
@@ -81,7 +81,7 @@ const timingParts = computed((): string[] => {
class="context-badge"
>
<router-link :to="`/notes/${message.context_note_id}`">
Note #{{ message.context_note_id }}
{{ message.context_note_title || `Note #${message.context_note_id}` }}
</router-link>
</div>
</div>
+68
View File
@@ -20,6 +20,14 @@ const label = computed(() => {
return "Created note";
case "note_updated":
return "Updated note";
case "note_deleted":
return "Deleted note";
case "task_deleted":
return "Deleted task";
case "note_content":
return "Note";
case "notes_list":
return "Notes";
case "tasks":
return "Tasks";
case "todo_updated":
@@ -166,6 +174,32 @@ const searchResults = computed(() => {
return results && results.length > 0 ? results : null;
});
const deletedNote = computed(() => {
const data = props.toolCall.result.data;
const type = props.toolCall.result.type;
if (!data || (type !== "note_deleted" && type !== "task_deleted")) return null;
return data as { id: number; title: string };
});
const noteContent = computed(() => {
const data = props.toolCall.result.data;
if (!data || props.toolCall.result.type !== "note_content") return null;
return data as { id: number; title: string; body: string; tags: string[] };
});
const noteList = computed(() => {
const data = props.toolCall.result.data;
if (!data || props.toolCall.result.type !== "notes_list") return null;
const results = data.results as Array<{ id: number; title: string; tags: string[]; preview: string }> | undefined;
return results ?? [];
});
const noteListCount = computed(() => {
const data = props.toolCall.result.data;
if (!data || props.toolCall.result.type !== "notes_list") return 0;
return (data.total as number) ?? 0;
});
async function applyTag(tag: string) {
if (!noteId.value || appliedTags.value.has(tag) || applyingTag.value) return;
applyingTag.value = tag;
@@ -189,6 +223,30 @@ async function applyTag(tag: string) {
<template v-else-if="toolCall.status === 'error'">
<span class="tool-error">{{ toolCall.result.error }}</span>
</template>
<template v-else-if="deletedNote">
<span class="tool-deleted">{{ deletedNote.title || "Untitled" }}</span>
</template>
<template v-else-if="noteContent">
<router-link :to="`/notes/${noteContent.id}`" class="tool-link">{{ noteContent.title || "Untitled" }}</router-link>
<span v-if="noteContent.tags.length" class="tool-note-tags">
<span v-for="tag in noteContent.tags" :key="tag" class="tool-note-tag">#{{ tag }}</span>
</span>
</template>
<template v-else-if="noteList !== null">
<span class="tool-search-info">{{ noteListCount }} found</span>
<div v-if="noteList.length > 0" class="tool-search-results">
<router-link
v-for="n in noteList.slice(0, 8)"
:key="n.id"
:to="`/notes/${n.id}`"
class="tool-search-item"
>
{{ n.title || "Untitled" }}
</router-link>
<span v-if="noteList.length > 8" class="tool-event-more">+{{ noteList.length - 8 }} more</span>
</div>
<span v-else class="tool-search-info">No notes found</span>
</template>
<template v-else-if="searchResults">
<span class="tool-search-info">{{ (toolCall.result.data?.total as number) ?? 0 }} found</span>
<div class="tool-search-results">
@@ -380,6 +438,16 @@ async function applyTag(tag: string) {
text-decoration: line-through;
opacity: 0.7;
}
.tool-note-tags {
display: flex;
flex-wrap: wrap;
gap: 0.2rem;
width: 100%;
}
.tool-note-tag {
font-size: 0.72rem;
color: var(--color-text-muted);
}
.tool-task-priority {
font-size: 0.7rem;
font-weight: 600;
+2
View File
@@ -121,6 +121,7 @@ export const useChatStore = defineStore("chat", () => {
contextNoteId?: number | null,
excludeNoteIds?: number[],
think = false,
contextNoteTitle?: string,
) {
if (!currentConversation.value) return;
@@ -150,6 +151,7 @@ export const useChatStore = defineStore("chat", () => {
role: "user",
content,
context_note_id: contextNoteId ?? null,
context_note_title: contextNoteTitle ?? null,
created_at: new Date().toISOString(),
};
currentConversation.value.messages.push(userMsg);
+1
View File
@@ -26,6 +26,7 @@ export interface Message {
content: string;
status?: "complete" | "generating" | "error";
context_note_id: number | null;
context_note_title?: string | null;
tool_calls?: ToolCallRecord[] | null;
created_at: string;
timing?: GenerationTiming;
+156 -165
View File
@@ -33,6 +33,9 @@ let noteSearchTimer: ReturnType<typeof setTimeout> | null = null;
// Exclude tracking (session-scoped per conversation)
const excludedNoteIds = ref<Set<number>>(new Set());
// Persistent context notes — populated as assistant responds, cleared on conv change
const contextNotes = ref<{ id: number; title: string }[]>([]);
let prevConvId: number | null = null;
const convId = computed(() => {
@@ -89,6 +92,7 @@ watch(convId, async (newId) => {
prevConvId = newId ?? null;
excludedNoteIds.value = new Set();
contextNotes.value = [];
attachedNote.value = null;
store.lastContextMeta = null;
if (newId) {
@@ -109,6 +113,21 @@ watch(
() => scrollToBottom()
);
watch(
() => store.lastContextMeta?.auto_notes,
(newNotes) => {
if (!newNotes) return;
const excluded = excludedNoteIds.value;
const existing = new Set(contextNotes.value.map((n) => n.id));
for (const note of newNotes) {
if (!excluded.has(note.id) && !existing.has(note.id)) {
contextNotes.value.push(note);
existing.add(note.id);
}
}
}
);
function scrollToBottom() {
nextTick(() => {
if (messagesEl.value) {
@@ -152,6 +171,7 @@ async function sendMessage() {
sending.value = true;
const contextNoteId = attachedNote.value?.id ?? undefined;
const contextNoteTitle = attachedNote.value?.title ?? undefined;
attachedNote.value = null;
messageInput.value = "";
resetTextareaHeight();
@@ -162,6 +182,7 @@ async function sendMessage() {
contextNoteId,
excludedNoteIds.value.size ? [...excludedNoteIds.value] : undefined,
true, // enable thinking in the full chat view
contextNoteTitle,
);
sending.value = false;
@@ -261,12 +282,9 @@ function removeAttachedNote() {
attachedNote.value = null;
}
function promoteAutoNote(note: { id: number; title: string }) {
attachedNote.value = note;
}
function excludeAutoNote(noteId: number) {
excludedNoteIds.value = new Set([...excludedNoteIds.value, noteId]);
contextNotes.value = contextNotes.value.filter((n) => n.id !== noteId);
}
// Keyboard shortcuts
@@ -355,88 +373,77 @@ onUnmounted(() => {
</button>
</div>
<div ref="messagesEl" class="messages-container">
<div class="messages-inner">
<ChatMessage
v-for="msg in store.currentConversation.messages"
:key="msg.id"
:message="msg"
@save-as-note="handleSaveAsNote"
/>
<div class="chat-body">
<div ref="messagesEl" class="messages-container">
<div class="messages-inner">
<ChatMessage
v-for="msg in store.currentConversation.messages"
:key="msg.id"
:message="msg"
@save-as-note="handleSaveAsNote"
/>
<!-- Context pills (auto-found notes) -->
<div
v-if="store.lastContextMeta?.auto_notes?.length && (store.streaming || store.currentConversation.messages.length)"
class="context-pills"
>
<span class="context-pills-label">Context:</span>
<span
v-for="note in store.lastContextMeta.auto_notes"
:key="note.id"
class="context-pill"
>
<router-link :to="`/notes/${note.id}`" class="context-pill-link">
{{ note.title }}
</router-link>
<button
class="context-pill-btn promote"
@click="promoteAutoNote(note)"
title="Attach this note for next message"
>+</button>
<button
class="context-pill-btn exclude"
@click="excludeAutoNote(note.id)"
title="Exclude from auto-search"
>&times;</button>
</span>
</div>
<!-- Streaming message (assistant typing) -->
<div v-if="store.streaming" class="chat-message role-assistant">
<div class="message-bubble streaming-bubble">
<div class="message-header">
<span class="role-label">{{ settingsStore.assistantName }}</span>
</div>
<div v-if="store.streamingToolCalls.length" class="streaming-tool-calls">
<ToolCallCard
v-for="(tc, i) in store.streamingToolCalls"
:key="i"
:tool-call="tc"
<!-- Streaming message (assistant typing) -->
<div v-if="store.streaming" class="chat-message role-assistant">
<div class="message-bubble streaming-bubble">
<div class="message-header">
<span class="role-label">{{ settingsStore.assistantName }}</span>
</div>
<div v-if="store.streamingToolCalls.length" class="streaming-tool-calls">
<ToolCallCard
v-for="(tc, i) in store.streamingToolCalls"
:key="i"
:tool-call="tc"
/>
</div>
<ToolConfirmCard
v-if="store.streamingPendingTool"
:pending-tool="store.streamingPendingTool"
@accept="store.confirmTool(true)"
@decline="store.confirmTool(false)"
/>
<div v-if="store.streamingStatus" class="streaming-status-line">
<span class="streaming-status-dot"></span>
{{ store.streamingStatus }}
</div>
<div class="message-content prose" v-html="streamingRendered"></div>
<span v-if="!store.streamingStatus" class="typing-indicator"></span>
</div>
<ToolConfirmCard
v-if="store.streamingPendingTool"
:pending-tool="store.streamingPendingTool"
@accept="store.confirmTool(true)"
@decline="store.confirmTool(false)"
/>
<div v-if="store.streamingStatus" class="streaming-status-line">
<span class="streaming-status-dot"></span>
{{ store.streamingStatus }}
</div>
<div class="message-content prose" v-html="streamingRendered"></div>
<span v-if="!store.streamingStatus" class="typing-indicator"></span>
</div>
<p
v-if="!store.currentConversation.messages.length && !store.streaming"
class="empty-msg"
>
Send a message to start the conversation.
</p>
</div>
</div>
<!-- Persistent context sidebar -->
<aside v-if="contextNotes.length || attachedNote" class="context-sidebar">
<div class="context-sidebar-header">In Context</div>
<!-- Manually attached note pinned until sent -->
<div v-if="attachedNote" class="context-note context-note-pinned">
<span class="context-note-icon" title="Attached for this message">📌</span>
<router-link :to="`/notes/${attachedNote.id}`" class="context-note-name">
{{ attachedNote.title }}
</router-link>
<button class="context-note-remove" @click="removeAttachedNote" title="Remove">&times;</button>
</div>
<p
v-if="!store.currentConversation.messages.length && !store.streaming"
class="empty-msg"
>
Send a message to start the conversation.
</p>
</div>
<!-- Auto-found notes persist across turns -->
<div v-for="note in contextNotes" :key="note.id" class="context-note">
<router-link :to="`/notes/${note.id}`" class="context-note-name">
{{ note.title }}
</router-link>
<button class="context-note-remove" @click="excludeAutoNote(note.id)" title="Remove from context">&times;</button>
</div>
</aside>
</div>
<div class="input-wrapper">
<!-- Attached note pill -->
<div v-if="attachedNote" class="attached-note">
<span class="attached-note-pill">
{{ attachedNote.title }}
<button class="attached-note-remove" @click="removeAttachedNote">&times;</button>
</span>
</div>
<div class="input-area">
<!-- Note picker button -->
<div class="note-picker-wrapper">
@@ -656,6 +663,13 @@ onUnmounted(() => {
cursor: default;
}
.chat-body {
flex: 1;
display: flex;
min-height: 0;
overflow: hidden;
}
.messages-container {
flex: 1;
overflow-y: auto;
@@ -666,6 +680,68 @@ onUnmounted(() => {
margin: 0 auto;
width: 100%;
}
.context-sidebar {
width: 220px;
min-width: 180px;
border-left: 1px solid var(--color-border);
background: var(--color-bg-secondary);
display: flex;
flex-direction: column;
padding: 0.75rem;
gap: 0.35rem;
overflow-y: auto;
}
.context-sidebar-header {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
margin-bottom: 0.25rem;
}
.context-note {
display: flex;
align-items: center;
gap: 0.3rem;
padding: 0.3rem 0.4rem;
border-radius: var(--radius-sm);
background: var(--color-bg-card);
border: 1px solid var(--color-border);
font-size: 0.82rem;
}
.context-note-pinned {
border-color: var(--color-primary);
}
.context-note-icon {
font-size: 0.75rem;
flex-shrink: 0;
}
.context-note-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--color-text);
text-decoration: none;
}
.context-note-name:hover {
color: var(--color-primary);
}
.context-note-remove {
background: none;
border: none;
cursor: pointer;
color: var(--color-text-muted);
font-size: 1rem;
line-height: 1;
padding: 0 0.1rem;
flex-shrink: 0;
}
.context-note-remove:hover {
color: var(--color-danger, #e74c3c);
}
.messages-inner {
margin-top: auto;
}
@@ -743,67 +819,6 @@ onUnmounted(() => {
50% { opacity: 1; }
}
/* Context pills */
.context-pills {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem;
padding: 0.5rem 0;
}
.context-pills-label {
font-size: 0.75rem;
color: var(--color-text-muted);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.context-pill {
display: inline-flex;
align-items: center;
gap: 0.15rem;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: 12px;
padding: 0.15rem 0.35rem 0.15rem 0.5rem;
font-size: 0.8rem;
}
.context-pill-link {
color: var(--color-primary);
text-decoration: none;
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.context-pill-link:hover {
text-decoration: underline;
}
.context-pill-btn {
background: none;
border: none;
cursor: pointer;
font-size: 0.85rem;
line-height: 1;
padding: 0 0.15rem;
color: var(--color-text-muted);
border-radius: 50%;
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
}
.context-pill-btn:hover {
background: var(--color-border);
}
.context-pill-btn.promote:hover {
color: var(--color-primary);
}
.context-pill-btn.exclude:hover {
color: var(--color-danger, #e74c3c);
}
/* Input wrapper */
.input-wrapper {
max-width: 960px;
@@ -813,33 +828,6 @@ onUnmounted(() => {
box-sizing: border-box;
}
/* Attached note */
.attached-note {
padding: 0.25rem 0.5rem;
}
.attached-note-pill {
display: inline-flex;
align-items: center;
gap: 0.25rem;
background: var(--color-primary);
color: #fff;
border-radius: 12px;
padding: 0.2rem 0.5rem;
font-size: 0.8rem;
}
.attached-note-remove {
background: none;
border: none;
color: rgba(255, 255, 255, 0.7);
cursor: pointer;
font-size: 1rem;
line-height: 1;
padding: 0 0.15rem;
}
.attached-note-remove:hover {
color: #fff;
}
/* Floating input bar */
.input-area {
display: flex;
@@ -1038,6 +1026,9 @@ onUnmounted(() => {
background: var(--color-overlay);
z-index: 99;
}
.context-sidebar {
display: none;
}
.messages-container {
padding: 0.75rem;
}