From 48b99b62bef52dcc92bc620eed2e368fdbb98c13 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 23 May 2026 10:33:42 -0400 Subject: [PATCH] feat(curator): Needs Review panel in journal right rail (C5/5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The frontend half of the review queue. Closes the curator approval loop end-to-end. JournalView.vue: - New 'Needs Review' section in the right rail, ABOVE the Captures panel (per the design decision: pending stands out, captures are ambient). Hidden entirely when nothing is pending so the rail stays calm. - Each pending action renders as a card: - Header: action_type chip (e.g. 'update_note') + human-readable title built from pendingTitle() ('Update Famous Supply network restage', 'Delete Old grocery list', etc.). - Diff body: - For deletes: a red 'Permanent delete' warning. - For updates: field-level diff rows (field name | old | → | new) computed by pendingDiff(), which compares the curator's payload against the snapshot taken at proposal time. Skips lookup-only params (query, task, project, milestone, confirmed) so the diff shows only what'd actually change. - Empty-diff fallback for tools without snapshot helpers. - Approve / Reject buttons. Disabled while a request is in flight via reviewingIds Set so double-clicks can't fire twice. - Approve calls approvePendingAction → server replays the original tool call with authority='user'; toast on success/error. - Reject calls rejectPendingAction → marks rejected, no execution. - Both actions refresh the pending list AND the moments list (since approving an update_note could affect what shows in captures). - loadPendingActions() also runs after every manual curator trigger and on initial mount, so the panel reflects current state without manual page refresh. CSS: warm-tinted panel using --color-warning so the section visually distinguishes from the neutral captures feed below. Approve button in success-green, reject in muted. Diff rows use a grid layout with old-value strikethrough and an arrow separator. End-to-end demo loop: 1. Have a journal conversation that includes 'mark the Famous Supply task as done'. 2. Wait for curator sweep or hit 'Process captures'. 3. Curator search_notes('Famous Supply'), then update_note(...) is intercepted by execute_tool(authority='curator') and queued. 4. The Needs Review panel shows: 'Update task Famous Supply network restage' with status diff todo→done. 5. Click Approve → execute_tool replays with authority='user' → the task moves to done. Card disappears from Needs Review. This is the last C* commit in the queue. The curator now has a safe path to mutate user data via proposals, with the user firmly in the loop on every change. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/views/JournalView.vue | 252 ++++++++++++++++++++++++++++- 1 file changed, 249 insertions(+), 3 deletions(-) diff --git a/frontend/src/views/JournalView.vue b/frontend/src/views/JournalView.vue index c28a108..4fcce86 100644 --- a/frontend/src/views/JournalView.vue +++ b/frontend/src/views/JournalView.vue @@ -4,7 +4,7 @@ import { useBackgroundRefresh } from '@/composables/useBackgroundRefresh' import { useChatStore } from '@/stores/chat' import ChatPanel from '@/components/ChatPanel.vue' import WeatherCard from '@/components/WeatherCard.vue' -import { RotateCcw, Sparkles } from 'lucide-vue-next' +import { RotateCcw, Sparkles, Check, X } from 'lucide-vue-next' import { apiGet, apiPost, @@ -15,8 +15,12 @@ import { listEvents, listJournalMoments, runJournalCurator, + listPendingActions, + approvePendingAction, + rejectPendingAction, type EventEntry, type JournalMoment, + type PendingCuratorAction, } from '@/api/client' import { useToastStore } from '@/stores/toast' @@ -69,6 +73,95 @@ const moments = ref([]) const momentsLoading = ref(false) const curatorRunning = ref(false) +// Needs Review queue — curator-proposed mutations awaiting user approval. +// Visible only when count > 0; sits above the Captures panel in the rail. +const pendingActions = ref([]) +const pendingLoading = ref(false) +const reviewingIds = ref>(new Set()) + +async function loadPendingActions() { + pendingLoading.value = true + try { + const res = await listPendingActions() + pendingActions.value = res.pending + } catch { + /* silent — pending feature may not be deployed yet */ + } finally { + pendingLoading.value = false + } +} + +async function approvePending(action: PendingCuratorAction) { + if (reviewingIds.value.has(action.id)) return + reviewingIds.value.add(action.id) + try { + const result = await approvePendingAction(action.id) + if ((result as { error?: string }).error) { + toastStore.show(`Approve failed: ${(result as { error: string }).error}`, 'error') + } else { + toastStore.show(`Applied: ${action.action_type}`, 'success') + } + await Promise.all([loadPendingActions(), loadMoments()]) + } catch (e) { + const msg = e instanceof Error ? e.message : 'Approve failed' + toastStore.show(msg, 'error') + } finally { + reviewingIds.value.delete(action.id) + } +} + +async function rejectPending(action: PendingCuratorAction) { + if (reviewingIds.value.has(action.id)) return + reviewingIds.value.add(action.id) + try { + await rejectPendingAction(action.id) + toastStore.show(`Rejected: ${action.action_type}`, 'success') + await loadPendingActions() + } catch (e) { + const msg = e instanceof Error ? e.message : 'Reject failed' + toastStore.show(msg, 'error') + } finally { + reviewingIds.value.delete(action.id) + } +} + +// Human-readable subject for the card header. +function pendingTitle(a: PendingCuratorAction): string { + const verb = a.action_type.startsWith('delete_') ? 'Delete' + : a.action_type.startsWith('update_') ? 'Update' + : a.action_type + const target = a.target_label || (a.target_type ?? 'item') + return `${verb} ${target}` +} + +// Compute the field-level diff between current snapshot and proposed payload. +// Returns [{field, before, after}] only for fields the curator wants to change. +interface DiffRow { field: string; before: unknown; after: unknown } +function pendingDiff(a: PendingCuratorAction): DiffRow[] { + const snap = a.current_snapshot || {} + const payload = a.payload || {} + const rows: DiffRow[] = [] + // Map payload param names → snapshot key names where they differ. + // update_note uses `query` (the lookup) but we diff the target's own + // fields — skip `query` itself from the diff. + const skip = new Set(['query', 'task', 'project', 'milestone', 'confirmed']) + for (const [key, after] of Object.entries(payload)) { + if (skip.has(key)) continue + if (after === null || after === undefined || after === '') continue + const before = (snap as Record)[key] + if (JSON.stringify(before) === JSON.stringify(after)) continue + rows.push({ field: key, before, after }) + } + return rows +} + +function formatDiffValue(v: unknown): string { + if (v === null || v === undefined) return '—' + if (Array.isArray(v)) return v.length ? v.join(', ') : '—' + if (typeof v === 'object') return JSON.stringify(v) + return String(v) +} + async function loadMoments() { if (!selectedDay.value) { moments.value = [] @@ -115,7 +208,7 @@ async function triggerCurator() { 'success', ) } - await loadMoments() + await Promise.all([loadMoments(), loadPendingActions()]) } catch (e) { const msg = e instanceof Error ? e.message : 'Curator failed' toastStore.show(`Curator failed: ${msg}`, 'error') @@ -248,7 +341,13 @@ async function loadAll() { } } catch { /* silent */ } - await Promise.all([loadWeather(), loadCurrentConditions(), loadEvents(), loadMoments()]) + await Promise.all([ + loadWeather(), + loadCurrentConditions(), + loadEvents(), + loadMoments(), + loadPendingActions(), + ]) } watch(selectedDay, async (iso) => { @@ -371,6 +470,57 @@ onMounted(async () => { /> + +
+
+
+ Needs Review {{ pendingActions.length }} +
+
+
    +
  • +
    + {{ a.action_type }} + {{ pendingTitle(a) }} +
    +
    + Permanent delete — the entry will be gone. +
    +
      +
    • + {{ row.field }} + {{ formatDiffValue(row.before) }} + + {{ formatDiffValue(row.after) }} +
    • +
    +
    + No visible change preview; review payload via DB if needed. +
    +
    + + +
    +
  • +
+
+ @@ -608,6 +758,102 @@ onMounted(async () => { } .panel-label-row { display: flex; align-items: center; justify-content: space-between; flex-shrink: 0; } +/* Needs Review panel — curator-proposed mutations awaiting approval. + Sits above Captures; visible only when there are pending items. */ +.review-section { + padding: 0.75rem 1rem; + border-top: 1px solid var(--color-border); + background: color-mix(in srgb, var(--color-warning, #b88a2c) 6%, transparent); +} +.review-panel-label { + display: inline-flex; + align-items: center; + gap: 0.4rem; + color: var(--color-warning, #b88a2c); +} +.review-count { + background: color-mix(in srgb, var(--color-warning, #b88a2c) 20%, transparent); + color: var(--color-warning, #b88a2c); + border-radius: 999px; + padding: 0.05rem 0.5rem; + font-size: 0.7rem; + font-weight: 600; +} +.review-list { list-style: none; margin: 0.5rem 0 0; padding: 0; display: flex; flex-direction: column; gap: 0.6rem; } +.review-row { + padding: 0.6rem; + border-radius: 8px; + background: var(--color-bg, #111); + border: 1px solid color-mix(in srgb, var(--color-warning, #b88a2c) 25%, transparent); + display: flex; + flex-direction: column; + gap: 0.4rem; +} +.review-header { display: flex; align-items: baseline; gap: 0.5rem; } +.review-action-type { + font-family: var(--font-mono, monospace); + font-size: 0.7rem; + padding: 0.1rem 0.4rem; + border-radius: 4px; + background: color-mix(in srgb, var(--color-text-muted) 12%, transparent); + color: var(--color-text-muted); + flex-shrink: 0; +} +.review-title { font-size: 0.88rem; font-weight: 500; color: var(--color-text); } +.review-delete-warn { + font-size: 0.78rem; + color: var(--color-danger, #d04848); + font-style: italic; + padding: 0.3rem 0.5rem; + background: color-mix(in srgb, var(--color-danger, #d04848) 8%, transparent); + border-radius: 4px; +} +.review-diff { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.25rem; } +.review-diff-row { + display: grid; + grid-template-columns: minmax(70px, max-content) 1fr auto 1fr; + gap: 0.4rem; + align-items: baseline; + font-size: 0.78rem; +} +.review-diff-field { + font-family: var(--font-mono, monospace); + color: var(--color-text-muted); +} +.review-diff-before { + color: var(--color-text-muted); + text-decoration: line-through; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.review-diff-arrow { color: var(--color-text-muted); } +.review-diff-after { + color: var(--color-text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.review-diff-empty { font-size: 0.76rem; color: var(--color-text-muted); font-style: italic; } +.review-actions { display: flex; gap: 0.5rem; } +.review-btn { + display: inline-flex; + align-items: center; + gap: 0.25rem; + font-size: 0.75rem; + padding: 0.3rem 0.65rem; + border-radius: 6px; + border: 1px solid var(--color-border); + background: transparent; + cursor: pointer; + transition: background 0.15s, color 0.15s, border-color 0.15s; +} +.review-btn:disabled { opacity: 0.5; cursor: not-allowed; } +.review-btn--approve { color: var(--color-success, #22c55e); border-color: color-mix(in srgb, var(--color-success, #22c55e) 50%, transparent); } +.review-btn--approve:hover:not(:disabled) { background: color-mix(in srgb, var(--color-success, #22c55e) 12%, transparent); } +.review-btn--reject { color: var(--color-text-muted); } +.review-btn--reject:hover:not(:disabled) { background: color-mix(in srgb, var(--color-text-muted) 12%, transparent); color: var(--color-text); } + /* Captures panel — moments extracted by the curator (services/curator.py). Sits above the events panel; the chat is in the center column. */ .captures-section { padding: 0.75rem 1rem; border-top: 1px solid var(--color-border); }