Phase 10: CalDAV full lifecycle, update_note, dashboard inline streaming, keyboard shortcuts

Backend:
- caldav.py: Full event lifecycle — update_event, delete_event; VTODO suite —
  create_todo, list_todos, complete_todo, delete_todo; list_calendars; timezone
  support via ZoneInfo; reminders via VALARM; attendees; multi-calendar search
  (_get_all_calendars scans all calendars when no specific one is configured)
- tools.py: New update_note tool (find by title + replace/append modes),
  7 new CalDAV tool definitions, corresponding execute_tool cases
- llm.py: Update system prompt — add update_note guidance, full CalDAV action list
- intent.py: Confidence scoring (high/medium/low) + should_execute property;
  conversation history support for anaphora resolution; routing rules for
  update/delete events, todos, update_note vs create_note disambiguation,
  time-period → list_events (not search_events), reminder_minutes conversion
- generation_task.py: Parallel fetch of tools + intent_model setting; dedicated
  intent model (OLLAMA_INTENT_MODEL env var or per-user intent_model setting)
- config.py: Add OLLAMA_INTENT_MODEL env var

Frontend:
- HomeView.vue: Inline streaming response (no navigation); quick action chips;
  isConversational computed — prominent "Continue this conversation" CTA when
  no tool calls; auto-focus chat input on mount via chatInputRef
- DashboardChatInput.vue: defineExpose({ focus }) for external focus control
- ChatView.vue: Escape key handler — close picker → close sidebar → clear
  textarea → navigate home; onUnmounted cleanup
- App.vue: Global ? key shortcut toggles keyboard shortcuts overlay; shared
  state via useShortcuts composable; Transition animation
- AppHeader.vue: ? button for shortcuts overlay discoverability
- useShortcuts.ts (new): Shared showShortcuts ref + open/close/toggle helpers
- ToolCallCard.vue: note_updated, event_updated, event_deleted, calendars,
  todo, todos, todo_completed, todo_deleted label cases + render blocks
