import * as React from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useConfig, buildDaemonRequest } from '@/state/config'
import { useI18n } from '@/state/i18n'
import { ipc } from '@/lib/api/desktop'
import { asEnvelope } from '@/lib/error'
import { qk } from '@/lib/queryKeys'
import type {
DesktopHistoryResponse,
DesktopLifecycleAction,
DesktopRecordResponse,
} from '@/lib/types/desktop'
import type { LedgerEntry } from '@/lib/types/lifecycle'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { ErrorBanner } from '@/components/error-banner'
import { DraftDialog } from '@/components/draft-dialog'
import {
ActionConfirmDialog,
type ActionConfirmMetadata,
} from '@/components/action-confirm-dialog'
import {
actionLabel,
entrySummaryLine,
stateLabel,
stateVariant,
} from '@/lib/lifecycle-format'
import {
Archive,
CheckCircle2,
FilePlus,
Inbox,
Loader2,
Search,
Sparkles,
TrendingUp,
Zap,
} from 'lucide-react'
type QueueTab = 'pending' | 'ready'
type ConfirmTarget =
| { kind: 'single'; recordId: string; title?: string }
| { kind: 'batch'; ids: string[] }
const ACTION_META: Record<
DesktopLifecycleAction,
{ icon: typeof CheckCircle2; variant: 'default' | 'outline' | 'destructive' }
> = {
accept: { icon: CheckCircle2, variant: 'default' },
promote: { icon: TrendingUp, variant: 'outline' },
archive: { icon: Archive, variant: 'destructive' },
}
function isEditingTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false
if (target instanceof HTMLInputElement) return true
if (target instanceof HTMLTextAreaElement) return true
if (target instanceof HTMLSelectElement) return true
if (target.isContentEditable) return true
return false
}
export function WorkbenchPage({ navigateToId }: { navigateToId?: string | null }) {
const cfg = useConfig()
const { t } = useI18n()
const qc = useQueryClient()
const [queueTab, setQueueTab] = React.useState<QueueTab>('pending')
const [selectedId, setSelectedId] = React.useState<string | null>(null)
const [draftMode, setDraftMode] = React.useState<'manual' | 'propose' | null>(null)
const [searchQuery, setSearchQuery] = React.useState('')
const [typeFilter, setTypeFilter] = React.useState<string>('all')
const [checkedIds, setCheckedIds] = React.useState<Set<string>>(new Set())
const [pendingAction, setPendingAction] = React.useState<DesktopLifecycleAction | null>(null)
const [pendingTarget, setPendingTarget] = React.useState<ConfirmTarget | null>(null)
const searchInputRef = React.useRef<HTMLInputElement | null>(null)
const daemon = React.useMemo(() => buildDaemonRequest(cfg), [cfg])
const configPath = cfg.configPath
const workbenchQuery = useQuery({
queryKey: qk.workbench(configPath),
queryFn: () =>
ipc.loadWorkbench({
config_path: configPath,
daemon,
}),
enabled: configPath.trim().length > 0,
})
const recordQuery = useQuery({
queryKey: selectedId ? qk.record(configPath, selectedId) : ['record', '__none__'],
queryFn: () =>
ipc.getRecord({
config_path: configPath,
record_id: selectedId as string,
daemon,
}),
enabled: Boolean(selectedId) && configPath.trim().length > 0,
})
const historyQuery = useQuery({
queryKey: selectedId ? qk.history(configPath, selectedId) : ['history', '__none__'],
queryFn: () =>
ipc.getHistory({
config_path: configPath,
record_id: selectedId as string,
daemon,
}),
enabled: Boolean(selectedId) && configPath.trim().length > 0,
})
const actionMutation = useMutation({
mutationFn: async (input: {
recordId: string
action: DesktopLifecycleAction
metadata: ActionConfirmMetadata
}) => {
await ipc.applyMemoryAction({
config_path: configPath,
record_id: input.recordId,
action: input.action,
metadata: {
actor: input.metadata.actor || null,
reason: input.metadata.reason || null,
evidence_refs: input.metadata.evidence_refs,
},
})
return input
},
onSuccess: ({ recordId }) => {
qc.invalidateQueries({ queryKey: qk.workbench(configPath) })
qc.invalidateQueries({ queryKey: qk.record(configPath, recordId) })
qc.invalidateQueries({ queryKey: qk.history(configPath, recordId) })
},
})
const batchMutation = useMutation({
mutationFn: async (input: {
ids: string[]
action: DesktopLifecycleAction
metadata: ActionConfirmMetadata
}) => {
const meta = {
actor: input.metadata.actor || null,
reason: input.metadata.reason || null,
evidence_refs: input.metadata.evidence_refs,
}
for (const recordId of input.ids) {
await ipc.applyMemoryAction({
config_path: configPath,
record_id: recordId,
action: input.action,
metadata: meta,
})
}
return input.ids
},
onSuccess: (ids) => {
qc.invalidateQueries({ queryKey: qk.workbench(configPath) })
for (const id of ids) {
qc.invalidateQueries({ queryKey: qk.record(configPath, id) })
qc.invalidateQueries({ queryKey: qk.history(configPath, id) })
}
setCheckedIds(new Set())
},
})
React.useEffect(() => {
if (navigateToId) {
setSelectedId(navigateToId)
}
}, [navigateToId])
const snapshot = workbenchQuery.data ?? null
const detail: DesktopRecordResponse | null = (recordQuery.data as DesktopRecordResponse | null | undefined) ?? null
const history: DesktopHistoryResponse | null = historyQuery.data ?? null
const error =
workbenchQuery.error ??
recordQuery.error ??
historyQuery.error ??
actionMutation.error ??
batchMutation.error ??
null
const errorEnvelope = error ? asEnvelope(error) : null
const loadingQueue = workbenchQuery.isFetching
const loadingDetail = recordQuery.isFetching || historyQuery.isFetching
const actionLoading: DesktopLifecycleAction | null =
actionMutation.isPending && actionMutation.variables
? actionMutation.variables.action
: null
const batchLoading = batchMutation.isPending
const dialogOpen = pendingAction !== null && pendingTarget !== null
const rawEntries =
snapshot?.snapshot[queueTab === 'pending' ? 'pending_review' : 'wakeup_ready'] ?? []
const entries = React.useMemo(() => {
let filtered = rawEntries
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase()
filtered = filtered.filter(
(e) =>
e.record.title.toLowerCase().includes(q) ||
e.record.summary.toLowerCase().includes(q)
)
}
if (typeFilter !== 'all') {
filtered = filtered.filter((e) => e.record.memory_type === typeFilter)
}
if (cfg.currentProjectId) {
filtered = filtered.filter(
(e) => e.record.project_id === cfg.currentProjectId,
)
}
return filtered
}, [rawEntries, searchQuery, typeFilter, cfg.currentProjectId])
const memoryTypes = React.useMemo(() => {
const types = new Set(rawEntries.map((e) => e.record.memory_type))
return Array.from(types).sort()
}, [rawEntries])
function toggleCheck(recordId: string) {
setCheckedIds((prev) => {
const next = new Set(prev)
if (next.has(recordId)) next.delete(recordId)
else next.add(recordId)
return next
})
}
function toggleAll() {
const visibleIds = entries.map((e) => e.record_id)
const allChecked = visibleIds.every((id) => checkedIds.has(id))
if (allChecked) {
setCheckedIds((prev) => {
const next = new Set(prev)
for (const id of visibleIds) next.delete(id)
return next
})
} else {
setCheckedIds((prev) => {
const next = new Set(prev)
for (const id of visibleIds) next.add(id)
return next
})
}
}
const requestSingleAction = React.useCallback(
(recordId: string, action: DesktopLifecycleAction, title?: string) => {
setPendingAction(action)
setPendingTarget({ kind: 'single', recordId, title })
},
[],
)
const requestBatchAction = React.useCallback(
(ids: string[], action: DesktopLifecycleAction) => {
setPendingAction(action)
setPendingTarget({ kind: 'batch', ids })
},
[],
)
function closeDialog() {
setPendingAction(null)
setPendingTarget(null)
}
function handleConfirm(metadata: ActionConfirmMetadata) {
if (!pendingAction || !pendingTarget) return
if (pendingTarget.kind === 'single') {
actionMutation.mutate(
{
recordId: pendingTarget.recordId,
action: pendingAction,
metadata,
},
{ onSuccess: () => closeDialog() },
)
} else {
batchMutation.mutate(
{
ids: pendingTarget.ids,
action: pendingAction,
metadata,
},
{ onSuccess: () => closeDialog() },
)
}
}
React.useEffect(() => {
function onKey(event: KeyboardEvent) {
if (dialogOpen) return
if (draftMode !== null) return
if (event.metaKey || event.ctrlKey || event.altKey) return
if (isEditingTarget(event.target)) {
if (event.key === 'Escape' && document.activeElement instanceof HTMLElement) {
document.activeElement.blur()
}
return
}
const list = entries
const currentIndex = selectedId ? list.findIndex((e) => e.record_id === selectedId) : -1
switch (event.key) {
case 'j':
case 'ArrowDown': {
if (list.length === 0) return
event.preventDefault()
const next = currentIndex < 0 ? 0 : Math.min(currentIndex + 1, list.length - 1)
setSelectedId(list[next].record_id)
break
}
case 'k':
case 'ArrowUp': {
if (list.length === 0) return
event.preventDefault()
const next = currentIndex < 0 ? list.length - 1 : Math.max(currentIndex - 1, 0)
setSelectedId(list[next].record_id)
break
}
case 'a': {
if (!selectedId) return
event.preventDefault()
const entry = list.find((e) => e.record_id === selectedId)
if (entry) requestSingleAction(selectedId, 'accept', entry.record.title)
break
}
case 'p': {
if (!selectedId) return
event.preventDefault()
const entry = list.find((e) => e.record_id === selectedId)
if (entry) requestSingleAction(selectedId, 'promote', entry.record.title)
break
}
case 'x': {
if (!selectedId) return
event.preventDefault()
const entry = list.find((e) => e.record_id === selectedId)
if (entry) requestSingleAction(selectedId, 'archive', entry.record.title)
break
}
case '/': {
event.preventDefault()
searchInputRef.current?.focus()
break
}
default:
break
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [dialogOpen, draftMode, entries, selectedId, requestSingleAction])
const dialogTarget = pendingTarget
const dialogRecordTitle =
dialogTarget?.kind === 'single' ? dialogTarget.title : undefined
const dialogCountLabel =
dialogTarget?.kind === 'batch' ? ` ${dialogTarget.ids.length} 条` : undefined
return (
<div className="grid gap-5 lg:grid-cols-[380px_1fr]">
{/* Queue panel */}
<div className="flex h-[calc(100vh-7rem)] flex-col rounded-xl border border-border-subtle bg-bg-elevated/50 backdrop-blur-sm overflow-hidden">
{/* Queue header */}
<div className="shrink-0 space-y-3 p-4 border-b border-border-subtle">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-foreground flex items-center gap-2">
{t('workbench.queue')}
{loadingQueue && (
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
)}
</h2>
</div>
{/* Pill tabs */}
<div className="flex h-8 rounded-lg bg-bg-deep p-0.5">
<button
onClick={() => { setQueueTab('pending'); setCheckedIds(new Set()) }}
className={`flex flex-1 items-center justify-center gap-1.5 rounded-md text-xs font-medium transition-all duration-200 ease-out-expo ${
queueTab === 'pending'
? 'bg-bg-elevated text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground/80'
}`}
>
<Inbox className="h-3.5 w-3.5" />
{t('workbench.tab.pending')}
{(snapshot?.snapshot.pending_review.length ?? 0) > 0 && (
<span className={`min-w-[18px] rounded-full px-1.5 text-[10px] font-semibold leading-[18px] text-center ${
queueTab === 'pending' ? 'bg-primary/15 text-primary' : 'bg-muted/80 text-muted-foreground'
}`}>
{snapshot?.snapshot.pending_review.length ?? 0}
</span>
)}
</button>
<button
onClick={() => { setQueueTab('ready'); setCheckedIds(new Set()) }}
className={`flex flex-1 items-center justify-center gap-1.5 rounded-md text-xs font-medium transition-all duration-200 ease-out-expo ${
queueTab === 'ready'
? 'bg-bg-elevated text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground/80'
}`}
>
<Zap className="h-3.5 w-3.5" />
{t('workbench.tab.wakeup')}
{(snapshot?.snapshot.wakeup_ready.length ?? 0) > 0 && (
<span className={`min-w-[18px] rounded-full px-1.5 text-[10px] font-semibold leading-[18px] text-center ${
queueTab === 'ready' ? 'bg-success/15 text-success' : 'bg-muted/80 text-muted-foreground'
}`}>
{snapshot?.snapshot.wakeup_ready.length ?? 0}
</span>
)}
</button>
</div>
{/* Search + filter */}
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground/60" />
<input
ref={searchInputRef}
placeholder={t('workbench.search.placeholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-8 w-full rounded-lg bg-bg-deep pl-8 pr-3 text-xs text-foreground placeholder:text-muted-foreground/50 outline-none transition-shadow duration-150 focus:shadow-glow-sm"
/>
</div>
<Select value={typeFilter} onValueChange={setTypeFilter}>
<SelectTrigger className="h-8 w-[90px] text-xs border-border-subtle bg-bg-deep">
<SelectValue placeholder={t('workbench.filter.all')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('workbench.filter.all')}</SelectItem>
{memoryTypes.map((t) => (
<SelectItem key={t} value={t}>{t}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Action buttons */}
<div className="flex gap-2">
<button
className="flex h-8 flex-1 items-center justify-center gap-1.5 rounded-lg border border-dashed border-border-subtle text-xs text-muted-foreground transition-all duration-150 hover:border-border-hover hover:bg-bg-deep hover:text-foreground cursor-pointer"
onClick={() => setDraftMode('manual')}
>
<FilePlus className="h-3.5 w-3.5" />
{t('workbench.action.manual')}
</button>
<button
className="flex h-8 flex-1 items-center justify-center gap-1.5 rounded-lg border border-dashed border-border-subtle text-xs text-muted-foreground transition-all duration-150 hover:border-border-hover hover:bg-bg-deep hover:text-foreground cursor-pointer"
onClick={() => setDraftMode('propose')}
>
<Sparkles className="h-3.5 w-3.5" />
{t('workbench.action.propose')}
</button>
</div>
{/* Batch bar */}
{checkedIds.size > 0 && (
<div className="flex items-center gap-2 rounded-lg bg-primary/5 border border-primary/15 px-3 py-2 animate-fade-in-up">
<span className="text-xs text-muted-foreground">
{t('workbench.batch.selected').replace('{n}', String(checkedIds.size))}
</span>
<Button
size="sm"
className="ml-auto h-6 text-xs gap-1 bg-success hover:bg-success/90 text-success-foreground"
disabled={batchLoading}
onClick={() => requestBatchAction(Array.from(checkedIds), 'accept')}
>
{batchLoading ? <Loader2 className="h-3 w-3 animate-spin" /> : <CheckCircle2 className="h-3 w-3" />}
{t('workbench.batch.accept')}
</Button>
<Button
size="sm"
className="h-6 text-xs gap-1 bg-destructive/15 text-destructive hover:bg-destructive/25 border-0"
disabled={batchLoading}
onClick={() => requestBatchAction(Array.from(checkedIds), 'archive')}
>
{batchLoading ? <Loader2 className="h-3 w-3 animate-spin" /> : <Archive className="h-3 w-3" />}
{t('workbench.batch.archive')}
</Button>
</div>
)}
</div>
{/* Queue list */}
<div className="flex-1 overflow-y-auto px-3 pb-3 pt-2">
{entries.length === 0 && !loadingQueue && (
<div className="flex flex-col items-center justify-center py-12 animate-fade-in-up">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted/30 mb-3">
{queueTab === 'pending' ? (
<Inbox className="h-5 w-5 text-muted-foreground/50" />
) : (
<Zap className="h-5 w-5 text-muted-foreground/50" />
)}
</div>
<p className="text-sm font-medium text-muted-foreground/70">
{queueTab === 'pending' ? t('workbench.empty.pending.title') : t('workbench.empty.wakeup.title')}
</p>
<p className="mt-1.5 text-[11px] text-muted-foreground/40 max-w-[240px] text-center leading-relaxed">
{queueTab === 'ready'
? t('workbench.empty.wakeup.desc')
: t('workbench.empty.pending.desc')}
</p>
</div>
)}
{entries.length > 0 && (
<div className="mb-2 flex items-center gap-2 px-1">
<input
type="checkbox"
className="h-3.5 w-3.5 rounded border-muted-foreground/30 cursor-pointer"
checked={entries.length > 0 && entries.every((e) => checkedIds.has(e.record_id))}
onChange={toggleAll}
/>
<span className="text-[10px] text-muted-foreground/60">{t('workbench.select_all')}</span>
</div>
)}
<div className="stagger-children space-y-0.5">
{entries.map((entry) => (
<QueueItem
key={entry.record_id}
entry={entry}
selected={entry.record_id === selectedId}
checked={checkedIds.has(entry.record_id)}
onSelect={() => setSelectedId(entry.record_id)}
onCheck={() => toggleCheck(entry.record_id)}
/>
))}
</div>
<p className="mt-4 px-1 text-[10px] leading-relaxed text-muted-foreground/40">
{t('workbench.keyboard.hint')}
</p>
</div>
</div>
{/* Detail panel */}
<div className="space-y-4">
{errorEnvelope && <ErrorBanner envelope={errorEnvelope} />}
{!selectedId && (
<div className="flex h-[calc(100vh-7rem)] items-center justify-center rounded-xl border border-border-subtle bg-bg-elevated/30 backdrop-blur-sm">
<div className="text-center animate-fade-in-up">
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-muted/30">
<Search className="h-5 w-5 text-muted-foreground/40" />
</div>
<p className="text-sm text-muted-foreground/60">{t('workbench.detail.empty')}</p>
<p className="mt-1 text-[11px] text-muted-foreground/40">{t('workbench.detail.empty.desc')}</p>
</div>
</div>
)}
{selectedId && detail && (
<DetailPanel
detail={detail}
loading={loadingDetail}
actionLoading={actionLoading}
onAction={(action) =>
requestSingleAction(selectedId, action, detail.record.record.title)
}
/>
)}
{selectedId && history && <HistoryPanel history={history} />}
</div>
{draftMode && (
<DraftDialog
mode={draftMode}
open
onOpenChange={(open) => !open && setDraftMode(null)}
onCreated={() => {
setDraftMode(null)
qc.invalidateQueries({ queryKey: qk.workbench(configPath) })
}}
/>
)}
<ActionConfirmDialog
open={dialogOpen}
action={pendingAction}
recordTitle={dialogRecordTitle}
countLabel={dialogCountLabel}
defaultActor={cfg.defaultActor}
loading={actionMutation.isPending || batchMutation.isPending}
onConfirm={handleConfirm}
onCancel={closeDialog}
/>
</div>
)
}
function QueueItem({
entry,
selected,
checked,
onSelect,
onCheck,
}: {
entry: LedgerEntry
selected: boolean
checked: boolean
onSelect: () => void
onCheck: () => void
}) {
return (
<div
className={`group flex items-start gap-2.5 rounded-lg px-2.5 py-2.5 transition-all duration-150 ease-out-expo cursor-pointer ${
selected
? 'bg-bg-elevated shadow-glow-sm'
: 'hover:bg-bg-elevated/50'
}`}
>
{/* Left accent bar */}
<div className="relative flex items-start pt-1">
<div
className={`absolute -left-2.5 top-0 bottom-0 w-0.5 rounded-r transition-all duration-200 ease-out-expo ${
selected ? 'bg-primary opacity-100' : 'bg-primary opacity-0 group-hover:opacity-30'
}`}
/>
<input
type="checkbox"
className="h-3.5 w-3.5 shrink-0 rounded border-muted-foreground/30 cursor-pointer"
checked={checked}
onChange={(e) => { e.stopPropagation(); onCheck() }}
onClick={(e) => e.stopPropagation()}
/>
</div>
<button
type="button"
onClick={onSelect}
className="min-w-0 flex-1 text-left"
>
<div className="flex items-center justify-between gap-2">
<span className="line-clamp-1 text-sm font-medium text-foreground">
{entrySummaryLine(entry)}
</span>
<Badge
variant={stateVariant[entry.record.state]}
className="shrink-0 text-[10px] bg-primary/10 text-primary border-0"
>
{stateLabel[entry.record.state]}
</Badge>
</div>
<p className="mt-0.5 line-clamp-1 text-xs text-muted-foreground/60">
{entry.record.summary.split('\n')[0]}
</p>
<div className="mt-1 flex items-center gap-2 text-[10px] text-muted-foreground/50">
<span>{entry.record.memory_type}</span>
<span className="opacity-40">·</span>
<span>{entry.record.scope}</span>
{entry.record.project_id && (
<>
<span className="opacity-40">·</span>
<span className="truncate max-w-[80px] text-primary/60">{entry.record.project_id}</span>
</>
)}
<span className="ml-auto text-muted-foreground/30">{entry.recorded_at.slice(0, 10)}</span>
</div>
</button>
</div>
)
}
function DetailPanel({
detail,
loading,
actionLoading,
onAction,
}: {
detail: DesktopRecordResponse
loading: boolean
actionLoading: DesktopLifecycleAction | null
onAction: (a: DesktopLifecycleAction) => void
}) {
const { t } = useI18n()
const { record } = detail
return (
<div className="rounded-xl border border-border-subtle bg-bg-elevated/50 backdrop-blur-sm overflow-hidden animate-fade-in-up">
<div className="p-5 border-b border-border-subtle">
<div className="flex items-start justify-between gap-4">
<div className="space-y-2 min-w-0 flex-1">
<h3 className="text-base font-semibold text-foreground">{record.record.title}</h3>
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-[10px] font-mono text-muted-foreground/40 truncate max-w-[160px]">{record.record_id}</span>
<Badge variant={stateVariant[record.record.state]} className="text-[10px] bg-primary/10 text-primary border-0">
{stateLabel[record.record.state]}
</Badge>
<Badge className="text-[10px] bg-muted text-muted-foreground border-0">{record.record.memory_type}</Badge>
<Badge className="text-[10px] bg-muted text-muted-foreground border-0">{record.record.scope}</Badge>
</div>
</div>
<div className="flex items-center gap-1.5 shrink-0">
{detail.available_actions.map((action) => {
const meta = ACTION_META[action]
const Icon = meta.icon
const busy = actionLoading === action
return (
<Button
key={action}
variant={meta.variant}
size="sm"
className={`gap-1 text-xs ${
action === 'accept'
? 'bg-success hover:bg-success/90 text-success-foreground'
: action === 'archive'
? 'bg-destructive/15 text-destructive hover:bg-destructive/25 border-0'
: ''
}`}
disabled={actionLoading !== null}
onClick={() => onAction(action)}
>
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Icon className="h-3.5 w-3.5" />}
{t(`workbench.action.${action}` as Parameters<typeof t>[0])}
</Button>
)
})}
{loading && <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />}
</div>
</div>
</div>
<div className="p-5 space-y-4">
<p className="text-sm leading-relaxed text-foreground/90">{record.record.summary}</p>
<div className="grid grid-cols-2 gap-3 text-xs">
<div className="flex items-center gap-2">
<span className="text-muted-foreground/50">source</span>
<span className="text-foreground/70">{record.record.origin.source_kind}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground/50">ref</span>
<span className="text-foreground/70 truncate">{record.record.origin.source_ref}</span>
</div>
{record.record.project_id && (
<div className="flex items-center gap-2">
<span className="text-muted-foreground/50">project</span>
<span className="text-primary/70">{record.record.project_id}</span>
</div>
)}
{record.record.user_id && (
<div className="flex items-center gap-2">
<span className="text-muted-foreground/50">user</span>
<span className="text-foreground/70">{record.record.user_id}</span>
</div>
)}
{record.record.sensitivity && (
<div className="flex items-center gap-2">
<span className="text-muted-foreground/50">sensitivity</span>
<span className="text-foreground/70">{record.record.sensitivity}</span>
</div>
)}
</div>
</div>
</div>
)
}
function HistoryPanel({ history }: { history: DesktopHistoryResponse }) {
const { t } = useI18n()
return (
<div className="rounded-xl border border-border-subtle bg-bg-elevated/50 backdrop-blur-sm overflow-hidden animate-fade-in-up">
<div className="p-5 border-b border-border-subtle">
<h3 className="text-sm font-semibold text-foreground">{t('workbench.history.title')}</h3>
<p className="text-[11px] text-muted-foreground/50 mt-0.5">
{t('workbench.history.events').replace('{n}', String(history.history.length))}
</p>
</div>
<div className="p-4">
<ol className="stagger-children space-y-2">
{history.history.map((entry, idx) => (
<li
key={`${entry.record_id}-${idx}`}
className="rounded-lg bg-bg-deep px-4 py-3 text-xs"
>
<div className="flex flex-wrap items-center gap-2">
<Badge className="text-[10px] bg-muted text-muted-foreground border-0">{actionLabel[entry.action]}</Badge>
<Badge variant={stateVariant[entry.record.state]} className="text-[10px] bg-primary/10 text-primary border-0">
{stateLabel[entry.record.state]}
</Badge>
<span className="text-muted-foreground/40 ml-auto">{entry.recorded_at}</span>
</div>
{(entry.metadata.actor || entry.metadata.reason) && (
<p className="mt-1.5 text-muted-foreground/60">
{entry.metadata.actor && <span>actor={entry.metadata.actor} </span>}
{entry.metadata.reason && <span>reason={entry.metadata.reason}</span>}
</p>
)}
{entry.metadata.evidence_refs && entry.metadata.evidence_refs.length > 0 && (
<p className="mt-1 text-muted-foreground/50">
evidence: {entry.metadata.evidence_refs.join(', ')}
</p>
)}
</li>
))}
</ol>
</div>
</div>
)
}