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); });
|
onUnmounted(() => { if (linkCheckTimer) clearTimeout(linkCheckTimer); });
|
||||||
|
|
||||||
|
defineExpose({ reload: loadProjectNotes });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from "vue";
|
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import { apiGet, transcribeAudio } from "@/api/client";
|
import { apiGet, apiPatch, transcribeAudio } from "@/api/client";
|
||||||
import { fmtCompact } from "@/utils/dateFormat";
|
import { fmtCompact } from "@/utils/dateFormat";
|
||||||
import { useChatStore } from "@/stores/chat";
|
import { useChatStore } from "@/stores/chat";
|
||||||
import { useSettingsStore } from "@/stores/settings";
|
import { useSettingsStore } from "@/stores/settings";
|
||||||
@@ -32,6 +32,8 @@ interface KnowledgeItem {
|
|||||||
hours?: string;
|
hours?: string;
|
||||||
item_count?: number;
|
item_count?: number;
|
||||||
checked_count?: number;
|
checked_count?: number;
|
||||||
|
list_items?: { text: string; checked: boolean }[];
|
||||||
|
body?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UpcomingEvent {
|
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) => {
|
watch(() => chatStore.streaming, (streaming) => {
|
||||||
if (!streaming) scrollChatToBottom();
|
if (!streaming) {
|
||||||
|
scrollChatToBottom();
|
||||||
|
processedToolCalls.value = 0;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function closeChat() {
|
async function closeChat() {
|
||||||
@@ -239,6 +267,40 @@ function chatAutoResize() {
|
|||||||
el.style.height = Math.min(el.scrollHeight, 120) + "px";
|
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 ───────────────────────────────────────────────────────
|
// ─── Navigation helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
function openItem(item: KnowledgeItem) {
|
function openItem(item: KnowledgeItem) {
|
||||||
@@ -402,11 +464,25 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- List specifics -->
|
<!-- List specifics -->
|
||||||
<div v-else-if="item.note_type === 'list'" class="k-card-meta">
|
<div v-else-if="item.note_type === 'list'" class="k-card-list" @click.stop>
|
||||||
<span class="list-progress">
|
<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 class="list-progress-bar" :style="{ width: item.item_count ? `${Math.round(((item.checked_count ?? 0) / item.item_count) * 100)}%` : '0%' }"></span>
|
||||||
</span>
|
</span>
|
||||||
<span class="meta-muted">{{ item.checked_count ?? 0 }} / {{ item.item_count ?? 0 }} done</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Note snippet -->
|
<!-- Note snippet -->
|
||||||
@@ -820,6 +896,37 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
.meta-muted { color: var(--color-muted); }
|
.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 bar */
|
||||||
.list-progress {
|
.list-progress {
|
||||||
display: block;
|
display: block;
|
||||||
@@ -935,6 +1042,7 @@ onUnmounted(() => {
|
|||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: var(--sidebar-width); /* align with content area past filter panel */
|
left: var(--sidebar-width); /* align with content area past filter panel */
|
||||||
right: 0;
|
right: 0;
|
||||||
|
max-width: var(--page-max-width);
|
||||||
z-index: 20;
|
z-index: 20;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ const messageInput = ref("");
|
|||||||
const messagesEl = ref<HTMLElement | null>(null);
|
const messagesEl = ref<HTMLElement | null>(null);
|
||||||
const inputEl = ref<HTMLTextAreaElement | null>(null);
|
const inputEl = ref<HTMLTextAreaElement | null>(null);
|
||||||
const taskPanelRef = ref<InstanceType<typeof WorkspaceTaskPanel> | null>(null);
|
const taskPanelRef = ref<InstanceType<typeof WorkspaceTaskPanel> | null>(null);
|
||||||
|
const noteEditorRef = ref<InstanceType<typeof WorkspaceNoteEditor> | null>(null);
|
||||||
const activeNoteId = ref<number | null>(null);
|
const activeNoteId = ref<number | null>(null);
|
||||||
let workspaceConvId: number | null = null;
|
let workspaceConvId: number | null = null;
|
||||||
let isNewConv = false;
|
let isNewConv = false;
|
||||||
@@ -78,6 +79,7 @@ watch(
|
|||||||
tc.result?.data?.id
|
tc.result?.data?.id
|
||||||
) {
|
) {
|
||||||
activeNoteId.value = tc.result.data.id as number;
|
activeNoteId.value = tc.result.data.id as number;
|
||||||
|
noteEditorRef.value?.reload();
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
["create_task", "update_task", "create_milestone", "update_milestone"].includes(tc.function) &&
|
["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">
|
<div v-if="panelOpen.notes" class="panel-inner">
|
||||||
<WorkspaceNoteEditor
|
<WorkspaceNoteEditor
|
||||||
v-if="project"
|
v-if="project"
|
||||||
|
ref="noteEditorRef"
|
||||||
:project-id="project.id"
|
:project-id="project.id"
|
||||||
:active-note-id="activeNoteId"
|
:active-note-id="activeNoteId"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -34,12 +34,18 @@ def _note_to_item(note: Note) -> dict:
|
|||||||
item["phone"] = meta.get("phone", "")
|
item["phone"] = meta.get("phone", "")
|
||||||
item["hours"] = meta.get("hours", "")
|
item["hours"] = meta.get("hours", "")
|
||||||
elif note.entity_type == "list":
|
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 ""
|
body = note.body or ""
|
||||||
total = body.count("- [ ]") + body.count("- [x]") + body.count("- [X]")
|
list_items = []
|
||||||
checked = body.count("- [x]") + body.count("- [X]")
|
for line in body.split("\n"):
|
||||||
item["item_count"] = total
|
stripped = line.strip()
|
||||||
item["checked_count"] = checked
|
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
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user