feat: live card refresh, list checkboxes, minichat width cap

KnowledgeView:
- Watch streamingToolCalls; call fetchItems+fetchTags on create/update
  note or task so the card grid reflects changes made via the minichat
- Cap minichat to max-width: var(--page-max-width) so it matches chat column width

WorkspaceView + WorkspaceNoteEditor:
- Expose reload() from WorkspaceNoteEditor via defineExpose
- Call noteEditorRef.reload() alongside taskPanelRef.reload() when
  create_note/update_note tools succeed in the SSE watcher

KnowledgeView list cards:
- Backend: parse markdown task list into list_items [{text, checked}] + body
- Card renders up to 6 items with real checkboxes; toggleListItem()
  does an optimistic update then PATCHes /api/notes/:id
- Progress bar kept below items; "+N more" shown when list is long

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-01 21:30:28 -04:00
parent 23a7ed7822
commit 9549eb85bc
4 changed files with 129 additions and 10 deletions
@@ -293,6 +293,8 @@ onMounted(async () => {
});
onUnmounted(() => { if (linkCheckTimer) clearTimeout(linkCheckTimer); });
defineExpose({ reload: loadProjectNotes });
</script>
<template>
+113 -5
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from "vue";
import { useRouter } from "vue-router";
import { apiGet, transcribeAudio } from "@/api/client";
import { apiGet, apiPatch, transcribeAudio } from "@/api/client";
import { fmtCompact } from "@/utils/dateFormat";
import { useChatStore } from "@/stores/chat";
import { useSettingsStore } from "@/stores/settings";
@@ -32,6 +32,8 @@ interface KnowledgeItem {
hours?: string;
item_count?: number;
checked_count?: number;
list_items?: { text: string; checked: boolean }[];
body?: string;
}
interface UpcomingEvent {
@@ -195,8 +197,34 @@ function scrollChatToBottom() {
});
}
// ─── Auto-refresh cards when chat creates/edits notes or tasks ───────────────
const processedToolCalls = ref(0);
watch(
() => chatStore.streamingToolCalls,
(calls) => {
for (let i = processedToolCalls.value; i < calls.length; i++) {
const tc = calls[i];
if (
['create_note', 'update_note', 'create_task', 'update_task'].includes(tc.function) &&
tc.status === 'success'
) {
fetchItems(true);
fetchTags();
break;
}
}
processedToolCalls.value = calls.length;
},
{ deep: true }
);
watch(() => chatStore.streaming, (streaming) => {
if (!streaming) scrollChatToBottom();
if (!streaming) {
scrollChatToBottom();
processedToolCalls.value = 0;
}
});
async function closeChat() {
@@ -239,6 +267,40 @@ function chatAutoResize() {
el.style.height = Math.min(el.scrollHeight, 120) + "px";
}
// ─── List item toggle ─────────────────────────────────────────────────────────
async function toggleListItem(item: KnowledgeItem, index: number) {
if (!item.list_items || item.body === undefined) return;
// Optimistic update
const newChecked = !item.list_items[index].checked;
item.list_items[index].checked = newChecked;
item.checked_count = item.list_items.filter(i => i.checked).length;
// Rebuild the body by replacing the targeted checkbox line
let listIdx = 0;
const newBody = (item.body).split('\n').map(line => {
const stripped = line.trimStart();
if (stripped.startsWith('- [ ] ') || stripped.startsWith('- [x] ') || stripped.startsWith('- [X] ')) {
if (listIdx === index) {
const indent = line.length - stripped.length;
const newLine = (' '.repeat(indent)) + (newChecked ? '- [x] ' : '- [ ] ') + item.list_items![index].text;
listIdx++;
return newLine;
}
listIdx++;
}
return line;
}).join('\n');
item.body = newBody;
try {
await apiPatch(`/api/notes/${item.id}`, { body: newBody });
} catch {
fetchItems(true); // revert on error
}
}
// ─── Navigation helpers ───────────────────────────────────────────────────────
function openItem(item: KnowledgeItem) {
@@ -402,11 +464,25 @@ onUnmounted(() => {
</div>
<!-- List specifics -->
<div v-else-if="item.note_type === 'list'" class="k-card-meta">
<span class="list-progress">
<div v-else-if="item.note_type === 'list'" class="k-card-list" @click.stop>
<label
v-for="(li, idx) in (item.list_items ?? []).slice(0, 6)"
:key="idx"
class="list-item-row"
>
<input
type="checkbox"
:checked="li.checked"
@change="toggleListItem(item, idx)"
/>
<span :class="{ 'list-item-done': li.checked }">{{ li.text }}</span>
</label>
<div v-if="(item.list_items?.length ?? 0) > 6" class="list-item-more">
+{{ (item.list_items?.length ?? 0) - 6 }} more
</div>
<span class="list-progress" style="margin-top: 6px;">
<span class="list-progress-bar" :style="{ width: item.item_count ? `${Math.round(((item.checked_count ?? 0) / item.item_count) * 100)}%` : '0%' }"></span>
</span>
<span class="meta-muted">{{ item.checked_count ?? 0 }} / {{ item.item_count ?? 0 }} done</span>
</div>
<!-- Note snippet -->
@@ -820,6 +896,37 @@ onUnmounted(() => {
}
.meta-muted { color: var(--color-muted); }
/* List card items */
.k-card-list {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 2px;
}
.list-item-row {
display: flex;
align-items: baseline;
gap: 6px;
font-size: 0.82rem;
color: var(--color-text);
cursor: pointer;
line-height: 1.4;
}
.list-item-row input[type="checkbox"] {
flex-shrink: 0;
accent-color: var(--color-primary);
cursor: pointer;
}
.list-item-done {
text-decoration: line-through;
color: var(--color-text-muted);
}
.list-item-more {
font-size: 0.75rem;
color: var(--color-text-muted);
margin-top: 2px;
}
/* List progress bar */
.list-progress {
display: block;
@@ -935,6 +1042,7 @@ onUnmounted(() => {
bottom: 0;
left: var(--sidebar-width); /* align with content area past filter panel */
right: 0;
max-width: var(--page-max-width);
z-index: 20;
display: flex;
flex-direction: column;
+3
View File
@@ -30,6 +30,7 @@ const messageInput = ref("");
const messagesEl = ref<HTMLElement | null>(null);
const inputEl = ref<HTMLTextAreaElement | null>(null);
const taskPanelRef = ref<InstanceType<typeof WorkspaceTaskPanel> | null>(null);
const noteEditorRef = ref<InstanceType<typeof WorkspaceNoteEditor> | null>(null);
const activeNoteId = ref<number | null>(null);
let workspaceConvId: number | null = null;
let isNewConv = false;
@@ -78,6 +79,7 @@ watch(
tc.result?.data?.id
) {
activeNoteId.value = tc.result.data.id as number;
noteEditorRef.value?.reload();
}
if (
["create_task", "update_task", "create_milestone", "update_milestone"].includes(tc.function) &&
@@ -395,6 +397,7 @@ onUnmounted(async () => {
<div v-if="panelOpen.notes" class="panel-inner">
<WorkspaceNoteEditor
v-if="project"
ref="noteEditorRef"
:project-id="project.id"
:active-note-id="activeNoteId"
/>
+11 -5
View File
@@ -34,12 +34,18 @@ def _note_to_item(note: Note) -> dict:
item["phone"] = meta.get("phone", "")
item["hours"] = meta.get("hours", "")
elif note.entity_type == "list":
# Count checked / total items from markdown task list syntax
# Parse markdown task list syntax into structured items
body = note.body or ""
total = body.count("- [ ]") + body.count("- [x]") + body.count("- [X]")
checked = body.count("- [x]") + body.count("- [X]")
item["item_count"] = total
item["checked_count"] = checked
list_items = []
for line in body.split("\n"):
stripped = line.strip()
if stripped.startswith("- [ ] ") or stripped.startswith("- [x] ") or stripped.startswith("- [X] "):
checked_item = not stripped.startswith("- [ ] ")
list_items.append({"text": stripped[6:], "checked": checked_item})
item["list_items"] = list_items
item["item_count"] = len(list_items)
item["checked_count"] = sum(1 for i in list_items if i["checked"])
item["body"] = body
return item