rail: bigger card thumbnails + a visible × on every card (cancel / soft-delete)

- Thumbnails grow to ~half the card width and a taller 4:5 crop (44% on mobile)
  so the render is actually legible in Up next and History.
- Every card gets an always-visible × (fast path) beside the ⋯ overflow menu —
  ONE shared handler, no second mechanism: a queued run cancels immediately
  (POST /v1/queue/cancel); a finished result is a destructive soft-delete
  (POST /v1/library/status status=deleted) and asks first with a small inline
  confirm. History hides soft-deleted results (optimistic + reconciled from the
  org's deleted set via /v1/library) so the delete persists across reloads.
This commit is contained in:
Hanzo AI
2026-07-17 15:18:35 -07:00
parent a7958511fb
commit 3ebb7d759d
4 changed files with 63 additions and 10 deletions
+14
View File
@@ -124,6 +124,20 @@ export const rate = (key: string, patch: { stars?: number; notes?: string; path?
body: JSON.stringify({ key, ...patch }),
})
// Soft-delete a finished result — the library status lifecycle ('deleted'), the same
// mechanism the assets view uses. Keyed by the run's output path.
export const softDelete = (path: string) =>
j<{ ok: boolean; path: string; status: string }>(`${BASE}/v1/library/status`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path, status: 'deleted' }),
})
// The org's soft-deleted asset paths — so History hides results deleted here or in the
// assets view, and the delete persists across reloads.
export const getDeletedPaths = () =>
j<{ assets: { path: string; status?: string }[] }>(`${BASE}/v1/library`)
.then(r => (r.assets || []).filter(a => a.status === 'deleted').map(a => a.path))
.catch(() => [] as string[])
// Favorites: the heart toggle, a per-org set keyed by run id. Separate concern from
// ratings — a bookmark, not quality feedback.
export const getFavorites = () =>
+19 -2
View File
@@ -54,7 +54,7 @@ export interface CardProps {
favorite: boolean
now: number
version?: { pos: number; len: number }
onCancel: (run: Run) => void
onRemove: (run: Run) => void
onAmend: (run: Run, files: File[]) => Promise<void>
onRate: (run: Run, patch: { stars?: number; notes?: string; path?: string }) => void
onFavorite: (run: Run) => void
@@ -80,6 +80,7 @@ export function RunCard(p: CardProps) {
const [files, setFiles] = useState<File[]>([])
const [busy, setBusy] = useState(false)
const [fileOver, setFileOver] = useState(false)
const [confirmDel, setConfirmDel] = useState(false)
const active = isActive(run)
const src = runThumb(run)
@@ -121,6 +122,12 @@ export function RunCard(p: CardProps) {
p.onRate(run, { stars, notes, path: run.output || undefined })
setPanel('none')
}
// The × (and the menu's Delete) share ONE handler: a queued run cancels straight
// away; a finished result is a destructive soft-delete, so it asks first.
function requestDelete() {
if (active) p.onRemove(run)
else setConfirmDel(true)
}
return (
<article
@@ -136,6 +143,16 @@ export function RunCard(p: CardProps) {
{fresh && <span className="rc-new">New</span>}
</a>
<button className="rc-x" aria-label={active ? 'cancel' : 'delete'}
title={active ? 'Cancel' : 'Delete'} onClick={requestDelete}>×</button>
{confirmDel && (
<div className="rc-confirm" role="alertdialog" aria-label="confirm delete">
<span>Delete result?</span>
<button className="danger" onClick={() => { setConfirmDel(false); p.onRemove(run) }}>Delete</button>
<button onClick={() => setConfirmDel(false)}>Keep</button>
</div>
)}
<div className="rc-body">
<div className="rc-title" title={run.prompt || run.kind}>{run.prompt || `(${run.kind})`}</div>
<div className="rc-meta">{meta}</div>
@@ -158,7 +175,7 @@ export function RunCard(p: CardProps) {
{menu && (
<div className="rc-menu" role="menu" onMouseLeave={() => setMenu(false)}>
{active && <button role="menuitem" onClick={() => { setMenu(false); p.onCancel(run) }}>Delete</button>}
<button role="menuitem" onClick={() => { setMenu(false); requestDelete() }}>Delete</button>
{active && <button role="menuitem" onClick={() => { setMenu(false); setPanel('refs') }}>Add references</button>}
<button role="menuitem" onClick={() => { setMenu(false); setStars(rating?.stars ?? 0); setNotes(rating?.notes ?? ''); setPanel('rate') }}>Rate &amp; note</button>
<button role="menuitem" onClick={() => { setMenu(false); p.onFavorite(run) }}>{favorite ? 'Unfavorite' : 'Favorite'}</button>
+22 -6
View File
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import {
getWorklog, getQueueStatus, getRatings, getFavorites, getStacks, saveStacks,
cancelRun, amendRun, uploadRef, rate, setFavorite, saveTemplate, getLineage,
thumbURL, fileURL,
softDelete, getDeletedPaths, thumbURL, fileURL,
type Run, type QueueStatus, type QueueItem, type Rating, type Stack, type Lineage,
} from './api'
import { RunCard, runThumb, idLabel, relTime, fmtDur } from './card'
@@ -68,6 +68,8 @@ export function Rail(props: RailProps) {
const [ratings, setRatings] = useState<Record<string, Rating>>({})
const [favorites, setFavorites] = useState<Record<string, { ts: number }>>({})
const [stacks, setStacks] = useState<Stack[]>([])
const [deletedPaths, setDeletedPaths] = useState<Set<string>>(new Set())
const [hidden, setHidden] = useState<Set<string>>(new Set())
const [query, setQuery] = useState('')
const [openStack, setOpenStack] = useState<string | null>(null)
@@ -85,9 +87,10 @@ export function Rail(props: RailProps) {
const loadRatings = useCallback(() => getRatings().then(setRatings), [])
const loadFavorites = useCallback(() => getFavorites().then(setFavorites), [])
const loadStacks = useCallback(() => getStacks().then(setStacks), [])
const loadDeleted = useCallback(() => getDeletedPaths().then(ps => setDeletedPaths(new Set(ps))), [])
useEffect(() => { pollLive(); const t = setInterval(pollLive, 4000); return () => clearInterval(t) }, [pollLive])
useEffect(() => { loadRatings(); loadFavorites(); loadStacks() }, [loadRatings, loadFavorites, loadStacks])
useEffect(() => { loadRatings(); loadFavorites(); loadStacks(); loadDeleted() }, [loadRatings, loadFavorites, loadStacks, loadDeleted])
useEffect(() => { if (!err) return; const t = setTimeout(() => setErr(''), 4000); return () => clearTimeout(t) }, [err])
// Active = the runs the server still counts as in-flight (present in queue/status),
@@ -95,7 +98,8 @@ export function Rail(props: RailProps) {
// linger in Up next forever (matches the queue's own 30-min active window).
const active = rows.filter(r => (r.status === 'queued' || r.status === 'running') && (q.items[r.id] || now - r.ts <= 1800))
.sort((a, b) => ((q.items[a.id]?.position ?? 999) - (q.items[b.id]?.position ?? 999)) || (a.ts - b.ts))
const history = rows.filter(r => r.status === 'done' || r.status === 'failed' || r.status === 'cancelled')
const history = rows.filter(r => (r.status === 'done' || r.status === 'failed' || r.status === 'cancelled')
&& !hidden.has(r.id) && !(r.output && deletedPaths.has(r.output)))
const favRuns = rows.filter(r => favorites[r.id])
const nextRun = active[0]
const chainMap = chains(rows)
@@ -110,8 +114,20 @@ export function Rail(props: RailProps) {
setErr('That run changed — refreshed, pick it again.')
return true
}
async function onCancel(run: Run) {
try { await cancelRun(run.id) } catch (e) { if (!heal(e as Error)) setErr((e as Error).message) }
// The ONE remove handler behind both the card's × and its menu Delete: a queued run
// cancels; a finished result soft-deletes its output (the library lifecycle) and is
// hidden from History optimistically (reconciled from the server's deleted set).
async function onRemove(run: Run) {
if (run.status === 'queued' || run.status === 'running') {
try { await cancelRun(run.id) } catch (e) { if (!heal(e as Error)) setErr((e as Error).message) }
pollLive(); return
}
setHidden(h => new Set(h).add(run.id))
const out = run.output
if (out) {
try { await softDelete(out); setDeletedPaths(d => new Set(d).add(out)) }
catch (e) { if (!heal(e as Error)) setErr((e as Error).message) }
}
pollLive()
}
async function onAmend(run: Run, files: File[]) {
@@ -192,7 +208,7 @@ export function Rail(props: RailProps) {
<RunCard
key={run.id} run={run} qitem={q.items[run.id]} rating={ratings[run.id]}
favorite={!!favorites[run.id]} now={now} version={chainMap.get(run.id)}
onCancel={onCancel} onAmend={onAmend} onRate={onRate} onFavorite={onFavorite}
onRemove={onRemove} onAmend={onAmend} onRate={onRate} onFavorite={onFavorite}
onAddToStack={r => setStackPick(r)} onOpenVersions={onOpenVersions}
onUseRef={r => props.onUseRef(idLabel(r))} dnd={dndFor(run)}
/>
+8 -2
View File
@@ -116,7 +116,7 @@ button { font: inherit; color: inherit; }
.rc:hover { border-color: var(--dim); }
.rc.stack-intent { border-color: var(--halo); box-shadow: 0 0 0 3px color-mix(in srgb, var(--halo) 35%, transparent); }
.rc.file-over { border-color: var(--brand); border-style: dashed; }
.rc-thumb { position: relative; flex: 0 0 40%; max-width: 40%; border-radius: 8px; overflow: hidden; background: var(--panel2); border: 1px solid var(--line); display: block; aspect-ratio: 1 / 1; text-decoration: none; }
.rc-thumb { position: relative; flex: 0 0 48%; max-width: 48%; border-radius: 8px; overflow: hidden; background: var(--panel2); border: 1px solid var(--line); display: block; aspect-ratio: 4 / 5; text-decoration: none; }
.rc-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
.rc-ph { display: grid; place-items: center; width: 100%; height: 100%; color: var(--dim); font-size: 12px; }
.rc-badge { position: absolute; left: 5px; bottom: 5px; font-size: 11px; font-weight: 600; padding: 1px 6px; border-radius: 6px; background: color-mix(in srgb, #000 62%, transparent); color: #fff; }
@@ -135,6 +135,12 @@ button { font: inherit; color: inherit; }
.rc-fav { color: var(--bad); }
.rc-more { position: absolute; right: 4px; top: 4px; }
.rc-more:hover { background: var(--panel2); color: var(--text); }
/* Always-visible fast-path delete/cancel (× top-left of the thumb) */
.rc-x { position: absolute; left: 6px; top: 6px; z-index: 4; width: 22px; height: 22px; border-radius: 999px; border: 0; background: color-mix(in srgb, #000 55%, transparent); color: #fff; font-size: 16px; line-height: 1; cursor: pointer; display: grid; place-items: center; opacity: .9; }
.rc-x:hover { background: var(--bad); opacity: 1; }
.rc-confirm { position: absolute; left: 6px; top: 6px; z-index: 7; display: flex; align-items: center; gap: 6px; background: var(--panel); border: 1px solid var(--line); border-radius: 9px; padding: 5px 7px; box-shadow: 0 6px 20px rgba(0,0,0,.28); font-size: 12px; }
.rc-confirm button { border: 1px solid var(--line); background: var(--panel2); color: var(--text); border-radius: 6px; padding: 3px 9px; font-size: 12px; cursor: pointer; }
.rc-confirm .danger { background: var(--bad); color: #fff; border-color: transparent; }
.rc-menu { position: absolute; z-index: 20; right: 6px; top: 30px; background: var(--panel); border: 1px solid var(--line); border-radius: 10px; box-shadow: 0 8px 28px rgba(0,0,0,.22); padding: 5px; display: flex; flex-direction: column; min-width: 168px; }
.rc-menu > button, .rc-menu > a { text-align: left; background: transparent; border: 0; border-radius: 7px; padding: 8px 10px; cursor: pointer; color: var(--text); text-decoration: none; font-size: 14px; }
@@ -221,7 +227,7 @@ button { font: inherit; color: inherit; }
.rail-main, .sections, .sec, .sec-body, .stacks-view { overflow: visible; min-height: 0; }
.sec, .sec.col { flex: 0 0 auto !important; }
.chips { position: sticky; top: 0; z-index: 5; background: var(--bg); }
.rc-thumb { flex-basis: 34%; max-width: 34%; }
.rc-thumb { flex-basis: 44%; max-width: 44%; }
.stacks-view { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 420px) {