feat(curator): Needs Review panel in journal right rail (C5/5)

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-23 10:33:42 -04:00
parent 4048a771d2
commit 48b99b62be
+249 -3
View File
@@ -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<JournalMoment[]>([])
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<PendingCuratorAction[]>([])
const pendingLoading = ref(false)
const reviewingIds = ref<Set<number>>(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<string, unknown>)[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 () => {
/>
</div>
<!-- Needs Review panel: curator-proposed mutations awaiting user
approval (update_note, delete_note, update_milestone, etc.).
Hidden when nothing is pending so the rail stays calm. -->
<div v-if="pendingActions.length > 0" class="review-section">
<div class="panel-label-row">
<div class="panel-label review-panel-label">
Needs Review <span class="review-count">{{ pendingActions.length }}</span>
</div>
</div>
<ul class="review-list">
<li v-for="a in pendingActions" :key="a.id" class="review-row">
<div class="review-header">
<span class="review-action-type">{{ a.action_type }}</span>
<span class="review-title">{{ pendingTitle(a) }}</span>
</div>
<div v-if="a.action_type.startsWith('delete_')" class="review-delete-warn">
Permanent delete the entry will be gone.
</div>
<ul v-else-if="pendingDiff(a).length" class="review-diff">
<li v-for="row in pendingDiff(a)" :key="row.field" class="review-diff-row">
<span class="review-diff-field">{{ row.field }}</span>
<span class="review-diff-before">{{ formatDiffValue(row.before) }}</span>
<span class="review-diff-arrow"></span>
<span class="review-diff-after">{{ formatDiffValue(row.after) }}</span>
</li>
</ul>
<div v-else class="review-diff-empty">
No visible change preview; review payload via DB if needed.
</div>
<div class="review-actions">
<button
class="review-btn review-btn--approve"
:disabled="reviewingIds.has(a.id)"
@click="approvePending(a)"
title="Apply the proposed change"
>
<Check :size="14" /> Approve
</button>
<button
class="review-btn review-btn--reject"
:disabled="reviewingIds.has(a.id)"
@click="rejectPending(a)"
title="Discard the proposal"
>
<X :size="14" /> Reject
</button>
</div>
</li>
</ul>
</div>
<!-- Captures panel: shows moments extracted by the curator for the
selected day. The chat model is tools=[]; the curator pass
(manual button below, scheduler in phase 2) populates this. -->
@@ -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); }