- SettingsView.vue: Intent model field + caldav_timezone setting

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-17 22:04:41 -05:00
parent 75560dee4e
commit 70cba72a80
15 changed files with 1569 additions and 71 deletions
+20
View File
@@ -2,11 +2,13 @@
import { ref, computed } from "vue";
import { useRouter } from "vue-router";
import { useTheme } from "@/composables/useTheme";
import { useShortcuts } from "@/composables/useShortcuts";
import { useAuthStore } from "@/stores/auth";
import { useChatStore } from "@/stores/chat";
import AppLogo from "@/components/AppLogo.vue";
const { theme, toggleTheme } = useTheme();
const { toggleShortcuts } = useShortcuts();
const authStore = useAuthStore();
const chatStore = useChatStore();
const router = useRouter();
@@ -66,6 +68,9 @@ router.afterEach(() => {
<span class="status-indicator" :class="statusClass" :title="statusLabel">
<span class="status-dot"></span>
</span>
<button class="btn-shortcuts" @click="toggleShortcuts" title="Keyboard shortcuts (?)">
?
</button>
<button class="theme-toggle" @click="toggleTheme" :title="`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`">
{{ theme === "dark" ? "\u2600" : "\u263E" }}
</button>
@@ -164,6 +169,21 @@ router.afterEach(() => {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.btn-shortcuts {
background: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 0.25rem 0.55rem;
cursor: pointer;
font-size: 0.9rem;
font-weight: 700;
color: var(--color-text-muted);
line-height: 1;
}
.btn-shortcuts:hover {
background: var(--color-bg-card);
color: var(--color-text);
}
.theme-toggle {
background: none;
border: 1px solid var(--color-border);
@@ -101,6 +101,12 @@ function selectNote(note: { id: number; title: string }) {
function removeAttachedNote() {
attachedNote.value = null;
}
function focus() {
inputEl.value?.focus();
}
defineExpose({ focus });
</script>
<template>
+107
View File
@@ -17,12 +17,28 @@ const label = computed(() => {
return "Created task";
case "note":
return "Created note";
case "note_updated":
return "Updated note";
case "search":
return "Searched notes";
case "event":
return "Created event";
case "events":
return "Found events";
case "event_updated":
return "Updated event";
case "event_deleted":
return "Deleted event";
case "calendars":
return "Calendars";
case "todo":
return "Created todo";
case "todos":
return "Calendar todos";
case "todo_completed":
return "Completed todo";
case "todo_deleted":
return "Deleted todo";
default:
return "Tool call";
}
@@ -57,6 +73,45 @@ const eventData = computed(() => {
return data as { title: string; start: string; end: string };
});
const updatedEvent = computed(() => {
const data = props.toolCall.result.data;
if (!data || props.toolCall.result.type !== "event_updated") return null;
return data as { title: string; start: string; end: string };
});
const deletedEvent = computed(() => {
const data = props.toolCall.result.data;
if (!data || props.toolCall.result.type !== "event_deleted") return null;
return data as { title: string };
});
const calendarList = computed(() => {
const data = props.toolCall.result.data;
if (!data || props.toolCall.result.type !== "calendars") return null;
const calendars = data.calendars as Array<{ name: string }> | undefined;
return calendars?.map((c) => c.name) ?? [];
});
const todoData = computed(() => {
const data = props.toolCall.result.data;
const type = props.toolCall.result.type;
if (!data || (type !== "todo" && type !== "todo_completed" && type !== "todo_deleted")) return null;
return data as { summary: string; due?: string; status?: string };
});
const todoList = computed(() => {
const data = props.toolCall.result.data;
if (!data || props.toolCall.result.type !== "todos") return null;
const todos = data.todos as Array<{ summary: string; due?: string; status?: string }> | undefined;
return todos ?? [];
});
const todoCount = computed(() => {
const data = props.toolCall.result.data;
if (!data || props.toolCall.result.type !== "todos") return 0;
return (data.count as number) ?? 0;
});
const eventList = computed(() => {
const data = props.toolCall.result.data;
if (!data || props.toolCall.result.type !== "events") return null;
@@ -130,6 +185,36 @@ async function applyTag(tag: string) {
<span class="tool-event-title">{{ eventData.title }}</span>
<span class="tool-event-time">{{ formatEventTime(eventData.start) }}</span>
</template>
<template v-else-if="updatedEvent">
<span class="tool-event-title">{{ updatedEvent.title }}</span>
<span class="tool-event-time">{{ formatEventTime(updatedEvent.start) }}</span>
</template>
<template v-else-if="deletedEvent">
<span class="tool-deleted">{{ deletedEvent.title }}</span>
</template>
<template v-else-if="calendarList !== null">
<div class="tool-calendar-list">
<span v-for="name in calendarList" :key="name" class="tool-calendar-name">{{ name }}</span>
</div>
</template>
<template v-else-if="todoData">
<span :class="{ 'tool-deleted': toolCall.result.type === 'todo_deleted', 'tool-completed': toolCall.result.type === 'todo_completed' }">
{{ todoData.summary }}
</span>
<span v-if="todoData.due" class="tool-event-time">{{ formatEventTime(todoData.due) }}</span>
</template>
<template v-else-if="todoList !== null">
<span class="tool-search-info">{{ todoCount }} found</span>
<div v-if="todoList.length > 0" class="tool-event-list">
<div v-for="(t, i) in todoList.slice(0, 5)" :key="i" class="tool-event-item">
<span class="tool-event-item-title">{{ t.summary }}</span>
<span v-if="t.due" class="tool-event-item-time">{{ formatEventTime(t.due) }}</span>
</div>
<div v-if="todoList.length > 5" class="tool-event-more">
+{{ todoList.length - 5 }} more
</div>
</div>
</template>
<template v-else-if="eventList !== null">
<span class="tool-search-info">{{ eventCount }} found</span>
<div v-if="eventList.length > 0" class="tool-event-list">
@@ -252,6 +337,28 @@ async function applyTag(tag: string) {
font-size: 0.75rem;
font-style: italic;
}
.tool-deleted {
text-decoration: line-through;
opacity: 0.7;
}
.tool-completed {
text-decoration: line-through;
color: var(--color-success, #2ecc71);
}
.tool-calendar-list {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.tool-calendar-name {
display: inline-block;
padding: 0.1rem 0.5rem;
border-radius: 999px;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
font-size: 0.75rem;
color: var(--color-text);
}
/* Tag suggestions */
.tag-suggestions {