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:
@@ -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); }
|
||||
|
||||
Reference in New Issue
Block a user