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:
@@ -293,6 +293,8 @@ onMounted(async () => {
|
||||
});
|
||||
|
||||
onUnmounted(() => { if (linkCheckTimer) clearTimeout(linkCheckTimer); });
|
||||
|
||||
defineExpose({ reload: loadProjectNotes });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user