mirror of
https://github.com/samvallad33/vestige.git
synced 2026-07-24 23:41:01 +02:00
feat(launch-polish): finish dashboard auth trust boundary + pending-review UX
Completes the launch-polish lane Codex started (rate-limited mid-wiring): Trust boundary (was: dashboard API/WS unauthenticated): - Token middleware on all /api/* and /ws, reusing the persisted Vestige auth token with constant-time compare (subtle); Sec-Fetch-Site + exact-Origin checks - Token delivered via URL fragment (never sent to server / logged), installed into localStorage on app load — WIRES installDashboardTokenFromLocation() into the root +layout onMount BEFORE websocket.connect()/any fetch (the missing piece that otherwise 401s the dashboard against its own API) Fail-closed memory governance: - Dashboard delete/suppress pre-gate through Memory PRs in Risk-Gated/Paranoid (Fast still mutates directly); decision-before-mutation; replay -> 409 - Duplicate pending-PR reuse; PR content redacted to preview+hash at the API layer Black Box / receipts: - Live refresh of run list + receipts on real TraceEvents (trace-before-receipt race handled via retry); synthetic events excluded from proof counters Fixes on top of Codex's work: - websocket.ts: source 'synthetic' -> `as const` (TS error, blocked pnpm check) - memories: scoped a11y-ignore on the non-interactive review-notice - collapsed sanitize_memory_pr_json (clippy collapsible_if); rustfmt the 7 touched Rust files Gates: pnpm check 0/0 (905 files); vestige-mcp 453 tests; core trace/review/ receipt 67 tests; clippy -D warnings clean. All green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
19ef586056
commit
0ff9527d2b
14 changed files with 2302 additions and 662 deletions
|
|
@ -11,7 +11,8 @@ import type {
|
|||
RetentionDistribution,
|
||||
ConsolidationResult,
|
||||
IntentionItem,
|
||||
SuppressResult,
|
||||
DeleteMemoryResult,
|
||||
SuppressMemoryResult,
|
||||
UnsuppressResult,
|
||||
SanhedrinAppealReason,
|
||||
SanhedrinAppealResponse,
|
||||
|
|
@ -20,14 +21,81 @@ import type {
|
|||
} from '$types';
|
||||
|
||||
const BASE = '/api';
|
||||
const DASHBOARD_TOKEN_STORAGE_KEY = 'vestige.dashboard.token';
|
||||
const DASHBOARD_TOKEN_HEADER = 'X-Vestige-Dashboard-Token';
|
||||
|
||||
async function fetcher<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const headers = withDashboardAuthHeaders(options?.headers);
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...options
|
||||
...options,
|
||||
headers
|
||||
});
|
||||
if (!res.ok) {
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
throw new Error(
|
||||
`API ${res.status}: dashboard auth failed. Open Vestige from the CLI or set VESTIGE_AUTH_TOKEN.`
|
||||
);
|
||||
}
|
||||
throw new Error(`API ${res.status}: ${res.statusText}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function withDashboardAuthHeaders(headers?: HeadersInit): Headers {
|
||||
const next = new Headers(headers);
|
||||
if (!next.has('Content-Type')) {
|
||||
next.set('Content-Type', 'application/json');
|
||||
}
|
||||
const token = getDashboardAuthToken();
|
||||
if (token) {
|
||||
next.set(DASHBOARD_TOKEN_HEADER, token);
|
||||
next.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function getDashboardAuthToken(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
return window.localStorage.getItem(DASHBOARD_TOKEN_STORAGE_KEY);
|
||||
}
|
||||
|
||||
export function installDashboardTokenFromLocation(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
const hash = window.location.hash.startsWith('#')
|
||||
? window.location.hash.slice(1)
|
||||
: window.location.hash;
|
||||
if (!hash) return false;
|
||||
|
||||
const params = new URLSearchParams(hash);
|
||||
const token = params.get('vestige_token') ?? params.get('token');
|
||||
if (!token) return false;
|
||||
|
||||
window.localStorage.setItem(DASHBOARD_TOKEN_STORAGE_KEY, token);
|
||||
params.delete('vestige_token');
|
||||
params.delete('token');
|
||||
|
||||
const remainingHash = params.toString();
|
||||
const nextUrl = `${window.location.pathname}${window.location.search}${
|
||||
remainingHash ? `#${remainingHash}` : ''
|
||||
}`;
|
||||
window.history.replaceState(null, document.title, nextUrl);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function downloadJson(path: string, filename: string): Promise<void> {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
headers: withDashboardAuthHeaders()
|
||||
});
|
||||
if (!res.ok) throw new Error(`API ${res.status}: ${res.statusText}`);
|
||||
return res.json();
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export const api = {
|
||||
|
|
@ -37,16 +105,16 @@ export const api = {
|
|||
const qs = params ? '?' + new URLSearchParams(params).toString() : '';
|
||||
return fetcher<MemoryListResponse>(`/memories${qs}`);
|
||||
},
|
||||
get: (id: string) => fetcher<Memory>(`/memories/${id}`),
|
||||
delete: (id: string) => fetcher<{ deleted: boolean }>(`/memories/${id}`, { method: 'DELETE' }),
|
||||
promote: (id: string) => fetcher<Memory>(`/memories/${id}/promote`, { method: 'POST' }),
|
||||
demote: (id: string) => fetcher<Memory>(`/memories/${id}/demote`, { method: 'POST' }),
|
||||
get: (id: string) => fetcher<Memory>(`/memories/${id}`),
|
||||
delete: (id: string) => fetcher<DeleteMemoryResult>(`/memories/${id}`, { method: 'DELETE' }),
|
||||
promote: (id: string) => fetcher<Memory>(`/memories/${id}/promote`, { method: 'POST' }),
|
||||
demote: (id: string) => fetcher<Memory>(`/memories/${id}/demote`, { method: 'POST' }),
|
||||
// v2.0.7: suppress + unsuppress. Anderson 2025 top-down inhibitory
|
||||
// control. Each suppress call compounds; reversible within 24h. The
|
||||
// backend emits MemorySuppressed / MemoryUnsuppressed so the 3D graph
|
||||
// plays the violet implosion / rainbow reversal.
|
||||
suppress: (id: string, reason?: string) =>
|
||||
fetcher<SuppressResult>(`/memories/${id}/suppress`, {
|
||||
suppress: (id: string, reason?: string) =>
|
||||
fetcher<SuppressMemoryResult>(`/memories/${id}/suppress`, {
|
||||
method: 'POST',
|
||||
body: reason ? JSON.stringify({ reason }) : undefined
|
||||
}),
|
||||
|
|
@ -138,9 +206,19 @@ export const api = {
|
|||
// Agent Black Box (v2.2): replayable agent-run traces. The runId in a tool
|
||||
// result threads through here unchanged — one id, end to end.
|
||||
traces: {
|
||||
list: (limit = 50) => fetcher<TraceRunListResponse>(`/traces?limit=${limit}`),
|
||||
list: (limit = 50, runId?: string) => {
|
||||
const qs = new URLSearchParams();
|
||||
qs.set('limit', String(limit));
|
||||
if (runId) qs.set('run', runId);
|
||||
return fetcher<TraceRunListResponse>(`/traces?${qs.toString()}`);
|
||||
},
|
||||
get: (runId: string) => fetcher<TraceDetail>(`/traces/${encodeURIComponent(runId)}`),
|
||||
exportUrl: (runId: string) => `${BASE}/traces/${encodeURIComponent(runId)}/export`
|
||||
exportUrl: (runId: string) => `${BASE}/traces/${encodeURIComponent(runId)}/export`,
|
||||
download: (runId: string) =>
|
||||
downloadJson(
|
||||
`/traces/${encodeURIComponent(runId)}/export`,
|
||||
`${runId}.vestige-trace.json`
|
||||
)
|
||||
},
|
||||
|
||||
// Memory Receipts (v2.2): the nutrition label for a retrieval.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { writable, derived } from 'svelte/store';
|
||||
import type { VestigeEvent } from '$types';
|
||||
import { getDashboardAuthToken } from './api';
|
||||
|
||||
const MAX_EVENTS = 200;
|
||||
|
||||
|
|
@ -23,9 +24,11 @@ function createWebSocketStore() {
|
|||
let reconnectAttempts = 0;
|
||||
|
||||
function connect(url?: string) {
|
||||
const wsUrl = url || (window.location.port === '5173'
|
||||
const baseWsUrl = url || (window.location.port === '5173'
|
||||
? `ws://${window.location.hostname}:3927/ws`
|
||||
: `ws://${window.location.host}/ws`);
|
||||
const token = getDashboardAuthToken();
|
||||
const wsUrl = token ? withToken(baseWsUrl, token) : baseWsUrl;
|
||||
|
||||
if (ws?.readyState === WebSocket.OPEN) return;
|
||||
|
||||
|
|
@ -93,7 +96,7 @@ function createWebSocketStore() {
|
|||
*/
|
||||
function injectEvent(event: VestigeEvent) {
|
||||
update(s => {
|
||||
const events = [event, ...s.events].slice(0, MAX_EVENTS);
|
||||
const events = [{ ...event, source: 'synthetic' as const }, ...s.events].slice(0, MAX_EVENTS);
|
||||
return { ...s, events };
|
||||
});
|
||||
}
|
||||
|
|
@ -107,6 +110,12 @@ function createWebSocketStore() {
|
|||
};
|
||||
}
|
||||
|
||||
function withToken(url: string, token: string): string {
|
||||
const next = new URL(url, window.location.href);
|
||||
next.searchParams.set('token', token);
|
||||
return next.toString();
|
||||
}
|
||||
|
||||
export const websocket = createWebSocketStore();
|
||||
|
||||
// Derived stores for specific event types
|
||||
|
|
@ -136,19 +145,19 @@ export const uptimeSeconds = derived(websocket, $ws =>
|
|||
// is a real `VestigeEvent::TraceEvent` backed by a persisted `agent_traces`
|
||||
// row — the dashboard pulse is only ever driven by these, never by fakes.
|
||||
export const traceEvents = derived(websocket, $ws =>
|
||||
$ws.events.filter((e) => e.type === 'TraceEvent')
|
||||
$ws.events.filter((e) => e.type === 'TraceEvent' && e.source !== 'synthetic')
|
||||
);
|
||||
|
||||
// The most recent runId seen on the live feed — the "current run" indicator in
|
||||
// Proof Mode / the Black Box live header.
|
||||
export const liveRunId = derived(websocket, $ws => {
|
||||
const latest = $ws.events.find((e) => e.type === 'TraceEvent');
|
||||
const latest = $ws.events.find((e) => e.type === 'TraceEvent' && e.source !== 'synthetic');
|
||||
return (latest?.data?.run_id as string) ?? null;
|
||||
});
|
||||
|
||||
// The single most recent trace event (for the "last event" readout).
|
||||
export const lastTraceEvent = derived(websocket, $ws =>
|
||||
$ws.events.find((e) => e.type === 'TraceEvent') ?? null
|
||||
$ws.events.find((e) => e.type === 'TraceEvent' && e.source !== 'synthetic') ?? null
|
||||
);
|
||||
|
||||
// Live Memory PR notifications (opened / decided) for the queue badge + toasts.
|
||||
|
|
|
|||
|
|
@ -176,6 +176,7 @@ export type VestigeEventType =
|
|||
export interface VestigeEvent {
|
||||
type: VestigeEventType;
|
||||
data: Record<string, unknown>;
|
||||
source?: 'socket' | 'synthetic';
|
||||
}
|
||||
|
||||
// v2.0.7: active-forgetting response shapes. Each suppress call COMPOUNDS;
|
||||
|
|
@ -196,6 +197,27 @@ export interface SuppressResult {
|
|||
reason: string | null;
|
||||
}
|
||||
|
||||
export interface MemoryPrOpenedSummary {
|
||||
id: string;
|
||||
kind: string;
|
||||
title: string;
|
||||
signals?: Array<Record<string, unknown>>;
|
||||
subjectId?: string;
|
||||
}
|
||||
|
||||
export interface PendingReviewResult {
|
||||
action: string;
|
||||
success: false;
|
||||
pendingReview: true;
|
||||
nodeId: string;
|
||||
message: string;
|
||||
memoryPrsOpened: MemoryPrOpenedSummary[];
|
||||
memoryPrNotice?: string;
|
||||
}
|
||||
|
||||
export type DeleteMemoryResult = { deleted: true; id: string } | PendingReviewResult;
|
||||
export type SuppressMemoryResult = SuppressResult | PendingReviewResult;
|
||||
|
||||
export interface UnsuppressResult {
|
||||
unsuppressed: true;
|
||||
id: string;
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
// Live events are real — they arrive over the WebSocket backed by trace
|
||||
// rows. No fake demo events.
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
import { onMount } from 'svelte';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import PageHeader from '$components/PageHeader.svelte';
|
||||
import Icon from '$components/Icon.svelte';
|
||||
import AnimatedNumber from '$components/AnimatedNumber.svelte';
|
||||
|
|
@ -46,6 +46,8 @@
|
|||
let scrubIndex = $state(0); // index into detail.events
|
||||
let proofMode = $state(false);
|
||||
let receipts = $state<Receipt[]>([]);
|
||||
let liveRefreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let receiptRetryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// The events up to and including the scrubber position — what the agent had
|
||||
// "experienced" at that moment in the run.
|
||||
|
|
@ -69,11 +71,15 @@
|
|||
detail?.events.some((e) => e.type === 'contradiction.detected') ?? false
|
||||
);
|
||||
|
||||
async function loadRuns() {
|
||||
async function loadRuns(preferredRunId?: string | null) {
|
||||
try {
|
||||
const res = await api.traces.list(100);
|
||||
runs = res.runs;
|
||||
if (!selectedRunId && runs.length) selectRun(runs[0].runId);
|
||||
if (preferredRunId) {
|
||||
await selectRun(preferredRunId);
|
||||
} else if (!selectedRunId && runs.length) {
|
||||
await selectRun(runs[0].runId);
|
||||
}
|
||||
} catch (e) {
|
||||
error = String(e);
|
||||
}
|
||||
|
|
@ -84,11 +90,11 @@
|
|||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
detail = await api.traces.get(runId);
|
||||
scrubIndex = Math.max(0, (detail.events.length || 1) - 1);
|
||||
// Receipts are the proof behind THIS run's retrievals — scoped to
|
||||
// the selected run (B5), not the global latest.
|
||||
receipts = (await api.receipts.listForRun(runId, 8)).receipts;
|
||||
const next = await api.traces.get(runId);
|
||||
if (selectedRunId !== runId) return;
|
||||
detail = next;
|
||||
scrubIndex = Math.max(0, (next.events.length || 1) - 1);
|
||||
await loadReceiptsForRun(runId);
|
||||
} catch (e) {
|
||||
error = String(e);
|
||||
detail = null;
|
||||
|
|
@ -97,30 +103,77 @@
|
|||
}
|
||||
}
|
||||
|
||||
async function refreshSelectedRun(runId: string, pinToLatest = false) {
|
||||
const wasPinnedToLatest = !detail || scrubIndex >= detail.events.length - 1;
|
||||
const next = await api.traces.get(runId);
|
||||
if (selectedRunId !== runId) return;
|
||||
detail = next;
|
||||
if (pinToLatest || wasPinnedToLatest) {
|
||||
scrubIndex = Math.max(0, next.events.length - 1);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadReceiptsForRun(runId: string) {
|
||||
const next = (await api.receipts.listForRun(runId, 8)).receipts;
|
||||
if (selectedRunId === runId) {
|
||||
receipts = next;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleReceiptRetry(runId: string) {
|
||||
if (receiptRetryTimer) clearTimeout(receiptRetryTimer);
|
||||
receiptRetryTimer = setTimeout(() => {
|
||||
if (selectedRunId === runId) {
|
||||
void loadReceiptsForRun(runId);
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function scheduleLiveRefresh(runId: string, eventType?: TraceEvent['type']) {
|
||||
if (liveRefreshTimer) clearTimeout(liveRefreshTimer);
|
||||
liveRefreshTimer = setTimeout(async () => {
|
||||
await loadRuns();
|
||||
if (!selectedRunId) {
|
||||
await selectRun(runId);
|
||||
return;
|
||||
}
|
||||
if (selectedRunId !== runId) return;
|
||||
await refreshSelectedRun(runId, true);
|
||||
await loadReceiptsForRun(runId);
|
||||
if (eventType === 'memory.retrieve') {
|
||||
scheduleReceiptRetry(runId);
|
||||
}
|
||||
}, 75);
|
||||
}
|
||||
|
||||
function exportTrace() {
|
||||
if (!selectedRunId) return;
|
||||
// Direct browser download of the .vestige-trace.json artifact.
|
||||
window.location.href = api.traces.exportUrl(selectedRunId);
|
||||
}
|
||||
|
||||
// Live: when a trace event for the *currently open* run arrives, refresh it
|
||||
// so the timeline grows in real time. Also refresh the run list so new runs
|
||||
// appear at the top.
|
||||
// Live: when a real trace event arrives, refresh the run picker and the open
|
||||
// trace. Receipts can be saved just after the TraceEvent broadcast, so
|
||||
// retrieval events get one delayed retry to avoid a stale proof panel.
|
||||
$effect(() => {
|
||||
const last = $lastTraceEvent;
|
||||
if (!last) return;
|
||||
const evRunId = last.data?.run_id as string | undefined;
|
||||
if (evRunId && evRunId === selectedRunId) {
|
||||
// Re-fetch the open run (cheap; trace rows are local SQLite).
|
||||
api.traces.get(selectedRunId).then((d) => {
|
||||
detail = d;
|
||||
// Keep the scrubber pinned to the newest event in live mode.
|
||||
scrubIndex = Math.max(0, d.events.length - 1);
|
||||
});
|
||||
const event = last.data?.event as TraceEvent | undefined;
|
||||
const evRunId = (last.data?.run_id as string | undefined) ?? event?.runId;
|
||||
if (evRunId) {
|
||||
scheduleLiveRefresh(evRunId, event?.type);
|
||||
}
|
||||
});
|
||||
|
||||
onMount(loadRuns);
|
||||
onMount(() => {
|
||||
const preferredRunId = new URLSearchParams(window.location.search).get('run');
|
||||
loadRuns(preferredRunId);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (liveRefreshTimer) clearTimeout(liveRefreshTimer);
|
||||
if (receiptRetryTimer) clearTimeout(receiptRetryTimer);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-6xl px-5 py-6">
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$stores/api';
|
||||
import type { Memory } from '$types';
|
||||
import type { Memory, PendingReviewResult } from '$types';
|
||||
import { NODE_TYPE_COLORS } from '$types';
|
||||
import MemoryAuditTrail from '$lib/components/MemoryAuditTrail.svelte';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
|
|
@ -18,6 +18,9 @@
|
|||
let minRetention = $state(0);
|
||||
let loading = $state(true);
|
||||
let selectedMemory: Memory | null = $state(null);
|
||||
let reviewNotices: Record<string, { action: string; prId?: string; title?: string; message: string }> = $state({});
|
||||
let actionErrors: Record<string, string> = $state({});
|
||||
let actionBusy: Record<string, string> = $state({});
|
||||
// Which inner tab of the expanded card is active. Keyed by memory id so
|
||||
// switching between cards remembers each one's last view independently.
|
||||
let expandedTab: Record<string, 'content' | 'audit'> = $state({});
|
||||
|
|
@ -53,6 +56,75 @@
|
|||
return '#ef4444';
|
||||
}
|
||||
|
||||
function isPendingReview(result: unknown): result is PendingReviewResult {
|
||||
return Boolean(result && typeof result === 'object' && (result as PendingReviewResult).pendingReview);
|
||||
}
|
||||
|
||||
function setBusy(id: string, action: string | null) {
|
||||
if (action) {
|
||||
actionBusy = { ...actionBusy, [id]: action };
|
||||
return;
|
||||
}
|
||||
const { [id]: _removed, ...rest } = actionBusy;
|
||||
actionBusy = rest;
|
||||
}
|
||||
|
||||
function setError(id: string, message: string | null) {
|
||||
if (message) {
|
||||
actionErrors = { ...actionErrors, [id]: message };
|
||||
return;
|
||||
}
|
||||
const { [id]: _removed, ...rest } = actionErrors;
|
||||
actionErrors = rest;
|
||||
}
|
||||
|
||||
function setReviewNotice(id: string, result: PendingReviewResult) {
|
||||
const firstPr = result.memoryPrsOpened?.[0];
|
||||
reviewNotices = {
|
||||
...reviewNotices,
|
||||
[id]: {
|
||||
action: result.action,
|
||||
prId: firstPr?.id,
|
||||
title: firstPr?.title,
|
||||
message: result.message
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function requestSuppress(memory: Memory) {
|
||||
setBusy(memory.id, 'suppress');
|
||||
setError(memory.id, null);
|
||||
try {
|
||||
const result = await api.memories.suppress(memory.id, 'dashboard trigger');
|
||||
if (isPendingReview(result)) {
|
||||
setReviewNotice(memory.id, result);
|
||||
return;
|
||||
}
|
||||
await loadMemories();
|
||||
} catch (e) {
|
||||
setError(memory.id, e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(memory.id, null);
|
||||
}
|
||||
}
|
||||
|
||||
async function requestDelete(memory: Memory) {
|
||||
setBusy(memory.id, 'delete');
|
||||
setError(memory.id, null);
|
||||
try {
|
||||
const result = await api.memories.delete(memory.id);
|
||||
if (isPendingReview(result)) {
|
||||
setReviewNotice(memory.id, result);
|
||||
return;
|
||||
}
|
||||
await loadMemories();
|
||||
} catch (e) {
|
||||
setError(memory.id, e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(memory.id, null);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear, labelled dropdown options replace the dead native <select>.
|
||||
const typeOptions: DropdownOption[] = [
|
||||
{ value: '', label: 'All types' },
|
||||
|
|
@ -171,9 +243,12 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{#if selectedMemory?.id === memory.id}
|
||||
{@const activeTab = expandedTab[memory.id] ?? 'content'}
|
||||
<div class="relative z-[1] mt-4 pt-4 border-t border-synapse/10 space-y-3">
|
||||
{#if selectedMemory?.id === memory.id}
|
||||
{@const activeTab = expandedTab[memory.id] ?? 'content'}
|
||||
{@const reviewNotice = reviewNotices[memory.id]}
|
||||
{@const actionError = actionErrors[memory.id]}
|
||||
{@const busyAction = actionBusy[memory.id]}
|
||||
<div class="relative z-[1] mt-4 pt-4 border-t border-synapse/10 space-y-3">
|
||||
<!-- Inner tab switcher: Content (default) vs Audit Trail. -->
|
||||
<div class="flex gap-1 text-[11px] uppercase tracking-wider">
|
||||
<span
|
||||
|
|
@ -209,39 +284,124 @@
|
|||
>
|
||||
<MemoryAuditTrail memoryId={memory.id} />
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-2">
|
||||
<span role="button" tabindex="0" onclick={(e) => { e.stopPropagation(); api.memories.promote(memory.id); }}
|
||||
onkeydown={(e) => { if (e.key === 'Enter') { e.stopPropagation(); api.memories.promote(memory.id); } }}
|
||||
{#if reviewNotice}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<!-- Handlers only stop propagation so a click on this status
|
||||
notice doesn't trigger the parent card; not interactive. -->
|
||||
<div
|
||||
class="review-notice"
|
||||
role="status"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div class="review-copy">
|
||||
<span class="review-kicker"><Icon name="memorypr" size={14} /> Review pending</span>
|
||||
<strong>{reviewNotice.title ?? 'Memory mutation held for review'}</strong>
|
||||
<small>{reviewNotice.message}</small>
|
||||
</div>
|
||||
<a class="review-link" href="/memory-prs" onclick={(e) => e.stopPropagation()}>
|
||||
Open queue
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if actionError}
|
||||
<div class="action-error" role="alert">{actionError}</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-2">
|
||||
<span role="button" tabindex="0" onclick={(e) => { e.stopPropagation(); api.memories.promote(memory.id); }}
|
||||
onkeydown={(e) => { if (e.key === 'Enter') { e.stopPropagation(); api.memories.promote(memory.id); } }}
|
||||
class="px-3 py-1.5 bg-recall/20 text-recall text-xs rounded-lg hover:bg-recall/30 cursor-pointer select-none">Promote</span>
|
||||
<span role="button" tabindex="0" onclick={(e) => { e.stopPropagation(); api.memories.demote(memory.id); }}
|
||||
onkeydown={(e) => { if (e.key === 'Enter') { e.stopPropagation(); api.memories.demote(memory.id); } }}
|
||||
class="px-3 py-1.5 bg-decay/20 text-decay text-xs rounded-lg hover:bg-decay/30 cursor-pointer select-none">Demote</span>
|
||||
<!-- v2.0.7: suppress (active forgetting). Distinct from delete: the memory
|
||||
persists but is inhibited from retrieval and actively decays. Each click
|
||||
compounds. Graph plays the violet implosion via MemorySuppressed event. -->
|
||||
<span role="button" tabindex="0"
|
||||
onclick={async (e) => {
|
||||
e.stopPropagation();
|
||||
await api.memories.suppress(memory.id, 'dashboard trigger');
|
||||
}}
|
||||
onkeydown={async (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
<!-- v2.0.7: suppress (active forgetting). Distinct from delete: the memory
|
||||
persists but is inhibited from retrieval and actively decays. Each click
|
||||
compounds. Graph plays the violet implosion via MemorySuppressed event. -->
|
||||
<span role="button" tabindex="0"
|
||||
onclick={async (e) => {
|
||||
e.stopPropagation();
|
||||
await api.memories.suppress(memory.id, 'dashboard trigger');
|
||||
}
|
||||
}}
|
||||
title="Top-down inhibition (Anderson 2025). Compounds. Reversible for 24h."
|
||||
class="px-3 py-1.5 bg-purple-500/20 text-purple-400 text-xs rounded-lg hover:bg-purple-500/30 cursor-pointer select-none">Suppress</span>
|
||||
<span role="button" tabindex="0" onclick={async (e) => { e.stopPropagation(); await api.memories.delete(memory.id); loadMemories(); }}
|
||||
onkeydown={async (e) => { if (e.key === 'Enter') { e.stopPropagation(); await api.memories.delete(memory.id); loadMemories(); } }}
|
||||
class="px-3 py-1.5 bg-decay/10 text-decay/60 text-xs rounded-lg hover:bg-decay/20 ml-auto cursor-pointer select-none">Delete</span>
|
||||
await requestSuppress(memory);
|
||||
}}
|
||||
onkeydown={async (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.stopPropagation();
|
||||
await requestSuppress(memory);
|
||||
}
|
||||
}}
|
||||
title="Top-down inhibition (Anderson 2025). Compounds. Reversible for 24h."
|
||||
class="px-3 py-1.5 bg-purple-500/20 text-purple-400 text-xs rounded-lg hover:bg-purple-500/30 cursor-pointer select-none {busyAction === 'suppress' ? 'opacity-60 pointer-events-none' : ''}">
|
||||
{busyAction === 'suppress' ? 'Holding…' : 'Suppress'}
|
||||
</span>
|
||||
<span role="button" tabindex="0" onclick={async (e) => { e.stopPropagation(); await requestDelete(memory); }}
|
||||
onkeydown={async (e) => { if (e.key === 'Enter') { e.stopPropagation(); await requestDelete(memory); } }}
|
||||
class="px-3 py-1.5 bg-decay/10 text-decay/60 text-xs rounded-lg hover:bg-decay/20 ml-auto cursor-pointer select-none {busyAction === 'delete' ? 'opacity-60 pointer-events-none' : ''}">
|
||||
{busyAction === 'delete' ? 'Holding…' : 'Delete'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.review-notice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 11px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid color-mix(in oklab, var(--color-synapse) 32%, transparent);
|
||||
background: color-mix(in oklab, var(--color-synapse) 10%, transparent);
|
||||
}
|
||||
.review-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
.review-kicker {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-synapse-glow);
|
||||
}
|
||||
.review-copy strong {
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.review-copy small {
|
||||
font-size: 0.74rem;
|
||||
color: var(--color-text-dim);
|
||||
line-height: 1.35;
|
||||
}
|
||||
.review-link {
|
||||
flex-shrink: 0;
|
||||
padding: 6px 9px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-synapse-glow);
|
||||
background: color-mix(in oklab, var(--color-synapse) 14%, transparent);
|
||||
border: 1px solid color-mix(in oklab, var(--color-synapse) 30%, transparent);
|
||||
}
|
||||
.action-error {
|
||||
padding: 8px 10px;
|
||||
border-radius: 9px;
|
||||
font-size: 0.78rem;
|
||||
color: #fecaca;
|
||||
background: color-mix(in oklab, #ef4444 14%, transparent);
|
||||
border: 1px solid color-mix(in oklab, #ef4444 30%, transparent);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -152,8 +152,8 @@
|
|||
|
||||
// Diff rendering helpers
|
||||
function diffContent(pr: MemoryPr): string {
|
||||
const node = pr.diff?.node as { content?: string } | undefined;
|
||||
return node?.content ?? '';
|
||||
const node = pr.diff?.node as { content?: string; contentPreview?: string } | undefined;
|
||||
return node?.contentPreview ?? '';
|
||||
}
|
||||
function diffNodeType(pr: MemoryPr): string {
|
||||
const node = pr.diff?.node as { nodeType?: string } | undefined;
|
||||
|
|
@ -163,6 +163,10 @@
|
|||
const node = pr.diff?.node as { tags?: string[] } | undefined;
|
||||
return node?.tags ?? [];
|
||||
}
|
||||
function diffHash(pr: MemoryPr): string {
|
||||
const node = pr.diff?.node as { contentHash?: string } | undefined;
|
||||
return node?.contentHash ?? '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-6xl px-5 py-6">
|
||||
|
|
@ -270,11 +274,15 @@
|
|||
<div class="diff-head">
|
||||
<span class="pr-kind kind-{selected.kind}">{kindLabel[selected.kind] ?? selected.kind}</span>
|
||||
<span class="pr-status st-{selected.status}">{selected.status}</span>
|
||||
{#if selected.run_id}
|
||||
<a class="run-link" href={`/blackbox`} title="View the run that produced this">
|
||||
<Icon name="blackbox" size={13} /> {selected.run_id.replace('run_', '').slice(0, 8)}
|
||||
</a>
|
||||
{/if}
|
||||
{#if selected.run_id}
|
||||
<a
|
||||
class="run-link"
|
||||
href={`/blackbox?run=${encodeURIComponent(selected.run_id)}`}
|
||||
title="View the run that produced this"
|
||||
>
|
||||
<Icon name="blackbox" size={13} /> {selected.run_id.replace('run_', '').slice(0, 8)}
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
<h2 class="diff-title">{selected.title}</h2>
|
||||
|
||||
|
|
@ -284,10 +292,13 @@
|
|||
{#if diffNodeType(selected)}
|
||||
<span class="meta-pill">type: {diffNodeType(selected)}</span>
|
||||
{/if}
|
||||
{#each diffTags(selected) as t (t)}
|
||||
<span class="meta-pill tag">#{t}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{#each diffTags(selected) as t (t)}
|
||||
<span class="meta-pill tag">#{t}</span>
|
||||
{/each}
|
||||
{#if diffHash(selected)}
|
||||
<span class="meta-pill">hash: {diffHash(selected)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if diffContent(selected)}
|
||||
<pre class="diff-add"><span class="gutter">+</span>{diffContent(selected)}</pre>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@
|
|||
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
|
||||
import Icon, { type IconName } from '$lib/components/Icon.svelte';
|
||||
import { initTheme } from '$stores/theme';
|
||||
import { installDashboardTokenFromLocation } from '$stores/api';
|
||||
|
||||
let { children } = $props();
|
||||
let showCommandPalette = $state(false);
|
||||
|
|
@ -31,6 +32,11 @@
|
|||
let isMarketingRoute = $derived(dashboardPath === '/waitlist' || dashboardPath.startsWith('/waitlist/'));
|
||||
|
||||
onMount(() => {
|
||||
// Vestige opens the dashboard with the auth token in the URL fragment.
|
||||
// Install it into localStorage (and scrub it from the address bar) BEFORE
|
||||
// any API call or WebSocket connect, or the now-authenticated /api and /ws
|
||||
// endpoints reject every request.
|
||||
installDashboardTokenFromLocation();
|
||||
if (!isMarketingRoute) {
|
||||
websocket.connect();
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue