Merge: glassmorphism + gradient theme, and review-inbox transparency

App-wide glassmorphism (frosted cards/popovers/sheets/inputs/pills + gradient field, gradient primary actions, dark-glass sidebar) with contrast fixes; and a restructured review inbox showing each held item as Action -> Result -> Run log, with the run log surfacing latency, skills, tools called, memory hits, product-identity inclusion, raw output, and the assembled prompt (enriched assembler run endpoint).
This commit is contained in:
soroush.asadi
2026-06-16 07:23:23 +03:30
5 changed files with 287 additions and 64 deletions
+10 -5
View File
@@ -27,8 +27,11 @@ export function AppShell({ children }: { children: ReactNode }) {
const logout = useAuth((s) => s.logout) const logout = useAuth((s) => s.logout)
return ( return (
<div className="flex min-h-screen bg-background text-foreground"> <div className="flex min-h-screen text-foreground">
<aside className="flex w-60 shrink-0 flex-col bg-sidebar text-sidebar-foreground"> <aside
className="flex w-60 shrink-0 flex-col border-r border-white/15 text-sidebar-foreground backdrop-blur-2xl"
style={{ background: 'linear-gradient(180deg, oklch(0.27 0.1 287 / 0.78) 0%, oklch(0.2 0.085 298 / 0.78) 100%)' }}
>
<div className="flex items-center gap-3 px-5 py-4"> <div className="flex items-center gap-3 px-5 py-4">
<span className="grid size-8 place-items-center rounded-md bg-sidebar-primary font-bold text-sidebar-primary-foreground"> <span className="grid size-8 place-items-center rounded-md bg-sidebar-primary font-bold text-sidebar-primary-foreground">
T T
@@ -93,9 +96,11 @@ function NavItem({
const active = to ? location.pathname === to : false const active = to ? location.pathname === to : false
const className = cn( const className = cn(
'flex items-center gap-3 rounded-md px-3 py-2 text-sm', 'flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors',
active ? 'bg-sidebar-accent font-medium text-sidebar-accent-foreground' : 'text-sidebar-foreground/80', active
muted ? 'opacity-50' : 'hover:bg-sidebar-accent/60', ? 'bg-white/15 font-medium text-white shadow-sm ring-1 ring-white/15 backdrop-blur-sm'
: 'text-sidebar-foreground/80',
muted ? 'opacity-50' : 'hover:bg-white/10 hover:text-white',
) )
const content = ( const content = (
+105 -13
View File
@@ -8,21 +8,21 @@
:root { :root {
--radius: 0.625rem; --radius: 0.625rem;
/* Light content surface — the "calm command center" body. */ /* Glassmorphism body — frosted surfaces over a vivid gradient field. */
--background: oklch(0.99 0.003 280); --background: oklch(0.95 0.022 286);
--foreground: oklch(0.21 0.03 280); --foreground: oklch(0.19 0.03 280);
--card: oklch(1 0 0); --card: oklch(1 0 0 / 0.74);
--card-foreground: oklch(0.21 0.03 280); --card-foreground: oklch(0.19 0.03 280);
--popover: oklch(1 0 0); --popover: oklch(1 0 0 / 0.92);
--popover-foreground: oklch(0.21 0.03 280); --popover-foreground: oklch(0.19 0.03 280);
/* Brand: indigo, rationed so it always means something. */ /* Brand: indigo, rationed so it always means something. */
--primary: oklch(0.511 0.262 276.966); --primary: oklch(0.511 0.262 276.966);
--primary-foreground: oklch(0.985 0 0); --primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.967 0.012 280); --secondary: oklch(0.967 0.012 280);
--secondary-foreground: oklch(0.3 0.05 280); --secondary-foreground: oklch(0.3 0.05 280);
--muted: oklch(0.967 0.006 280); --muted: oklch(0.95 0.01 280 / 0.6);
--muted-foreground: oklch(0.52 0.03 280); --muted-foreground: oklch(0.44 0.035 280);
--accent: oklch(0.95 0.03 280); --accent: oklch(0.95 0.03 280);
--accent-foreground: oklch(0.4 0.16 277); --accent-foreground: oklch(0.4 0.16 277);
--destructive: oklch(0.577 0.245 27.325); --destructive: oklch(0.577 0.245 27.325);
@@ -57,6 +57,14 @@
body { body {
margin: 0; margin: 0;
font-family: "Hanken Grotesk Variable", system-ui, sans-serif; font-family: "Hanken Grotesk Variable", system-ui, sans-serif;
/* Vivid gradient field behind the frosted-glass surfaces — gives the glass something to lift off. */
background:
radial-gradient(1200px 640px at 6% -10%, oklch(0.62 0.2 288 / 0.34), transparent 62%),
radial-gradient(1050px 720px at 112% 4%, oklch(0.7 0.16 210 / 0.27), transparent 58%),
radial-gradient(960px 680px at 48% 122%, oklch(0.72 0.18 334 / 0.22), transparent 62%),
var(--background);
background-attachment: fixed;
min-height: 100vh;
} }
@theme inline { @theme inline {
@@ -110,11 +118,11 @@ body {
} }
.dark { .dark {
--background: oklch(0.205 0.03 280); --background: oklch(0.17 0.035 287);
--foreground: oklch(0.985 0 0); --foreground: oklch(0.985 0 0);
--card: oklch(0.257 0.04 281); --card: oklch(0.31 0.055 286 / 0.62);
--card-foreground: oklch(0.985 0 0); --card-foreground: oklch(0.985 0 0);
--popover: oklch(0.257 0.04 281); --popover: oklch(0.26 0.05 286 / 0.94);
--popover-foreground: oklch(0.985 0 0); --popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.673 0.182 276.935); --primary: oklch(0.673 0.182 276.935);
--primary-foreground: oklch(0.205 0.03 280); --primary-foreground: oklch(0.205 0.03 280);
@@ -143,9 +151,93 @@ body {
@apply border-border outline-ring/50; @apply border-border outline-ring/50;
} }
body { body {
@apply bg-background text-foreground; @apply text-foreground;
} }
html { html {
@apply font-sans; @apply font-sans;
} }
} }
/* ---- Glassmorphism + gradients (app-wide, keyed on shadcn data-slots) ----
* Unlayered so they sit above Tailwind utilities; inline styles still win, so the
* gradient Team cards keep their own backgrounds. */
[data-slot="card"] {
backdrop-filter: blur(20px) saturate(155%);
-webkit-backdrop-filter: blur(20px) saturate(155%);
border: 1px solid color-mix(in oklch, white 65%, transparent);
box-shadow: 0 16px 44px -20px oklch(0.32 0.13 285 / 0.42), inset 0 1px 0 0 oklch(1 0 0 / 0.55);
}
.dark [data-slot="card"] {
border-color: color-mix(in oklch, white 16%, transparent);
box-shadow: 0 18px 48px -22px oklch(0 0 0 / 0.65), inset 0 1px 0 0 oklch(1 0 0 / 0.08);
}
[data-slot="popover-content"],
[data-slot="select-content"],
[data-slot="dropdown-menu-content"],
[data-slot="sheet-content"] {
backdrop-filter: blur(18px) saturate(160%);
-webkit-backdrop-filter: blur(18px) saturate(160%);
border: 1px solid color-mix(in oklch, white 40%, transparent);
}
.dark [data-slot="popover-content"],
.dark [data-slot="select-content"],
.dark [data-slot="dropdown-menu-content"],
.dark [data-slot="sheet-content"] {
border-color: color-mix(in oklch, white 12%, transparent);
}
/* Primary actions become a gradient; secondary/outline become glass. */
[data-slot="button"][data-variant="default"] {
background-image: linear-gradient(135deg, oklch(0.58 0.24 277) 0%, oklch(0.56 0.25 305) 100%);
box-shadow: 0 8px 20px -10px oklch(0.5 0.23 288 / 0.7);
}
[data-slot="button"][data-variant="default"]:hover {
background-image: linear-gradient(135deg, oklch(0.62 0.24 277) 0%, oklch(0.6 0.25 305) 100%);
}
[data-slot="button"][data-variant="outline"],
[data-slot="button"][data-variant="secondary"] {
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
background-color: color-mix(in oklch, var(--card) 65%, transparent);
border: 1px solid color-mix(in oklch, white 40%, transparent);
}
.dark [data-slot="button"][data-variant="outline"],
.dark [data-slot="button"][data-variant="secondary"] {
border-color: color-mix(in oklch, white 14%, transparent);
}
/* Frosted form fields — kept more opaque than cards so input text stays high-contrast. */
[data-slot="select-trigger"],
[data-slot="input"],
input:not([type="checkbox"]):not([type="radio"]):not([type="range"]),
textarea {
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
background-color: color-mix(in oklch, white 74%, transparent) !important;
border-color: color-mix(in oklch, oklch(0.5 0.04 285) 35%, transparent);
}
.dark [data-slot="select-trigger"],
.dark [data-slot="input"],
.dark input:not([type="checkbox"]):not([type="radio"]):not([type="range"]),
.dark textarea {
background-color: color-mix(in oklch, oklch(0.32 0.05 286) 82%, transparent) !important;
border-color: color-mix(in oklch, white 16%, transparent);
}
/* Pills: frosted for neutral variants, gradient for the primary one. */
[data-slot="badge"][data-variant="secondary"],
[data-slot="badge"][data-variant="outline"] {
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
background-color: color-mix(in oklch, white 58%, transparent);
border-color: color-mix(in oklch, white 55%, transparent);
}
.dark [data-slot="badge"][data-variant="secondary"],
.dark [data-slot="badge"][data-variant="outline"] {
background-color: color-mix(in oklch, white 12%, transparent);
border-color: color-mix(in oklch, white 16%, transparent);
}
[data-slot="badge"][data-variant="default"] {
background-image: linear-gradient(135deg, oklch(0.58 0.24 277), oklch(0.56 0.25 305));
}
+164 -44
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { ChevronDown, ChevronRight, ShieldAlert } from 'lucide-react' import { ChevronDown, ChevronRight, Clock, Cpu, ScrollText, ShieldAlert, Wrench } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { AppShell } from '@/components/AppShell' import { AppShell } from '@/components/AppShell'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
@@ -30,6 +30,43 @@ interface ReviewItem {
createdAtUtc: string createdAtUtc: string
} }
interface RunDetail {
status: string
output: string | null
prompt: string | null
error: string | null
trace: string | null
resultJson: string | null
latencyMs: number | null
}
interface RunTrace {
agent?: string
autonomy?: string
skills?: string[]
tools?: string[]
docs?: string[]
memories?: number
product?: { productId?: string | null; identity?: boolean }
task?: { taskType?: string }
}
interface RunResult {
action?: string
risk?: string
skill?: string
toolCalls?: { tool: string; server?: string | null; ok: boolean }[]
}
function parseJson<T>(value: string | null | undefined): T | null {
if (!value) return null
try {
return JSON.parse(value) as T
} catch {
return null
}
}
export function ReviewsPage() { export function ReviewsPage() {
const organizationId = useAuth((s) => s.organizationId) const organizationId = useAuth((s) => s.organizationId)
const [items, setItems] = useState<ReviewItem[] | null>(null) const [items, setItems] = useState<ReviewItem[] | null>(null)
@@ -54,7 +91,8 @@ export function ReviewsPage() {
<div className="mb-6"> <div className="mb-6">
<h1 className="text-2xl font-semibold tracking-tight">Review inbox</h1> <h1 className="text-2xl font-semibold tracking-tight">Review inbox</h1>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Held agent actions awaiting your decision. Edit before approving your edits feed the metric. Held agent actions awaiting your decision. See the action, the result, and the run log before
you approve. Your edits feed the metric.
</p> </p>
</div> </div>
@@ -90,19 +128,32 @@ export function ReviewsPage() {
function ReviewCard({ item, onDecided }: { item: ReviewItem; onDecided: (id: string) => void }) { function ReviewCard({ item, onDecided }: { item: ReviewItem; onDecided: (id: string) => void }) {
const [content, setContent] = useState(item.content) const [content, setContent] = useState(item.content)
const [childrenText, setChildrenText] = useState(item.childTitles.join('\n')) const [childrenText, setChildrenText] = useState(item.childTitles.join('\n'))
const [showTrace, setShowTrace] = useState(false) const [showLog, setShowLog] = useState(false)
const [run, setRun] = useState<RunDetail | null>(null)
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const destructive = item.risk.toLowerCase() === 'destructive' const destructive = item.risk.toLowerCase() === 'destructive'
const childCount = childrenText.split('\n').map((l) => l.trim()).filter(Boolean).length
// Lazily pull the full run (latency, tool calls, raw output, assembled prompt) the first time the
// approver opens the log.
const toggleLog = async () => {
const next = !showLog
setShowLog(next)
if (next && !run) {
try {
setRun(await api.get<RunDetail>(`/api/assembler/runs/${item.agentRunId}`))
} catch {
// The run row may be gone; the assembly trace on the item still renders below.
}
}
}
async function decide(action: 'approve' | 'sendback') { async function decide(action: 'approve' | 'sendback') {
setBusy(true) setBusy(true)
try { try {
if (action === 'approve') { if (action === 'approve') {
const childTitles = childrenText const childTitles = childrenText.split('\n').map((line) => line.trim()).filter(Boolean)
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
const result = await api.post<{ editDistance: number | null; decision: string }>( const result = await api.post<{ editDistance: number | null; decision: string }>(
`/api/governance/reviews/${item.id}/approve`, `/api/governance/reviews/${item.id}/approve`,
{ content, childTitles }, { content, childTitles },
@@ -138,23 +189,28 @@ function ReviewCard({ item, onDecided }: { item: ReviewItem; onDecided: (id: str
{destructive && <ShieldAlert />} {destructive && <ShieldAlert />}
{item.risk} {item.risk}
</Badge> </Badge>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">{new Date(item.createdAtUtc).toLocaleString()}</span>
{new Date(item.createdAtUtc).toLocaleString()}
</span>
</div> </div>
</div> </div>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="flex flex-col gap-4"> <CardContent className="flex flex-col gap-4">
{/* Action — what approving will actually do. */}
<div
className={`rounded-lg border px-3 py-2 text-sm ${
destructive ? 'border-destructive/40 bg-destructive/10 text-destructive' : 'border-primary/30 bg-primary/5'
}`}
>
<span className="font-medium capitalize">{item.actionKind.replace(/-/g, ' ')}</span> ·{' '}
{destructive
? 'Destructive — always held for a human.'
: `On approve, this artifact is written to the board${childCount ? ` and ${childCount} child task${childCount === 1 ? '' : 's'} created` : ''}.`}
</div>
{/* Result — the proposed artifact + child tasks (both editable). */}
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label htmlFor={`content-${item.id}`}>Proposed artifact</Label> <Label htmlFor={`content-${item.id}`}>Result · proposed artifact</Label>
<MarkdownEditor <MarkdownEditor id={`content-${item.id}`} value={content} onChange={setContent} rows={6} mono />
id={`content-${item.id}`}
value={content}
onChange={setContent}
rows={6}
mono
/>
</div> </div>
{content !== item.content && ( {content !== item.content && (
@@ -165,13 +221,9 @@ function ReviewCard({ item, onDecided }: { item: ReviewItem; onDecided: (id: str
segment.kind === 'same' ? ( segment.kind === 'same' ? (
<span key={i}>{segment.text}</span> <span key={i}>{segment.text}</span>
) : segment.kind === 'removed' ? ( ) : segment.kind === 'removed' ? (
<del key={i} className="rounded bg-destructive/15 text-destructive"> <del key={i} className="rounded bg-destructive/15 text-destructive">{segment.text}</del>
{segment.text}
</del>
) : ( ) : (
<ins key={i} className="rounded bg-seat-ai/15 font-medium text-primary no-underline"> <ins key={i} className="rounded bg-seat-ai/15 font-medium text-primary no-underline">{segment.text}</ins>
{segment.text}
</ins>
), ),
)} )}
</div> </div>
@@ -189,36 +241,104 @@ function ReviewCard({ item, onDecided }: { item: ReviewItem; onDecided: (id: str
/> />
</div> </div>
{/* Run log — how the agent got here. */}
<button <button
type="button" type="button"
onClick={() => setShowTrace((v) => !v)} onClick={toggleLog}
className="flex items-center gap-1 self-start text-xs font-medium text-primary hover:underline" className="flex items-center gap-1.5 self-start text-xs font-medium text-primary hover:underline"
> >
{showTrace ? <ChevronDown className="size-3.5" /> : <ChevronRight className="size-3.5" />} {showLog ? <ChevronDown className="size-3.5" /> : <ChevronRight className="size-3.5" />}
Reasoning trace <ScrollText className="size-3.5" /> Run log
</button> </button>
{showTrace && ( {showLog && <RunLog item={item} run={run} />}
<pre className="max-h-48 overflow-auto rounded-lg bg-muted p-3 text-xs">{formatTrace(item.trace)}</pre>
)}
<div className="flex items-center justify-end gap-2"> <div className="flex items-center justify-end gap-2">
<Button variant="outline" disabled={busy} onClick={() => decide('sendback')}> <Button variant="outline" disabled={busy} onClick={() => decide('sendback')}>Send back</Button>
Send back <Button disabled={busy} onClick={() => decide('approve')}>{busy ? 'Working…' : 'Approve'}</Button>
</Button>
<Button disabled={busy} onClick={() => decide('approve')}>
{busy ? 'Working…' : 'Approve'}
</Button>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
) )
} }
function formatTrace(trace: string | null): string { function RunLog({ item, run }: { item: ReviewItem; run: RunDetail | null }) {
if (!trace) return 'No trace captured.' // The assembly trace is on the review item; the run adds latency, tool-call outcomes, and raw output.
try { const trace = parseJson<RunTrace>(run?.trace ?? item.trace)
return JSON.stringify(JSON.parse(trace), null, 2) const result = parseJson<RunResult>(run?.resultJson)
} catch { const [showRaw, setShowRaw] = useState(false)
return trace const [showPrompt, setShowPrompt] = useState(false)
} const toolCalls = result?.toolCalls ?? []
return (
<div className="flex flex-col gap-2 rounded-lg border bg-muted/30 p-3 text-xs">
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-muted-foreground">
<span className="inline-flex items-center gap-1">
<Cpu className="size-3.5" /> {trace?.agent ?? 'agent'} · {trace?.autonomy ?? '—'}
</span>
{run?.latencyMs != null && (
<span className="inline-flex items-center gap-1">
<Clock className="size-3.5" /> {(run.latencyMs / 1000).toFixed(1)}s
</span>
)}
{trace?.product?.identity && <span>· product identity included</span>}
{typeof trace?.memories === 'number' && <span>· {trace.memories} memory hit{trace.memories === 1 ? '' : 's'}</span>}
</div>
<LogRow label="Skills applied" value={trace?.skills?.length ? trace.skills.join(', ') : 'none'} />
{trace?.tools?.length ? <LogRow label="Tools available" value={trace.tools.join(', ')} /> : null}
{trace?.docs?.length ? <LogRow label="Docs" value={trace.docs.join(', ')} /> : null}
<div className="flex gap-2">
<span className="w-28 shrink-0 font-medium text-foreground">Tools called</span>
<span className="min-w-0 flex-1">
{toolCalls.length === 0 ? (
<span className="text-muted-foreground">none</span>
) : (
<span className="flex flex-col gap-0.5">
{toolCalls.map((t, i) => (
<span key={i} className="inline-flex items-center gap-1">
<Wrench className="size-3" /> {t.tool}
{t.server ? ` · ${t.server}` : ''}
<Badge variant={t.ok ? 'outline' : 'destructive'} className="ml-1 h-4 px-1.5 text-[10px]">
{t.ok ? 'ok' : 'failed'}
</Badge>
</span>
))}
</span>
)}
</span>
</div>
{run?.error && <LogRow label="Error" value={run.error} />}
{run?.output && (
<div className="flex flex-col gap-1">
<button type="button" onClick={() => setShowRaw((v) => !v)} className="flex items-center gap-1 self-start font-medium text-foreground hover:underline">
{showRaw ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />} Raw model output
</button>
{showRaw && <pre className="max-h-48 overflow-auto rounded bg-background/60 p-2 whitespace-pre-wrap">{run.output}</pre>}
</div>
)}
{run?.prompt && (
<div className="flex flex-col gap-1">
<button type="button" onClick={() => setShowPrompt((v) => !v)} className="flex items-center gap-1 self-start font-medium text-foreground hover:underline">
{showPrompt ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />} Assembled prompt
</button>
{showPrompt && <pre className="max-h-56 overflow-auto rounded bg-background/60 p-2 whitespace-pre-wrap">{run.prompt}</pre>}
</div>
)}
{!run && !trace && <span className="text-muted-foreground">Loading run</span>}
</div>
)
}
function LogRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex gap-2">
<span className="w-28 shrink-0 font-medium text-foreground">{label}</span>
<span className="min-w-0 flex-1 break-words">{value}</span>
</div>
)
} }
@@ -12,7 +12,12 @@ internal sealed record RunResponse(
string? ActionRisk, string? ActionRisk,
string? Prompt, string? Prompt,
string? Output, string? Output,
string? Error); string? Error,
string? Trace,
string? ResultJson,
long? LatencyMs,
DateTimeOffset CreatedAtUtc,
DateTimeOffset? CompletedAtUtc);
internal sealed record AgentActivityResponse( internal sealed record AgentActivityResponse(
Guid AgentId, Guid AgentId,
@@ -85,5 +85,6 @@ internal static class AssemblerEndpoints
private static RunResponse ToResponse(AgentRun run) => new( private static RunResponse ToResponse(AgentRun run) => new(
run.Id, run.SeatId, run.WorkItemId, run.AgentId, run.Status.ToString(), run.Id, run.SeatId, run.WorkItemId, run.AgentId, run.Status.ToString(),
run.ActionType, run.ActionRisk, run.Prompt, run.Output, run.Error); run.ActionType, run.ActionRisk, run.Prompt, run.Output, run.Error,
run.Trace, run.ResultJson, run.LatencyMs, run.CreatedAtUtc, run.CompletedAtUtc);
} }