Workspace polish: milestone selector, streaming fix, push fix, log quieting

- WorkspaceTaskPanel: add milestone <select> in task detail (PATCH /api/notes/:id),
  replace delete ✕ with trash can SVG icon
- WorkspaceView: scroll to bottom when streaming ends so final message is visible
  without a page refresh
- ToolCallCard: fix search_notes result count (was reading data.total; tool returns
  data.count), so results no longer show "0 found"
- push.py: switch from deprecated WebPusher().send(vapid_private_key=...) to
  webpush() function (pywebpush 2.x API compatibility)
- app.py: downgrade /api/health, /api/chat/status, and static asset requests from
  INFO to DEBUG in after_request logger to reduce log noise at default log level

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-07 12:58:09 -05:00
parent 74ebb8a87f
commit 320e879788
5 changed files with 58 additions and 9 deletions
+1 -1
View File
@@ -262,7 +262,7 @@ async function applyTag(tag: string) {
<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>
<span class="tool-search-info">{{ (toolCall.result.data?.count as number) ?? (toolCall.result.data?.total as number) ?? 0 }} found</span>
<div class="tool-search-results">
<router-link
v-for="r in searchResults"
+40 -1
View File
@@ -127,6 +127,23 @@ async function addTask() {
const deletingTask = ref(false);
const deleteConfirmPending = ref(false);
const changingMilestone = ref(false);
async function setMilestone(milestoneId: number | null) {
if (!activeTask.value) return;
changingMilestone.value = true;
try {
await apiPatch(`/api/notes/${activeTask.value.id}`, { milestone_id: milestoneId });
activeTask.value.milestone_id = milestoneId;
// Update in tasks list too
const t = tasks.value.find((x) => x.id === activeTask.value!.id);
if (t) t.milestone_id = milestoneId;
} catch {
toast.show("Failed to update milestone", "error");
} finally {
changingMilestone.value = false;
}
}
async function deleteActiveTask() {
if (!activeTask.value) return;
@@ -192,7 +209,7 @@ defineExpose({ reload: loadAll });
<button class="btn-delete-cancel" @click="cancelDeleteTask"></button>
</template>
<button v-else class="btn-delete-task" title="Delete task" @click="deleteActiveTask">
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>
</button>
</div>
@@ -205,6 +222,15 @@ defineExpose({ reload: loadAll });
<span v-if="activeTask.due_date" class="meta-chip due">
Due {{ activeTask.due_date }}
</span>
<select
class="milestone-select"
:value="activeTask.milestone_id ?? ''"
:disabled="changingMilestone"
@change="setMilestone(($event.target as HTMLSelectElement).value === '' ? null : Number(($event.target as HTMLSelectElement).value))"
>
<option value="">No Milestone</option>
<option v-for="ms in milestones" :key="ms.id" :value="ms.id">{{ ms.title }}</option>
</select>
</div>
<div class="detail-log">
@@ -562,6 +588,19 @@ defineExpose({ reload: loadAll });
text-transform: capitalize;
}
.milestone-select {
font-size: 0.72rem;
padding: 0.15rem 0.4rem;
border-radius: 10px;
background: var(--color-bg);
border: 1px solid var(--color-border);
color: var(--color-text-muted);
cursor: pointer;
max-width: 140px;
}
.milestone-select:disabled { opacity: 0.5; cursor: default; }
.milestone-select:focus { outline: none; border-color: var(--color-primary); }
.detail-log {
flex: 1;
overflow-y: auto;
+4 -1
View File
@@ -79,7 +79,10 @@ watch(
watch(
() => chatStore.streaming,
(s) => {
if (!s) processedCount.value = 0;
if (!s) {
processedCount.value = 0;
scrollToBottom();
}
}
);
+7 -1
View File
@@ -74,7 +74,13 @@ def create_app() -> Quart:
async def after_request(response):
duration = time.monotonic() - getattr(g, "request_start", time.monotonic())
duration_ms = round(duration * 1000, 1)
logger.info(
# Downgrade noisy high-frequency / static paths to DEBUG
_quiet = (
request.path in {"/api/health", "/api/chat/status"}
or request.path.startswith(("/static/", "/assets/", "/sw.js", "/manifest.json"))
)
log_fn = logger.debug if _quiet else logger.info
log_fn(
"%s %s %s %.1fms",
request.method,
request.path,
+6 -5
View File
@@ -23,8 +23,8 @@ _VAPID_KEYS_FILE = Path(Config.IMAGE_CACHE_DIR).parent / "vapid_keys.json"
def _get_webpush():
"""Lazy import to avoid startup errors if pywebpush is not installed."""
try:
from pywebpush import WebPusher
return WebPusher
from pywebpush import webpush
return webpush
except ImportError:
return None
@@ -156,8 +156,8 @@ async def send_push_notification(
logger.debug("VAPID not configured, skipping push notification")
return
WebPusher = _get_webpush()
if WebPusher is None:
webpush = _get_webpush()
if webpush is None:
logger.warning("pywebpush not installed, cannot send push notifications")
return
@@ -182,7 +182,8 @@ async def send_push_notification(
"endpoint": sub.endpoint,
"keys": {"p256dh": sub.p256dh, "auth": sub.auth},
}
response = WebPusher(subscription_info).send(
response = webpush(
subscription_info=subscription_info,
data=payload,
vapid_private_key=Config.VAPID_PRIVATE_KEY,
vapid_claims=vapid_claims,