feat(web): refactor agent detail and agents page for improved navigation and layout
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 1m53s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped

- Updated the AgentDetailSheet component to enhance layout and integrate a new detail view for agents, improving user experience.
- Refactored the agents page to support a toggle between card and table views, allowing for better organization and accessibility of agent information.
- Adjusted routing for agent links to utilize search parameters, streamlining navigation to specific agent details.
- Removed unused imports and optimized component structure for better maintainability.

These changes contribute to a more intuitive and user-friendly interface across the application.
This commit is contained in:
Denozordec
2026-07-23 12:14:46 +07:00
parent 3058704585
commit 19a9540555
41 changed files with 5901 additions and 601 deletions
@@ -1,60 +1,27 @@
import { Link } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { Check, CircleAlertIcon, Copy, ShieldOff } from 'lucide-react'
import type { Agent } from '@evofw/shared'
import {
agentPreviewQueryOptions,
agentsQueryOptions,
} from '@/queries'
import { apiFetch } from '@/lib/api'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import {
AgentPlatformIcon,
platformLabel,
} from '@/components/agents/agent-platform-icon'
import { StatusBadge } from '@/components/status-badge'
import { DetailPanel } from '@/components/reui-kit'
import { Badge } from '@/components/reui/badge'
import {
Alert,
AlertDescription,
AlertTitle,
} from '@/components/reui/alert'
import { useQuery } from '@tanstack/react-query'
import { XIcon } from 'lucide-react'
import { AgentDetailView } from '@/components/agents/agent-detail-view'
import { agentQueryOptions } from '@/queries'
import { Button } from '@evofw/ui/components/button'
import { ScrollArea } from '@evofw/ui/components/scroll-area'
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@evofw/ui/components/sheet'
import { Skeleton } from '@evofw/ui/components/skeleton'
/**
* Agent preview sheet — VPS topology detail DNA, wider for policy summary.
* Preview: https://reui.io/preview/base/components/c-sheet-1
* DNA: vps-tracker VpsDetailSheet
* Wide agent detail Sheet — inventory-9 / CRM-4 shell + SA3 body.
* Preview: https://reui.io/preview/base/solution-inventory-9
* · https://reui.io/preview/base/solution-crm-4
* · https://reui.io/preview/base/solution-agents-3
* · https://reui.io/preview/base/components/c-sheet-1
*/
function Row({ label, value }: { label: string; value: string }) {
return (
<div className="border-border/60 flex items-baseline justify-between gap-3 border-b py-2 last:border-0">
<span className="text-muted-foreground text-xs">{label}</span>
<span className="text-right text-sm break-all">{value}</span>
</div>
)
}
const packetFmt = new Intl.NumberFormat('ru-RU')
function formatWhen(iso?: string | null): string {
if (!iso) return '—'
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return iso
return d.toLocaleString('ru-RU')
}
type AgentDetailSheetProps = {
agentId: string | null
open: boolean
@@ -66,257 +33,61 @@ export function AgentDetailSheet({
open,
onOpenChange,
}: AgentDetailSheetProps) {
const qc = useQueryClient()
const { copyToClipboard } = useCopyToClipboard()
const agentsQ = useQuery({
...agentsQueryOptions(),
enabled: open,
})
const agent = (agentsQ.data?.items ?? []).find((a) => a.id === agentId) as
| Agent
| undefined
const previewQ = useQuery({
...agentPreviewQueryOptions(agentId ?? ''),
const agentQ = useQuery({
...agentQueryOptions(agentId ?? ''),
enabled: open && Boolean(agentId),
})
const approve = useMutation({
mutationFn: () =>
apiFetch(`/api/v1/agents/${agentId}/approve`, { method: 'POST' }),
onSuccess: () => {
toast.success('Агент одобрен')
void qc.invalidateQueries({ queryKey: ['agents'] })
},
onError: (e: Error) => toast.error(e.message),
})
const revoke = useMutation({
mutationFn: () =>
apiFetch(`/api/v1/agents/${agentId}/revoke`, { method: 'POST' }),
onSuccess: () => {
toast.success('Агент отозван')
void qc.invalidateQueries({ queryKey: ['agents'] })
},
onError: (e: Error) => toast.error(e.message),
})
const subtitle = agent
? [
agent.hostname,
platformLabel(agent.platform),
`gen ${agent.policy_generation}`,
]
.filter(Boolean)
.join(' · ')
: 'Агент не найден'
const title = agentQ.data?.name ?? 'Агент'
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className="flex w-full flex-col gap-0 overflow-y-auto p-0 sm:max-w-2xl"
showCloseButton={false}
className="inset-y-4 right-4 left-auto flex h-[calc(100svh-2rem)] w-[min(72rem,calc(100vw-2rem))] max-w-none flex-col gap-0 overflow-hidden rounded-xl p-0 outline-none"
>
<SheetHeader className="border-border shrink-0 border-b px-4 py-4">
<div className="flex items-start gap-3">
{agent ? (
<AgentPlatformIcon platform={agent.platform} />
) : null}
<div className="flex min-w-0 flex-1 flex-col gap-1">
<SheetTitle className="truncate">
{agent?.name ?? 'Агент'}
</SheetTitle>
<SheetDescription>{subtitle}</SheetDescription>
</div>
<SheetHeader className="shrink-0 gap-0 border-b p-0">
<div className="flex min-h-11 items-center justify-between gap-2 px-4">
<SheetTitle className="truncate text-sm font-medium leading-5">
{title}
</SheetTitle>
<SheetClose
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Закрыть"
className="shrink-0"
>
<XIcon aria-hidden="true" />
</Button>
}
/>
</div>
<SheetDescription className="sr-only">
Политика, apply counters и identity агента
</SheetDescription>
</SheetHeader>
{!agentId || agentsQ.isLoading ? (
<div className="flex flex-col gap-4 p-4">
<Skeleton className="h-8 w-40" />
<Skeleton className="h-40 w-full" />
</div>
) : !agent ? (
<p className="text-muted-foreground p-4 text-sm">
Агент удалён или недоступен.
</p>
) : (
<div className="flex flex-col gap-4 p-4">
<div className="flex flex-wrap items-center gap-2">
<StatusBadge status={agent.status} />
<Badge
variant={
agent.default_action === 'drop'
? 'warning-light'
: 'success-light'
}
size="sm"
radius="full"
>
{agent.default_action === 'drop' ? 'Drop' : 'Accept'}
</Badge>
</div>
{agent.last_apply_error ? (
<Alert variant="destructive">
<CircleAlertIcon />
<AlertTitle>Ошибка apply</AlertTitle>
<AlertDescription>{agent.last_apply_error}</AlertDescription>
</Alert>
<div className="min-h-0 flex-1">
<ScrollArea className="h-full">
{agentId && open ? (
<AgentDetailView agentId={agentId} />
) : null}
</ScrollArea>
</div>
<DetailPanel>
<DetailPanel.Section title="Apply">
<div className="border-border rounded-lg border px-3">
<Row
label="Last apply"
value={formatWhen(agent.last_apply_at)}
/>
<Row
label="Status"
value={agent.last_apply_status ?? '—'}
/>
<Row
label="Kernel"
value={agent.last_apply_kernel_method ?? '—'}
/>
<Row
label="Dropped"
value={
agent.last_apply_at
? packetFmt.format(
agent.last_apply_packets_dropped ?? 0,
)
: '—'
}
/>
<Row
label="Accepted"
value={
agent.last_apply_at
? packetFmt.format(
agent.last_apply_packets_accepted ?? 0,
)
: '—'
}
/>
</div>
</DetailPanel.Section>
<DetailPanel.Section title="Политика">
<div className="border-border rounded-lg border px-3">
<Row
label="Если не совпало"
value={
agent.default_action === 'drop' ? 'Drop' : 'Accept'
}
/>
{previewQ.isLoading ? (
<div className="py-2">
<Skeleton className="h-4 w-full" />
</div>
) : previewQ.data ? (
<>
<Row
label="Наборы"
value={String(previewQ.data.summary.sets)}
/>
<Row
label="CIDR блок"
value={String(previewQ.data.summary.cidrs_deny)}
/>
<Row
label="CIDR accept"
value={String(previewQ.data.summary.cidrs_allow)}
/>
<Row
label="Конфликты"
value={String(
previewQ.data.summary.conflicts_dropped,
)}
/>
<Row
label="Apply version"
value={String(previewQ.data.apply_version)}
/>
</>
) : (
<Row label="Preview" value="недоступен" />
)}
</div>
</DetailPanel.Section>
<DetailPanel.Section title="Identity">
<div className="border-border rounded-lg border px-3">
<Row label="Hostname" value={agent.hostname ?? '—'} />
<Row label="Last seen IP" value={agent.last_seen_ip ?? '—'} />
<Row
label="Last seen"
value={formatWhen(agent.last_seen_at)}
/>
<Row
label="Client"
value={agent.client_version ?? '—'}
/>
<Row label="Token" value={agent.token_prefix} />
<Row label="Created" value={formatWhen(agent.created_at)} />
<Row
label="Approved"
value={formatWhen(agent.approved_at)}
/>
</div>
</DetailPanel.Section>
</DetailPanel>
<div className="flex flex-col gap-2">
<div className="flex flex-wrap gap-2">
{agent.status === 'pending' ? (
<Button
size="sm"
disabled={approve.isPending}
onClick={() => approve.mutate()}
>
<Check data-icon="inline-start" />
Approve
</Button>
) : null}
{agent.status === 'approved' ? (
<Button
size="sm"
variant="outline"
disabled={revoke.isPending}
onClick={() => revoke.mutate()}
>
<ShieldOff data-icon="inline-start" />
Revoke
</Button>
) : null}
{agent.install_curl ? (
<Button
size="sm"
variant="outline"
onClick={() => {
copyToClipboard(agent.install_curl!)
toast.success('Скопировано')
}}
>
<Copy data-icon="inline-start" />
Install
</Button>
) : null}
</div>
<Button
size="lg"
className="w-full"
render={
<Link to="/agents/$id" params={{ id: agent.id }} />
}
>
Открыть карточку агента
<SheetFooter className="bg-muted/40 shrink-0 border-t px-4 py-3">
<SheetClose
render={
<Button type="button" variant="outline" className="w-full">
Закрыть
</Button>
</div>
</div>
)}
}
/>
</SheetFooter>
</SheetContent>
</Sheet>
)
@@ -0,0 +1,302 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { useRef, useState } from 'react'
import {
BanIcon,
CheckCircle2Icon,
CircleAlertIcon,
ClockIcon,
Copy,
CopyPlusIcon,
CpuIcon,
MoreHorizontalIcon,
ShieldPlusIcon,
TerminalIcon,
} from 'lucide-react'
import { DetailPanel } from '@/components/reui-kit'
import {
Alert,
AlertDescription,
AlertTitle,
} from '@/components/reui/alert'
import { Badge } from '@/components/reui/badge'
import { StatusBadge } from '@/components/status-badge'
import {
AgentPlatformIcon,
platformLabel,
} from '@/components/agents/agent-platform-icon'
import { AgentPolicySetsSortable } from '@/components/agents/agent-policy-sets-sortable'
import { AgentPolicyTrace } from '@/components/agents/agent-policy-trace'
import { AgentFactsPanel } from '@/components/agents/agent-facts-panel'
import { AgentEffectiveCidrs } from '@/components/agents/agent-effective-cidrs'
import {
AgentCloneSetsSheet,
AgentOverrideSheet,
} from '@/components/agents/agent-settings-sheets'
import {
agentPreviewQueryOptions,
agentQueryOptions,
} from '@/queries'
import { apiFetch } from '@/lib/api'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { Button } from '@evofw/ui/components/button'
import { Skeleton } from '@evofw/ui/components/skeleton'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@evofw/ui/components/dropdown-menu'
/**
* Full agent detail body — SA3 DNA for Sheet (and redirect target).
* Preview: https://reui.io/preview/base/solution-agents-3
* · https://reui.io/preview/base/stats-12
* · https://reui.io/preview/base/form-7
*/
type AgentDetailViewProps = {
agentId: string
}
export function AgentDetailView({ agentId }: AgentDetailViewProps) {
const qc = useQueryClient()
const { copyToClipboard } = useCopyToClipboard()
const agentQ = useQuery(agentQueryOptions(agentId))
const previewQ = useQuery(agentPreviewQueryOptions(agentId))
const installRef = useRef<HTMLDivElement>(null)
const [overrideOpen, setOverrideOpen] = useState(false)
const [cloneOpen, setCloneOpen] = useState(false)
const revoke = useMutation({
mutationFn: () =>
apiFetch(`/api/v1/agents/${agentId}/revoke`, { method: 'POST' }),
onSuccess: () => {
toast.success('Агент отозван')
void qc.invalidateQueries({ queryKey: ['agents'] })
},
onError: (e: Error) => toast.error(e.message),
})
const approve = useMutation({
mutationFn: () =>
apiFetch(`/api/v1/agents/${agentId}/approve`, { method: 'POST' }),
onSuccess: () => {
toast.success('Агент одобрен')
void qc.invalidateQueries({ queryKey: ['agents'] })
},
onError: (e: Error) => toast.error(e.message),
})
const a = agentQ.data
if (agentQ.isLoading || !a) {
return (
<div className="flex flex-col gap-4 p-4">
<Skeleton className="h-10 w-56" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-64 w-full" />
</div>
)
}
if (agentQ.isError) {
return (
<div className="p-4">
<Alert variant="destructive">
<CircleAlertIcon />
<AlertTitle>Ошибка загрузки</AlertTitle>
<AlertDescription>
{agentQ.error?.message ?? 'Не удалось загрузить агента'}
</AlertDescription>
</Alert>
</div>
)
}
const headerDesc = [
a.hostname,
platformLabel(a.platform),
`gen ${a.policy_generation}`,
a.default_action === 'drop' ? 'default Drop' : 'default Accept',
]
.filter(Boolean)
.join(' · ')
return (
<>
<div className="flex flex-col gap-4 p-4">
<DetailPanel>
<DetailPanel.Header
title={a.name}
description={headerDesc}
actions={
<>
<AgentPlatformIcon platform={a.platform} />
<StatusBadge status={a.status} />
<Badge
variant={
a.default_action === 'drop'
? 'warning-light'
: 'success-light'
}
size="sm"
radius="full"
>
{a.default_action === 'drop' ? 'Drop' : 'Accept'}
</Badge>
{a.status === 'pending' ? (
<Button
size="sm"
onClick={() => approve.mutate()}
disabled={approve.isPending}
>
Approve
</Button>
) : null}
{a.status === 'approved' ? (
<Button
variant="outline"
size="sm"
onClick={() => revoke.mutate()}
disabled={revoke.isPending}
>
Revoke
</Button>
) : null}
{a.install_curl ? (
<Button
variant="outline"
size="sm"
onClick={() => {
copyToClipboard(a.install_curl!)
toast.success('Скопировано')
}}
>
<Copy data-icon="inline-start" />
Install
</Button>
) : null}
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="outline"
size="icon-sm"
aria-label="Ещё"
/>
}
>
<MoreHorizontalIcon className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setOverrideOpen(true)}>
<ShieldPlusIcon className="size-4" />
IP override
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setCloneOpen(true)}>
<CopyPlusIcon className="size-4" />
Копировать наборы
</DropdownMenuItem>
{a.install_curl ? (
<DropdownMenuItem
onClick={() => {
copyToClipboard(a.install_curl!)
toast.success('Скопировано')
installRef.current?.scrollIntoView({
behavior: 'smooth',
})
}}
>
<TerminalIcon className="size-4" />
Install curl
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
</>
}
/>
{a.last_apply_error ? (
<Alert variant="destructive">
<CircleAlertIcon />
<AlertTitle>Ошибка apply</AlertTitle>
<AlertDescription>{a.last_apply_error}</AlertDescription>
</Alert>
) : null}
<DetailPanel.Metrics
cards={[
{
id: 'dropped',
icon: <BanIcon aria-hidden />,
iconClassName: 'text-warning',
label: 'Dropped',
description: String(a.last_apply_packets_dropped ?? 0),
hint: 'сумма counters',
variant: 'warning',
},
{
id: 'accepted',
icon: <CheckCircle2Icon aria-hidden />,
iconClassName: 'text-success',
label: 'Accepted',
description: String(a.last_apply_packets_accepted ?? 0),
hint: 'сумма counters',
},
{
id: 'kernel',
icon: <CpuIcon aria-hidden />,
iconClassName: 'text-info',
label: 'Kernel',
description: a.last_apply_kernel_method ?? '—',
},
{
id: 'apply',
icon: <ClockIcon aria-hidden />,
iconClassName: 'text-primary',
label: 'Last apply',
description: a.last_apply_at ?? '—',
},
]}
/>
<DetailPanel.Section>
<div className="@container flex flex-col gap-4">
<div className="grid gap-4 @4xl:grid-cols-3">
<div className="@4xl:col-span-2">
<AgentPolicyTrace
preview={previewQ.data}
isLoading={previewQ.isLoading}
/>
</div>
<AgentFactsPanel agent={a} />
</div>
<div ref={installRef}>
<AgentPolicySetsSortable agentId={agentId} />
</div>
<AgentEffectiveCidrs
preview={previewQ.data}
isLoading={previewQ.isLoading}
/>
</div>
</DetailPanel.Section>
</DetailPanel>
</div>
<AgentOverrideSheet
agentId={agentId}
open={overrideOpen}
onOpenChange={setOverrideOpen}
/>
<AgentCloneSetsSheet
agentId={agentId}
open={cloneOpen}
onOpenChange={setCloneOpen}
/>
</>
)
}
@@ -0,0 +1,390 @@
import { useCallback, useMemo, type MouseEvent, type ReactNode } from 'react'
import type { ColumnDef } from '@tanstack/react-table'
import { Check, Copy, PanelRight } from 'lucide-react'
import type { Agent } from '@evofw/shared'
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
import { ResourcePage } from '@/components/reui-kit'
import { Badge } from '@/components/reui/badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import {
DataGridMutedCell,
DataGridPrimaryCell,
} from '@/components/data-grid-cell'
import { StatusBadge } from '@/components/status-badge'
import {
AgentPlatformIcon,
platformLabel,
} from '@/components/agents/agent-platform-icon'
import { Button } from '@evofw/ui/components/button'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@evofw/ui/components/tooltip'
/**
* Fleet triage DataGrid — firewall ops density.
* Preview: https://reui.io/preview/base/data-grid-filtering-2
* · https://reui.io/preview/base/solution-agents-2
* · https://reui.io/preview/base/solution-agents-1
*/
const packetFmt = new Intl.NumberFormat('ru-RU', {
notation: 'compact',
maximumFractionDigits: 1,
})
const seenFmt = new Intl.DateTimeFormat('ru-RU', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
function formatPackets(n: number | undefined, hasApply: boolean): string {
if (!hasApply || n === undefined) return '—'
return packetFmt.format(n)
}
function formatAgentSeen(iso: string | null | undefined): string {
if (!iso) return '—'
const t = Date.parse(iso)
if (Number.isNaN(t)) return '—'
return seenFmt.format(t)
}
export type AgentFleetDataGridProps = {
data: Agent[]
filterFields: FilterFieldConfig[]
filters: Filter[]
onFiltersChange: (filters: Filter[]) => void
onClearFilters?: () => void
getFilterFieldValue: (item: Agent, field: string) => unknown
searchQuery: string
onSearchChange: (query: string) => void
getSearchText: (item: Agent) => string
tabs: { id: string; label: string }[]
activeTab: string
onTabChange: (tabId: string) => void
tabFilter: (item: Agent, tabId: string) => boolean
isLoading?: boolean
isError?: boolean
error?: Error | null
onRetry?: () => void
emptyAction?: ReactNode
toolbarExtra?: ReactNode
onSelect: (id: string) => void
onApprove: (id: string) => void
approvePending?: boolean
onCopyInstall: (curl: string) => void
}
export function AgentFleetDataGrid({
data,
filterFields,
filters,
onFiltersChange,
onClearFilters,
getFilterFieldValue,
searchQuery,
onSearchChange,
getSearchText,
tabs,
activeTab,
onTabChange,
tabFilter,
isLoading,
isError,
error,
onRetry,
emptyAction,
toolbarExtra,
onSelect,
onApprove,
approvePending,
onCopyInstall,
}: AgentFleetDataGridProps) {
const handleCopy = useCallback(
(curl: string, e?: MouseEvent) => {
e?.stopPropagation()
onCopyInstall(curl)
},
[onCopyInstall],
)
const columns: ColumnDef<Agent>[] = useMemo(
() => [
{
accessorKey: 'name',
size: 260,
minSize: 180,
maxSize: 420,
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Агент" />
),
cell: ({ row }) => {
const a = row.original
return (
<div className="flex min-w-0 items-center gap-3">
<AgentPlatformIcon platform={a.platform} />
<DataGridPrimaryCell
accent="primary"
title={a.name}
subtitle={
a.hostname
? `${a.hostname} · ${platformLabel(a.platform)}`
: platformLabel(a.platform)
}
/>
</div>
)
},
},
{
accessorKey: 'status',
size: 110,
minSize: 100,
maxSize: 130,
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Статус" />
),
cell: ({ row }) => <StatusBadge status={row.original.status} />,
},
{
id: 'default_action',
size: 100,
minSize: 90,
maxSize: 120,
accessorFn: (row) => row.default_action,
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Default" />
),
cell: ({ row }) => {
const drop = row.original.default_action === 'drop'
return (
<Badge
variant={drop ? 'warning-light' : 'success-light'}
size="sm"
radius="full"
>
{drop ? 'Drop' : 'Accept'}
</Badge>
)
},
},
{
id: 'apply',
size: 100,
minSize: 90,
maxSize: 120,
accessorFn: (row) => row.last_apply_status ?? '',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Apply" />
),
cell: ({ row }) => {
const a = row.original
if (a.last_apply_error) {
return (
<Tooltip>
<TooltipTrigger
render={
<span className="inline-flex cursor-default" />
}
>
<Badge variant="destructive-light" size="sm">
error
</Badge>
</TooltipTrigger>
<TooltipContent className="max-w-sm">
{a.last_apply_error}
</TooltipContent>
</Tooltip>
)
}
if (!a.last_apply_status && !a.last_apply_at) {
return <DataGridMutedCell></DataGridMutedCell>
}
return (
<Badge variant="secondary" size="sm">
{a.last_apply_status ?? 'ok'}
</Badge>
)
},
},
{
id: 'dropped',
size: 90,
minSize: 80,
maxSize: 110,
accessorFn: (row) => row.last_apply_packets_dropped ?? -1,
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Dropped" />
),
cell: ({ row }) => {
const a = row.original
const hasApply = Boolean(a.last_apply_at || a.last_apply_status)
const text = formatPackets(a.last_apply_packets_dropped, hasApply)
if (text === '—') {
return <DataGridMutedCell></DataGridMutedCell>
}
return (
<span className="text-warning text-sm font-medium tabular-nums">
{text}
</span>
)
},
},
{
id: 'accepted',
size: 90,
minSize: 80,
maxSize: 110,
accessorFn: (row) => row.last_apply_packets_accepted ?? -1,
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Accepted" />
),
cell: ({ row }) => {
const a = row.original
const hasApply = Boolean(a.last_apply_at || a.last_apply_status)
const text = formatPackets(a.last_apply_packets_accepted, hasApply)
if (text === '—') {
return <DataGridMutedCell></DataGridMutedCell>
}
return (
<span className="text-success text-sm font-medium tabular-nums">
{text}
</span>
)
},
},
{
accessorKey: 'last_seen_at',
size: 140,
minSize: 120,
maxSize: 180,
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Seen" />
),
cell: ({ row }) => {
const a = row.original
const short = formatAgentSeen(a.last_seen_at)
if (!a.last_seen_at || short === '—') {
return <DataGridMutedCell></DataGridMutedCell>
}
return (
<DataGridPrimaryCell
accent="default"
title={short}
subtitle={a.last_seen_ip ?? undefined}
className="[&>span:first-child]:text-xs [&>span:first-child]:font-normal [&>span:first-child]:tabular-nums"
/>
)
},
},
{
id: 'gen',
size: 70,
minSize: 60,
maxSize: 90,
accessorFn: (row) => row.policy_generation,
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Gen" />
),
cell: ({ row }) => (
<span className="text-muted-foreground text-xs tabular-nums">
{row.original.policy_generation}
</span>
),
},
{
id: 'actions',
size: 120,
minSize: 120,
maxSize: 120,
enableSorting: false,
enableResizing: false,
header: () => <span className="sr-only">Действия</span>,
cell: ({ row }) => {
const a = row.original
return (
<div className="flex justify-end gap-1">
{a.status === 'pending' ? (
<Button
size="icon-sm"
variant="ghost"
aria-label="Approve"
disabled={approvePending}
onClick={(e) => {
e.stopPropagation()
onApprove(a.id)
}}
>
<Check className="size-3.5" />
</Button>
) : null}
{a.install_curl ? (
<Button
size="icon-sm"
variant="ghost"
aria-label="Copy install"
onClick={(e) => handleCopy(a.install_curl!, e)}
>
<Copy className="size-3.5" />
</Button>
) : null}
<Button
size="icon-sm"
variant="ghost"
aria-label="Открыть"
onClick={(e) => {
e.stopPropagation()
onSelect(a.id)
}}
>
<PanelRight className="size-3.5" />
</Button>
</div>
)
},
},
],
[approvePending, handleCopy, onApprove, onSelect],
)
return (
<ResourcePage
title="Очередь агентов"
hideHeader
data={data}
columns={columns}
getRowId={(r) => r.id}
filterFields={filterFields}
filters={filters}
onFiltersChange={onFiltersChange}
onClearFilters={onClearFilters}
getFilterFieldValue={getFilterFieldValue}
searchQuery={searchQuery}
onSearchChange={onSearchChange}
searchPlaceholder="Поиск агентов…"
getSearchText={getSearchText}
tableLayout={{ width: 'fixed', columnsResizable: true }}
columnPinning={{ right: ['actions'] }}
onRowClick={(row) => onSelect(row.id)}
tabs={tabs}
activeTab={activeTab}
onTabChange={onTabChange}
tabFilter={tabFilter}
isLoading={isLoading}
isError={isError}
error={error}
onRetry={onRetry}
toolbarExtra={toolbarExtra}
emptyState={{
title: 'Нет агентов',
description:
'Создайте агента — он появится в списке как Invited с командой установки.',
action: emptyAction,
}}
/>
)
}
@@ -0,0 +1,469 @@
import { type ReactNode } from "react"
import { CircleCheckIcon, InfoIcon, CreditCardIcon, SendIcon, DatabaseIcon, BookOpenIcon, SparklesIcon, FileTextIcon } from "lucide-react"
export type RunStatusId = "running" | "waiting" | "failed" | "completed"
export type AgentName =
| "Refund Resolver"
| "Outreach Composer"
| "Data Steward"
| "Invoice Reconciler"
| "Lead Enricher"
| "Contract Summarizer"
export type RunAttention =
| "Escalated"
| "Needs approval"
| "Retrying"
| "On track"
| "Done"
export type RunEnvironment = "Production" | "Staging" | "Development"
export interface RunOwner {
id: string
name: string
initials: string
avatarSrc?: string
role: string
}
export interface RunStatusGroup {
id: RunStatusId
label: "Running" | "Waiting" | "Failed" | "Completed"
summary: string
focus: string
order: number
}
export interface AgentRun {
id: string
runKey: string
title: string
context: string
statusId: RunStatusId
agent: AgentName
environment: RunEnvironment
attention: RunAttention
owner: RunOwner | null
owners: RunOwner[]
stepProgress: number | null
startedAt: string
startedLabel: string
}
export interface RunGroupRow {
kind: "group"
id: string
group: RunStatusGroup
subRows?: RunItemRow[]
}
export interface RunItemRow {
kind: "run"
id: string
group: RunStatusGroup
run: AgentRun
}
export type RunTableRow = RunGroupRow | RunItemRow
export const RUN_STATUS_ORDER: RunStatusId[] = [
"running",
"waiting",
"failed",
"completed",
]
export const RUN_ATTENTION_OPTIONS: RunAttention[] = [
"Escalated",
"Needs approval",
"Retrying",
"On track",
"Done",
]
export const TOAST_SUCCESS_ICON = (
<CircleCheckIcon className="size-[18px] text-green-600" aria-hidden="true" />
)
export const TOAST_INFO_ICON = (
<InfoIcon className="text-muted-foreground size-[18px]" aria-hidden="true" />
)
export const AGENT_DETAILS: Record<
AgentName,
{ label: AgentName; icon: ReactNode }
> = {
"Refund Resolver": {
label: "Refund Resolver",
icon: (
<CreditCardIcon className="size-3.5" aria-hidden="true" />
),
},
"Outreach Composer": {
label: "Outreach Composer",
icon: (
<SendIcon className="size-3.5" aria-hidden="true" />
),
},
"Data Steward": {
label: "Data Steward",
icon: (
<DatabaseIcon className="size-3.5" aria-hidden="true" />
),
},
"Invoice Reconciler": {
label: "Invoice Reconciler",
icon: (
<BookOpenIcon className="size-3.5" aria-hidden="true" />
),
},
"Lead Enricher": {
label: "Lead Enricher",
icon: (
<SparklesIcon className="size-3.5" aria-hidden="true" />
),
},
"Contract Summarizer": {
label: "Contract Summarizer",
icon: (
<FileTextIcon className="size-3.5" aria-hidden="true" />
),
},
}
export const RUN_STATUS_GROUPS: RunStatusGroup[] = [
{
id: "running",
label: "Running",
summary: "Live executions streaming steps right now",
focus: "Throughput",
order: 1,
},
{
id: "waiting",
label: "Waiting",
summary: "Queued behind approvals, rate limits, or schedules",
focus: "Backlog",
order: 2,
},
{
id: "failed",
label: "Failed",
summary: "Stopped runs awaiting retry or escalation",
focus: "Recovery",
order: 3,
},
{
id: "completed",
label: "Completed",
summary: "Finished in the last 24 hours",
focus: "Audit",
order: 4,
},
]
const MAYA: RunOwner = {
id: "owner-maya",
name: "Maya Perez",
initials: "MP",
avatarSrc:
"https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=96&h=96&dpr=2&q=80",
role: "Operations lead",
}
const NOA: RunOwner = {
id: "owner-noa",
name: "Noa Kim",
initials: "NK",
avatarSrc:
"https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=96&h=96&dpr=2&q=80",
role: "Reliability engineer",
}
const EMIL: RunOwner = {
id: "owner-emil",
name: "Emil Novak",
initials: "EN",
avatarSrc:
"https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=96&h=96&dpr=2&q=80",
role: "Platform engineer",
}
const LARA: RunOwner = {
id: "owner-lara",
name: "Lara Chen",
initials: "LC",
avatarSrc:
"https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=96&h=96&dpr=2&q=80",
role: "Support automation",
}
const PAVEL: RunOwner = {
id: "owner-pavel",
name: "Pavel Singh",
initials: "PS",
avatarSrc:
"https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=96&h=96&dpr=2&q=80",
role: "Growth engineer",
}
const JONAS: RunOwner = {
id: "owner-jonas",
name: "Jonas Reed",
initials: "JR",
role: "Safety reviewer",
}
export const RUN_OWNERS: RunOwner[] = [MAYA, NOA, EMIL, LARA, PAVEL, JONAS]
export const AGENT_RUNS: AgentRun[] = [
// Running
{
id: "run-4831",
runKey: "RUN-4831",
title: "Refund duplicate charge on ORD-99214 for Acme Corp",
context:
"Step 4 of 6 · Matching the second transaction in the payment ledger",
statusId: "running",
agent: "Refund Resolver",
environment: "Production",
attention: "On track",
owner: MAYA,
owners: [],
stepProgress: 62,
startedAt: "2026-06-10T12:40:00Z",
startedLabel: "2m ago",
},
{
id: "run-4830",
runKey: "RUN-4830",
title: "Draft renewal outreach for 38 Q3 accounts",
context: "Step 2 of 5 · Pulling usage summaries for segment B",
statusId: "running",
agent: "Outreach Composer",
environment: "Production",
attention: "On track",
owner: PAVEL,
owners: [],
stepProgress: 35,
startedAt: "2026-06-10T12:34:00Z",
startedLabel: "8m ago",
},
{
id: "run-4829",
runKey: "RUN-4829",
title: "Dedupe 412 contacts imported from the spring webinar",
context: "Step 3 of 4 · Merging 57 confirmed duplicate pairs",
statusId: "running",
agent: "Data Steward",
environment: "Staging",
attention: "On track",
owner: EMIL,
owners: [],
stepProgress: 70,
startedAt: "2026-06-10T12:28:00Z",
startedLabel: "14m ago",
},
{
id: "run-4828",
runKey: "RUN-4828",
title: "Refund damaged item claim on ORD-99187 for Globex",
context: "Step 5 of 6 · Waiting on finance approval for the $1,240 credit",
statusId: "running",
agent: "Refund Resolver",
environment: "Production",
attention: "Needs approval",
owner: MAYA,
owners: [MAYA, JONAS],
stepProgress: 81,
startedAt: "2026-06-10T12:20:00Z",
startedLabel: "22m ago",
},
{
id: "run-4827",
runKey: "RUN-4827",
title: "Summarize the Meridian master services agreement",
context: "Step 1 of 3 · Splitting 84 pages into clause sections",
statusId: "running",
agent: "Contract Summarizer",
environment: "Development",
attention: "On track",
owner: LARA,
owners: [],
stepProgress: 15,
startedAt: "2026-06-10T12:11:00Z",
startedLabel: "31m ago",
},
// Waiting
{
id: "run-4826",
runKey: "RUN-4826",
title: "Reconcile 23 June freight invoices",
context: "Queued 18 min · Position 2 in the finance lane",
statusId: "waiting",
agent: "Invoice Reconciler",
environment: "Production",
attention: "On track",
owner: NOA,
owners: [],
stepProgress: null,
startedAt: "2026-06-10T12:24:00Z",
startedLabel: "18m ago",
},
{
id: "run-4825",
runKey: "RUN-4825",
title: "Send onboarding nudges to 112 trial signups",
context: "Step 1 of 4 · Rate limited by the email provider, retry at 13:05",
statusId: "waiting",
agent: "Outreach Composer",
environment: "Production",
attention: "Retrying",
owner: PAVEL,
owners: [],
stepProgress: 10,
startedAt: "2026-06-10T12:16:00Z",
startedLabel: "26m ago",
},
{
id: "run-4824",
runKey: "RUN-4824",
title: "Enrich 64 new leads from the partner webinar",
context: "Queued 32 min · Waiting for the enrichment API window",
statusId: "waiting",
agent: "Lead Enricher",
environment: "Staging",
attention: "On track",
owner: PAVEL,
owners: [],
stepProgress: null,
startedAt: "2026-06-10T12:10:00Z",
startedLabel: "32m ago",
},
{
id: "run-4823",
runKey: "RUN-4823",
title: "Archive conversations older than 180 days",
context: "Queued 1 h · Scheduled for the low traffic window at 02:00",
statusId: "waiting",
agent: "Data Steward",
environment: "Production",
attention: "On track",
owner: EMIL,
owners: [],
stepProgress: null,
startedAt: "2026-06-10T11:42:00Z",
startedLabel: "1h ago",
},
// Failed
{
id: "run-4822",
runKey: "RUN-4822",
title: "Refund partial return on ORD-99102 for Acme Corp",
context:
"Failed at step 3 of 6 · The processor declined the partial capture",
statusId: "failed",
agent: "Refund Resolver",
environment: "Production",
attention: "Escalated",
owner: MAYA,
owners: [MAYA, NOA],
stepProgress: 48,
startedAt: "2026-06-10T11:38:00Z",
startedLabel: "1h ago",
},
{
id: "run-4821",
runKey: "RUN-4821",
title: "Match 9 supplier invoices to June purchase orders",
context: "Failed at step 2 of 5 · Two invoices reference a closed PO",
statusId: "failed",
agent: "Invoice Reconciler",
environment: "Production",
attention: "Escalated",
owner: NOA,
owners: [],
stepProgress: 32,
startedAt: "2026-06-10T10:55:00Z",
startedLabel: "2h ago",
},
{
id: "run-4820",
runKey: "RUN-4820",
title: "Summarize 3 renewal contracts for legal review",
context:
"Failed at step 2 of 3 · The Globex renewal PDF is password protected",
statusId: "failed",
agent: "Contract Summarizer",
environment: "Development",
attention: "Retrying",
owner: LARA,
owners: [],
stepProgress: 55,
startedAt: "2026-06-10T09:48:00Z",
startedLabel: "3h ago",
},
// Completed
{
id: "run-4819",
runKey: "RUN-4819",
title: "Send weekly digest to 1,847 subscribers",
context: "Done in 4m 12s · 1,812 delivered, 35 bounced",
statusId: "completed",
agent: "Outreach Composer",
environment: "Production",
attention: "Done",
owner: PAVEL,
owners: [],
stepProgress: 100,
startedAt: "2026-06-09T16:05:00Z",
startedLabel: "Yesterday",
},
{
id: "run-4818",
runKey: "RUN-4818",
title: "Rebuild the product catalog embeddings",
context: "Done in 18m · 3,072 vectors refreshed",
statusId: "completed",
agent: "Data Steward",
environment: "Staging",
attention: "Done",
owner: EMIL,
owners: [],
stepProgress: 100,
startedAt: "2026-06-09T14:30:00Z",
startedLabel: "Yesterday",
},
{
id: "run-4817",
runKey: "RUN-4817",
title: "Score 240 inbound leads from the pricing page",
context: "Done in 6m 40s · 31 leads routed to sales",
statusId: "completed",
agent: "Lead Enricher",
environment: "Production",
attention: "Done",
owner: JONAS,
owners: [],
stepProgress: 100,
startedAt: "2026-06-09T11:20:00Z",
startedLabel: "Yesterday",
},
{
id: "run-4816",
runKey: "RUN-4816",
title: "Refund cancelled subscription for Globex",
context: "Done in 1m 05s · $89 credited to the original card",
statusId: "completed",
agent: "Refund Resolver",
environment: "Production",
attention: "Done",
owner: MAYA,
owners: [],
stepProgress: 100,
startedAt: "2026-06-08T15:42:00Z",
startedLabel: "Mon",
},
]
@@ -0,0 +1,614 @@
"use no memo"
import { memo, type ComponentProps } from "react"
import { Badge } from "@/components/reui/badge"
import { DataGridColumnHeader } from "@/components/reui/data-grid/data-grid-column-header"
import { type ColumnDef } from "@tanstack/react-table"
import { cn } from "@evofw/ui/lib/utils"
import {
Avatar,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarImage,
} from "@evofw/ui/components/avatar"
import { Button } from "@evofw/ui/components/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@evofw/ui/components/dropdown-menu"
import { Item, ItemMedia } from "@evofw/ui/components/item"
import {
AGENT_DETAILS,
type AgentRun,
type RunAttention,
type RunEnvironment,
type RunGroupRow,
type RunItemRow,
type RunOwner,
type RunStatusId,
type RunTableRow,
} from "./data"
import { ChevronRightIcon, ArrowRightIcon, CalendarClockIcon, MoreHorizontalIcon, EyeIcon, CopyIcon, RefreshCwIcon, PauseIcon } from "lucide-react"
export type RunAction = "open" | "copy" | "retry" | "pause"
const attentionVariant: Record<
RunAttention,
ComponentProps<typeof Badge>["variant"]
> = {
Escalated: "destructive-light",
"Needs approval": "warning-light",
Retrying: "info-light",
"On track": "success-light",
Done: "secondary",
}
const environmentDotClass: Record<RunEnvironment, string> = {
Production: "bg-primary",
Staging: "bg-warning",
Development: "bg-info",
}
const agentTileClass = "bg-background border-border text-foreground"
const statusDotClass: Record<RunStatusId, string> = {
running: "bg-sky-500",
waiting: "bg-muted-foreground/70",
failed: "bg-destructive",
completed: "bg-success",
}
function isRunRow(row: RunTableRow): row is RunItemRow {
return row.kind === "run"
}
function getGroupRuns(row: RunGroupRow) {
return row.subRows?.map((item) => item.run) ?? []
}
function getGroupAttention(runs: AgentRun[]): RunAttention {
if (runs.some((run) => run.attention === "Escalated")) return "Escalated"
if (runs.some((run) => run.attention === "Needs approval")) {
return "Needs approval"
}
if (runs.some((run) => run.attention === "Retrying")) return "Retrying"
if (runs.length > 0 && runs.every((run) => run.attention === "Done")) {
return "Done"
}
return "On track"
}
function getLatestRun(runs: AgentRun[]) {
return runs.reduce<AgentRun | undefined>((latest, run) => {
if (!latest || run.startedAt.localeCompare(latest.startedAt) > 0) return run
return latest
}, undefined)
}
function getRunOwners(run: AgentRun) {
if (run.owners.length > 0) return run.owners
return run.owner ? [run.owner] : []
}
function stepRingColor(rate: number) {
if (rate >= 75) return "text-emerald-500"
if (rate >= 40) return "text-amber-500"
return "text-rose-500"
}
const OwnerAvatar = memo(function OwnerAvatar({
owner,
className,
}: {
owner: RunOwner | null
className?: string
}) {
return (
<Avatar className={cn("size-5 shrink-0", className)}>
{owner?.avatarSrc ? (
<AvatarImage src={owner.avatarSrc} alt={owner.name} />
) : null}
<AvatarFallback className="text-[9px]">
{owner?.initials ?? "--"}
</AvatarFallback>
</Avatar>
)
})
function EnvironmentBadge({ environment }: { environment: RunEnvironment }) {
return (
<Badge variant="outline" className="bg-background gap-1.5">
<span
className={cn(
"size-1.5 shrink-0 rounded-full",
environmentDotClass[environment]
)}
aria-hidden="true"
/>
{environment}
</Badge>
)
}
function AttentionBadge({ attention }: { attention: RunAttention }) {
return <Badge variant={attentionVariant[attention]}>{attention}</Badge>
}
function StatusMark({ statusId }: { statusId: RunStatusId }) {
return (
<span
className={cn("size-2.5 shrink-0 rounded-full", statusDotClass[statusId])}
aria-hidden="true"
/>
)
}
function GroupExpandButton({
label,
expanded,
onToggle,
}: {
label: string
expanded: boolean
onToggle: () => void
}) {
return (
<Button
type="button"
size="icon-sm"
variant="ghost"
aria-label={expanded ? `Collapse ${label}` : `Expand ${label}`}
aria-expanded={expanded}
className="text-muted-foreground hover:text-foreground size-6 shrink-0 p-0 shadow-none"
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
onToggle()
}}
>
<ChevronRightIcon className={cn(
"size-3.5 shrink-0 transition-transform duration-150",
expanded && "rotate-90"
)} aria-hidden="true" />
</Button>
)
}
function StatusGroupCell({
row,
expanded,
onToggle,
}: {
row: RunGroupRow
expanded: boolean
onToggle: () => void
}) {
return (
<div data-run-row="group" className="flex min-w-0 items-center gap-1.5">
<GroupExpandButton
label={row.group.label}
expanded={expanded}
onToggle={onToggle}
/>
<StatusMark statusId={row.group.id} />
<div className="flex min-w-0 flex-1 items-center gap-2">
<span className="text-foreground shrink-0 truncate text-sm font-medium">
{row.group.label}
</span>
<Badge variant="outline" className="shrink-0">
{row.subRows?.length ?? 0}
</Badge>
</div>
</div>
)
}
function AgentIcon({ run }: { run: AgentRun }) {
return (
<Item
render={<span />}
className={cn(
"p-0",
"flex size-7 shrink-0 items-center justify-center border [&_svg]:opacity-95",
agentTileClass
)}
>
<ItemMedia variant="icon" className="size-auto">
{AGENT_DETAILS[run.agent].icon}
</ItemMedia>
</Item>
)
}
function RunTitleAffordance({ title }: { title: string }) {
return (
<span className="group/run-title flex min-w-0 items-center gap-1">
<span
data-slot="run-title"
className="hover:text-primary text-foreground min-w-0 cursor-pointer truncate py-0.25 transition-colors"
>
{title}
</span>
<ArrowRightIcon className="size-3 shrink-0 -translate-x-1 opacity-0 transition-all group-hover/run-title:translate-x-0 group-hover/run-title:opacity-100" aria-hidden="true" />
</span>
)
}
function RunTitleCell({
run,
showContext,
}: {
run: AgentRun
showContext: boolean
}) {
return (
<div data-run-row="run" className="flex min-w-0 items-center gap-3">
<AgentIcon run={run} />
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="min-w-0 text-sm leading-5 font-medium">
<RunTitleAffordance title={run.title} />
</div>
{showContext ? (
<p className="text-muted-foreground truncate text-xs leading-4">
{run.context}
</p>
) : null}
</div>
</div>
)
}
function RunCell({
row,
expanded,
onToggle,
showContext,
}: {
row: RunTableRow
expanded: boolean
onToggle: () => void
showContext: boolean
}) {
if (isRunRow(row)) {
return <RunTitleCell run={row.run} showContext={showContext} />
}
return <StatusGroupCell row={row} expanded={expanded} onToggle={onToggle} />
}
function OwnerCell({ run }: { run: AgentRun }) {
const owners = getRunOwners(run)
const visibleOwners = owners.slice(0, 3)
const overflowCount = Math.max(0, owners.length - visibleOwners.length)
if (owners.length === 0) {
return (
<div className="flex min-w-0 items-center justify-end">
<OwnerAvatar owner={null} />
<span className="sr-only">Unassigned owner</span>
</div>
)
}
return (
<div className="flex min-w-0 items-center justify-end">
<AvatarGroup
aria-label={`Assigned owners for ${run.runKey}`}
className="-space-x-1"
>
{visibleOwners.map((owner) => (
<OwnerAvatar
key={owner.id}
owner={owner}
className="ring-background ring-2"
/>
))}
{overflowCount > 0 ? (
<AvatarGroupCount className="bg-background size-5 border text-[9px] tabular-nums">
+{overflowCount}
</AvatarGroupCount>
) : null}
</AvatarGroup>
<span className="sr-only">
{owners.map((owner) => owner.name).join(",")}
</span>
</div>
)
}
function StartedCell({ run }: { run: AgentRun }) {
return (
<Badge variant="outline" className="bg-background gap-1.5 font-normal">
<CalendarClockIcon className="text-muted-foreground size-3.5" aria-hidden="true" />
<span className="tabular-nums">{run.startedLabel}</span>
</Badge>
)
}
function StepProgressCell({ run }: { run: AgentRun }) {
if (run.stepProgress === null) {
return (
<div className="flex justify-end">
<span className="text-muted-foreground text-sm tabular-nums">-</span>
</div>
)
}
const rate = run.stepProgress
const radius = 9
const circumference = 2 * Math.PI * radius
const dashOffset = circumference - (rate / 100) * circumference
return (
<div
className="flex items-center justify-end gap-2"
aria-label={`${rate}% of steps complete`}
>
<svg
viewBox="0 0 24 24"
className={cn("size-5 shrink-0", stepRingColor(rate))}
aria-hidden="true"
>
<circle
cx="12"
cy="12"
r={radius}
fill="none"
className="stroke-border"
strokeWidth="2.5"
/>
<circle
cx="12"
cy="12"
r={radius}
fill="none"
className="stroke-current"
strokeWidth="2.5"
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={dashOffset}
transform="rotate(-90 12 12)"
/>
</svg>
<span className="text-foreground text-sm tabular-nums">{rate}%</span>
</div>
)
}
function GroupSummaryCell({ row }: { row: RunGroupRow }) {
return (
<div className="relative h-4 min-w-0">
<span className="text-muted-foreground pointer-events-none absolute top-1/2 right-0 w-80 max-w-[calc(100vw-8rem)] -translate-y-1/2 truncate text-right text-sm leading-4">
{row.group.summary}
</span>
</div>
)
}
function RunActionsCell({
run,
onAction,
}: {
run: AgentRun
onAction: (action: RunAction, run: AgentRun) => void
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
type="button"
size="icon-sm"
variant="ghost"
aria-label={`Actions for ${run.runKey}`}
/>
}
>
<MoreHorizontalIcon aria-hidden="true" />
</DropdownMenuTrigger>
{/* Content */}
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuGroup>
<DropdownMenuItem onClick={() => onAction("open", run)}>
<EyeIcon aria-hidden="true" />
Open run
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onAction("copy", run)}>
<CopyIcon aria-hidden="true" />
Copy run id
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onAction("retry", run)}>
<RefreshCwIcon aria-hidden="true" />
Retry run
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onAction("pause", run)}>
<PauseIcon aria-hidden="true" />
Pause run
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
export function createRunColumns({
showContext,
onAction,
}: {
showContext: boolean
onAction: (action: RunAction, run: AgentRun) => void
}): ColumnDef<RunTableRow>[] {
return [
{
accessorFn: (row) => (isRunRow(row) ? row.run.title : row.group.label),
id: "run",
header: ({ column }) => (
<DataGridColumnHeader title="Run" column={column} />
),
cell: ({ row }) => (
<RunCell
row={row.original}
expanded={row.getIsExpanded()}
onToggle={row.getToggleExpandedHandler()}
showContext={showContext}
/>
),
enableHiding: false,
enableSorting: false,
minSize: 300,
meta: {
headerTitle: "Run",
autoSize: true,
},
},
{
accessorFn: (row) =>
isRunRow(row)
? getRunOwners(row.run)
.map((owner) => owner.name)
.join(",") || "Unassigned"
: row.group.focus,
id: "owner",
header: ({ column }) => (
<DataGridColumnHeader title="Owner" column={column} />
),
cell: ({ row }) =>
isRunRow(row.original) ? (
<OwnerCell run={row.original.run} />
) : (
<span className="sr-only">{row.original.group.focus}</span>
),
size: 120,
enableSorting: false,
meta: {
headerTitle: "Owner",
},
},
{
accessorFn: (row) =>
isRunRow(row) ? row.run.environment : row.group.focus,
id: "environment",
header: ({ column }) => (
<DataGridColumnHeader title="Env" column={column} />
),
cell: ({ row }) =>
isRunRow(row.original) ? (
<div className="flex justify-end">
<EnvironmentBadge environment={row.original.run.environment} />
</div>
) : (
<span className="sr-only">{row.original.group.focus}</span>
),
size: 96,
enableSorting: false,
meta: {
headerTitle: "Env",
},
},
{
accessorFn: (row) =>
isRunRow(row)
? row.run.startedAt
: (getLatestRun(getGroupRuns(row))?.startedAt ?? ""),
id: "started",
header: ({ column }) => (
<DataGridColumnHeader title="Started" column={column} />
),
cell: ({ row }) => {
if (isRunRow(row.original)) {
return (
<div className="flex justify-end">
<StartedCell run={row.original.run} />
</div>
)
}
return (
<span className="sr-only">
Latest {getLatestRun(getGroupRuns(row.original))?.startedLabel}
</span>
)
},
size: 92,
enableSorting: false,
meta: {
headerTitle: "Started",
},
},
{
accessorFn: (row) =>
isRunRow(row)
? (row.run.stepProgress ?? -1)
: Math.round(
getGroupRuns(row).reduce(
(sum, run) => sum + (run.stepProgress ?? 0),
0
) / Math.max(getGroupRuns(row).length, 1)
),
id: "steps",
header: ({ column }) => (
<DataGridColumnHeader title="Steps" column={column} />
),
cell: ({ row }) => {
if (isRunRow(row.original)) {
return <StepProgressCell run={row.original.run} />
}
return null
},
size: 84,
enableSorting: false,
meta: {
headerTitle: "Steps",
},
},
{
accessorFn: (row) =>
isRunRow(row)
? row.run.attention
: getGroupAttention(getGroupRuns(row)),
id: "attention",
header: ({ column }) => (
<DataGridColumnHeader title="Attention" column={column} />
),
cell: ({ row }) =>
isRunRow(row.original) ? (
<div className="flex justify-end">
<AttentionBadge attention={row.original.run.attention} />
</div>
) : (
<span className="sr-only">
{getGroupAttention(getGroupRuns(row.original))}
</span>
),
size: 80,
enableSorting: false,
meta: {
headerTitle: "Attention",
},
},
{
id: "actions",
header: "",
cell: ({ row }) =>
isRunRow(row.original) ? (
<div className="flex justify-end">
<RunActionsCell run={row.original.run} onAction={onAction} />
</div>
) : (
<GroupSummaryCell row={row.original} />
),
size: 60,
enableHiding: false,
enableSorting: false,
},
]
}
@@ -0,0 +1,565 @@
"use client"
"use no memo"
import { useCallback, useMemo, useState, type ComponentProps } from "react"
import { Badge } from "@/components/reui/badge"
import {
DataGrid,
DataGridContainer,
} from "@/components/reui/data-grid/data-grid"
import { DataGridScrollArea } from "@/components/reui/data-grid/data-grid-scroll-area"
import { DataGridTable } from "@/components/reui/data-grid/data-grid-table"
import {
getCoreRowModel,
getExpandedRowModel,
useReactTable,
type ExpandedState,
} from "@tanstack/react-table"
import { toast } from "sonner"
import { cn } from "@evofw/ui/lib/utils"
import { Button } from "@evofw/ui/components/button"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@evofw/ui/components/dropdown-menu"
import { Field, FieldGroup, FieldLabel } from "@evofw/ui/components/field"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@evofw/ui/components/input-group"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@evofw/ui/components/popover"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@evofw/ui/components/select"
import { Switch } from "@evofw/ui/components/switch"
import {
AGENT_RUNS,
RUN_ATTENTION_OPTIONS,
RUN_STATUS_GROUPS,
TOAST_INFO_ICON,
TOAST_SUCCESS_ICON,
type AgentRun,
type RunAttention,
type RunGroupRow,
type RunItemRow,
type RunStatusGroup,
type RunTableRow,
} from "./data"
import { createRunColumns, type RunAction } from "./run-queue-columns"
import { SearchIcon, XIcon, BellIcon, Settings2Icon, PlusIcon } from "lucide-react"
type TableDensity = "compact" | "comfortable"
const TABLE_DENSITY_OPTIONS: { value: TableDensity; label: string }[] = [
{ value: "compact", label: "Compact" },
{ value: "comfortable", label: "Comfortable" },
]
function getRunSearchBlob(run: AgentRun) {
return [
run.runKey,
run.title,
run.context,
run.agent,
run.environment,
run.attention,
run.owner?.name,
run.owner?.role,
run.owners.map((owner) => owner.name).join(" "),
run.owners.map((owner) => owner.role).join(" "),
run.stepProgress === null ? "queued" : `${run.stepProgress}%`,
]
.filter(Boolean)
.join(" ")
.toLowerCase()
}
function buildRunRows(runs: AgentRun[]): RunGroupRow[] {
return RUN_STATUS_GROUPS.map((group) => {
const subRows: RunItemRow[] = runs
.filter((run) => run.statusId === group.id)
.map((run) => ({
kind: "run",
id: run.id,
group,
run,
}))
const row: RunGroupRow = {
kind: "group",
id: group.id,
group,
subRows,
}
return row
}).filter((row) => (row.subRows?.length ?? 0) > 0)
}
function getExpandedGroupState(rows: RunGroupRow[]): ExpandedState {
return rows.reduce<Record<string, boolean>>((expanded, row) => {
expanded[row.id] = true
return expanded
}, {})
}
function isExpanded(expanded: ExpandedState, rowId: string) {
if (expanded === true) return true
return expanded[rowId] === true
}
function getGroupRunCount(group: RunStatusGroup, runs: AgentRun[]) {
return runs.filter((run) => run.statusId === group.id).length
}
function getAttentionCount(runs: AgentRun[], attention: RunAttention) {
return runs.filter((run) => run.attention === attention).length
}
function getUnassignedRunCount(runs: AgentRun[]) {
return runs.filter((run) => !run.owner && run.owners.length === 0).length
}
function RunMetric({
label,
value,
variant = "secondary",
}: {
label: string
value: number
variant?: ComponentProps<typeof Badge>["variant"]
}) {
return (
<div className="flex min-w-0 items-center gap-2 sm:border-l sm:pl-3 sm:first:border-l-0 sm:first:pl-0">
<span className="text-muted-foreground truncate text-xs font-medium">
{label}
</span>
<Badge variant={variant}>{value}</Badge>
</div>
)
}
export function RunQueue() {
const [searchQuery, setSearchQuery] = useState("")
const [selectedAttention, setSelectedAttention] = useState<RunAttention[]>([])
const [showContext, setShowContext] = useState(true)
const [tableDensity, setTableDensity] = useState<TableDensity>("compact")
const [expandedRows, setExpandedRows] = useState<ExpandedState>(() =>
getExpandedGroupState(buildRunRows(AGENT_RUNS))
)
const filteredRuns = useMemo(() => {
const normalizedQuery = searchQuery.trim().toLowerCase()
return AGENT_RUNS.filter((run) => {
if (
normalizedQuery.length > 0 &&
!getRunSearchBlob(run).includes(normalizedQuery)
) {
return false
}
if (
selectedAttention.length > 0 &&
!selectedAttention.includes(run.attention)
) {
return false
}
return true
})
}, [searchQuery, selectedAttention])
const groupedRows = useMemo(() => buildRunRows(filteredRuns), [filteredRuns])
const allGroupsExpanded =
groupedRows.length > 0 &&
groupedRows.every((row) => isExpanded(expandedRows, row.id))
const activeFilterCount = selectedAttention.length
const escalatedRunCount = getAttentionCount(filteredRuns, "Escalated")
const approvalRunCount = getAttentionCount(filteredRuns, "Needs approval")
const unassignedRunCount = getUnassignedRunCount(filteredRuns)
const handleAttentionToggle = useCallback(
(attention: RunAttention, checked: boolean) => {
setSelectedAttention((current) => {
if (checked) {
return current.includes(attention) ? current : [...current, attention]
}
return current.filter((item) => item !== attention)
})
},
[]
)
const handleToggleGroups = useCallback(() => {
setExpandedRows(allGroupsExpanded ? {} : getExpandedGroupState(groupedRows))
}, [allGroupsExpanded, groupedRows])
const handleRunAction = useCallback((action: RunAction, run: AgentRun) => {
if (action === "open") {
toast.info("Open run", {
description: `${run.runKey} / ${run.title}`,
icon: TOAST_INFO_ICON,
})
return
}
if (action === "copy") {
if (typeof navigator !== "undefined" && navigator.clipboard) {
void navigator.clipboard.writeText(run.runKey)
}
toast.success("Run id copied", {
description: run.runKey,
icon: TOAST_SUCCESS_ICON,
})
return
}
if (action === "retry") {
toast.success("Run requeued", {
description: `${run.runKey} replays from its last successful checkpoint.`,
icon: TOAST_SUCCESS_ICON,
})
return
}
toast.info("Run paused", {
description: `${run.runKey} holds after its current step until resumed.`,
icon: TOAST_INFO_ICON,
})
}, [])
const handleNewRun = useCallback(() => {
toast.info("New run", {
description: "Connect this action to your agent launch flow.",
icon: TOAST_INFO_ICON,
})
}, [])
const columns = useMemo(
() =>
createRunColumns({
showContext,
onAction: handleRunAction,
}),
[handleRunAction, showContext]
)
const table = useReactTable({
data: groupedRows,
columns,
getRowId: (row) => row.id,
getSubRows: (row) =>
row.kind === "group"
? (row.subRows as RunTableRow[] | undefined)
: undefined,
getRowCanExpand: (row) =>
row.original.kind === "group" && Boolean(row.original.subRows?.length),
state: {
expanded: expandedRows,
},
onExpandedChange: setExpandedRows,
getCoreRowModel: getCoreRowModel(),
getExpandedRowModel: getExpandedRowModel(),
})
function clearFilters() {
setSearchQuery("")
setSelectedAttention([])
}
return (
<DataGrid
table={table}
recordCount={filteredRuns.length}
emptyMessage="No runs match this view. Clear the search or attention filters."
tableLayout={{
dense: tableDensity === "compact",
rowBorder: true,
headerSticky: false,
columnsVisibility: false,
columnsResizable: false,
columnsMovable: false,
width: "fixed",
}}
tableClassNames={{
body: "[&>tr:has(>td:only-child:empty)]:hidden",
bodyRow: cn(
"group/run-row [&>td]:h-9 [&:has([data-run-row=group])>td]:h-11 [&:has([data-run-row=group])>td]:bg-muted/45 [&:has([data-run-row=group])>td]:shadow-none [&:has([data-run-row=group])>td]:hover:bg-muted/45",
showContext && "[&>td]:h-12"
),
edgeCell: "first:ps-3 last:pe-3 lg:first:ps-4 lg:last:pe-4",
}}
>
<section className="flex w-full max-w-7xl flex-col px-4 py-8 sm:px-6 lg:px-8">
{/* Header */}
<div className="mb-4 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div className="flex min-w-0 flex-col gap-1">
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-xl font-semibold tracking-tight">
Run Queue
</h2>
<span className="flex items-center gap-1.5">
<span
className="relative flex size-2 shrink-0"
aria-hidden="true"
>
<span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-500/60" />
<span className="relative inline-flex size-2 rounded-full bg-emerald-500" />
</span>
<span className="text-muted-foreground hidden text-sm sm:block">
Live
</span>
</span>
</div>
<p className="text-muted-foreground max-w-2xl text-sm">
Triage the live agent run backlog.
</p>
</div>
<div className="flex min-w-0 flex-wrap items-center gap-3">
<RunMetric label="Runs" value={filteredRuns.length} />
<RunMetric
label="Escalated"
value={escalatedRunCount}
variant={
escalatedRunCount > 0 ? "destructive-light" : "secondary"
}
/>
<RunMetric
label="Needs approval"
value={approvalRunCount}
variant={approvalRunCount > 0 ? "warning-light" : "secondary"}
/>
<RunMetric label="Unassigned" value={unassignedRunCount} />
</div>
</div>
{/* Toolbar */}
<div className="bg-muted/20 flex flex-col gap-3 border-y px-3 py-3 lg:flex-row lg:items-center lg:justify-between">
<InputGroup className="w-full min-w-0 lg:max-w-sm">
<InputGroupAddon align="inline-start">
<SearchIcon className="text-muted-foreground size-4" aria-hidden="true" />
</InputGroupAddon>
<InputGroupInput
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
placeholder="Search runs..."
aria-label="Search agent runs"
/>
{searchQuery.length > 0 ? (
<InputGroupAddon align="inline-end">
<InputGroupButton
size="icon-xs"
aria-label="Clear search"
onClick={() => setSearchQuery("")}
>
<XIcon className="size-4" aria-hidden="true" />
</InputGroupButton>
</InputGroupAddon>
) : null}
</InputGroup>
<div className="flex min-w-0 flex-wrap items-center gap-1.5 lg:justify-end">
<DropdownMenu modal={false}>
<DropdownMenuTrigger
render={
<Button type="button" variant="outline">
<BellIcon data-icon="inline-start" aria-hidden="true" />
Attention
{activeFilterCount > 0 ? (
<Badge variant="secondary">{activeFilterCount}</Badge>
) : null}
</Button>
}
/>
<DropdownMenuContent align="end" className="min-w-48">
<DropdownMenuGroup>
<DropdownMenuLabel>Attention</DropdownMenuLabel>
{RUN_ATTENTION_OPTIONS.map((attention) => (
<DropdownMenuCheckboxItem
key={attention}
checked={selectedAttention.includes(attention)}
closeOnClick={false}
onCheckedChange={(checked) =>
handleAttentionToggle(attention, checked === true)
}
>
{attention}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
{activeFilterCount > 0 ? (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
closeOnClick={false}
onClick={() => setSelectedAttention([])}
>
Reset attention
</DropdownMenuItem>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
<Popover>
<PopoverTrigger
render={
<Button type="button" variant="outline">
<Settings2Icon data-icon="inline-start" aria-hidden="true" />
Display
</Button>
}
/>
<PopoverContent align="end" className="w-[300px] p-0">
<FieldGroup className="gap-3 px-3.5 py-3">
<div className="flex flex-col gap-2">
<div className="text-muted-foreground text-xs font-medium">
Table
</div>
<div className="flex flex-col">
<Field
orientation="horizontal"
className="min-h-9 items-center justify-between gap-3"
>
<FieldLabel className="text-sm font-normal">
Density
</FieldLabel>
<Select
value={tableDensity}
onValueChange={(value) =>
setTableDensity(value as TableDensity)
}
>
<SelectTrigger
size="sm"
className="w-[132px] shrink-0"
>
<SelectValue>
{
TABLE_DENSITY_OPTIONS.find(
(option) => option.value === tableDensity
)?.label
}
</SelectValue>
</SelectTrigger>
<SelectContent align="end">
<SelectGroup>
{TABLE_DENSITY_OPTIONS.map((option) => (
<SelectItem
key={option.value}
value={option.value}
>
{option.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
<Field
orientation="horizontal"
className="min-h-9 items-center justify-between gap-3"
>
<FieldLabel className="text-sm font-normal">
Latest step line
</FieldLabel>
<Switch
size="sm"
checked={showContext}
onCheckedChange={setShowContext}
aria-label="Toggle the latest step context line"
/>
</Field>
</div>
</div>
</FieldGroup>
</PopoverContent>
</Popover>
<Button
type="button"
variant="outline"
onClick={handleToggleGroups}
>
{allGroupsExpanded ? "Collapse groups" : "Expand groups"}
</Button>
{searchQuery.length > 0 || selectedAttention.length > 0 ? (
<Button type="button" variant="ghost" onClick={clearFilters}>
Clear
</Button>
) : null}
<Button type="button" onClick={handleNewRun}>
<PlusIcon data-icon="inline-start" aria-hidden="true" />
New run
</Button>
</div>
</div>
{/* Content */}
<DataGridContainer className="border-b">
<DataGridScrollArea>
<DataGridTable renderHeader={false} />
</DataGridScrollArea>
</DataGridContainer>
{/* Footer */}
<div className="text-muted-foreground mt-3 flex flex-col gap-2 text-sm lg:flex-row lg:items-center lg:justify-between">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<span className="text-foreground font-medium">Queue Mix</span>
{RUN_STATUS_GROUPS.map((group) => (
<Badge key={group.id} variant="outline">
{group.label}: {getGroupRunCount(group, filteredRuns)}
</Badge>
))}
</div>
<div className="flex min-w-0 flex-wrap items-center gap-2 text-xs">
<span className="text-foreground font-medium">
{filteredRuns.length} visible
</span>
<span className="text-muted-foreground">
of {AGENT_RUNS.length} runs
</span>
<Badge
variant={
activeFilterCount > 0 || searchQuery.length > 0
? "info-light"
: "secondary"
}
>
{activeFilterCount > 0 || searchQuery.length > 0
? "Filtered"
: "All runs"}
</Badge>
</div>
</div>
</section>
</DataGrid>
)
}
@@ -0,0 +1,15 @@
import { RunQueue } from "./components/run-queue"
export function Page() {
return (
<main
className="bg-background mx-auto flex min-h-svh w-full max-w-[1320px] items-start justify-center p-3 md:p-4"
aria-labelledby="page-heading"
>
<h1 id="page-heading" className="sr-only">
Run Queue
</h1>
<RunQueue />
</main>
)
}
@@ -0,0 +1,260 @@
import { type ReactNode } from "react"
import { CircleCheckIcon, InfoIcon } from "lucide-react"
export type RunStatus = "running" | "failed" | "completed"
export type RunPriority = "low" | "medium" | "high" | "urgent"
export type RetryPolicy = "none" | "fixed" | "exponential"
export type StepStatus = "completed" | "active" | "failed" | "pending"
export type ToolCallStatus = "Succeeded" | "Failed"
export type RunSelectOption<TValue extends string = string> = {
value: TValue
label: string
description?: string
}
export type RunOwner = {
id: string
name: string
role: string
initials: string
avatarSrc?: string
}
export type RunSettingsValue = {
status: RunStatus
priority: RunPriority
ownerIds: string[]
retryPolicy: RetryPolicy
maxRetries: string
notifyOnFailure: boolean
}
export type ToolCall = {
id: string
name: string
status: ToolCallStatus
latency: string
note?: string
}
export type RunStep = {
id: number
title: string
status: StepStatus
duration?: string
summary: string
toolCalls: ToolCall[]
error?: { title: string; detail: string }
}
export const TOAST_SUCCESS_ICON = (
<CircleCheckIcon className="size-[18px] text-green-600" aria-hidden="true" />
)
export const TOAST_INFO_ICON = (
<InfoIcon className="text-muted-foreground size-[18px]" aria-hidden="true" />
)
export const RUN_IDENTITY = {
key: "RUN-4822",
agent: "Refund Resolver",
environment: "Production",
}
export const RUN_TIMESTAMPS = {
started: "Jun 10, 12:38",
lastActivity: "8 min ago",
}
export const STATUS_OPTIONS: RunSelectOption<RunStatus>[] = [
{
value: "running",
label: "Running",
description: "Executing steps and streaming output.",
},
{
value: "failed",
label: "Failed",
description: "Stopped at a step and waiting on an operator.",
},
{
value: "completed",
label: "Completed",
description: "Finished every step and wrote its results.",
},
]
export const PRIORITY_OPTIONS: RunSelectOption<RunPriority>[] = [
{ value: "low", label: "Low" },
{ value: "medium", label: "Medium" },
{ value: "high", label: "High" },
{ value: "urgent", label: "Urgent" },
]
export const RETRY_POLICY_OPTIONS: RunSelectOption<RetryPolicy>[] = [
{
value: "none",
label: "No retries",
description: "Fail the run on the first step error.",
},
{
value: "fixed",
label: "Fixed delay",
description: "Retry every 30 seconds.",
},
{
value: "exponential",
label: "Exponential backoff",
description: "Retry at 30s, 2m, then 8m.",
},
]
export const RUN_OWNERS: RunOwner[] = [
{
id: "owner-maya",
name: "Maya Perez",
role: "Operations lead",
initials: "MP",
avatarSrc:
"https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=96&h=96&dpr=2&q=80",
},
{
id: "owner-noa",
name: "Noa Kim",
role: "Reliability engineer",
initials: "NK",
avatarSrc:
"https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=96&h=96&dpr=2&q=80",
},
{
id: "owner-emil",
name: "Emil Novak",
role: "Platform engineer",
initials: "EN",
avatarSrc:
"https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=96&h=96&dpr=2&q=80",
},
{
id: "owner-lara",
name: "Lara Chen",
role: "Support automation",
initials: "LC",
avatarSrc:
"https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=96&h=96&dpr=2&q=80",
},
{
id: "owner-pavel",
name: "Pavel Singh",
role: "Growth engineer",
initials: "PS",
avatarSrc:
"https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=96&h=96&dpr=2&q=80",
},
{
id: "owner-jonas",
name: "Jonas Reed",
role: "Safety reviewer",
initials: "JR",
},
]
export const DEFAULT_RUN_SETTINGS: RunSettingsValue = {
status: "failed",
priority: "high",
ownerIds: ["owner-maya", "owner-noa"],
retryPolicy: "exponential",
maxRetries: "3",
notifyOnFailure: true,
}
export const RUN_STEPS: RunStep[] = [
{
id: 1,
title: "Plan the refund approach",
status: "completed",
duration: "14s",
summary: "Matched the return to its original charge and picked a partial capture.",
toolCalls: [
{
id: "call-1a",
name: "kb.search",
status: "Succeeded",
latency: "320ms",
note: "2 refund runbooks retrieved",
},
{
id: "call-1b",
name: "orders.lookup",
status: "Succeeded",
latency: "410ms",
note: "ORD-99102, 3 line items",
},
],
},
{
id: 2,
title: "Validate the refund policy",
status: "completed",
duration: "9s",
summary: "Partial return of $182.40 fits the 30 day window and needs no second approval.",
toolCalls: [
{
id: "call-2a",
name: "policies.refunds.check",
status: "Succeeded",
latency: "280ms",
note: "under the $500 finance threshold",
},
],
},
{
id: 3,
title: "Capture the partial refund",
status: "failed",
duration: "12s",
summary: "The processor declined the partial capture on the original card.",
error: {
title: "Partial Capture Declined",
detail:
"payments.refunds.create returned 402 card_declined. Retry with a manual amount or credit the account instead.",
},
toolCalls: [
{
id: "call-3a",
name: "payments.refunds.create",
status: "Failed",
latency: "6.1s",
note: "402 card_declined, attempt 1 of 3",
},
{
id: "call-3b",
name: "payments.refunds.create",
status: "Failed",
latency: "5.8s",
note: "402 card_declined, attempt 2 of 3",
},
],
},
{
id: 4,
title: "Verify the refund landed",
status: "pending",
summary: "Waits for the capture to settle before checking the ledger.",
toolCalls: [],
},
{
id: 5,
title: "Notify the customer",
status: "pending",
summary: "Sends the confirmation email with the credited amount.",
toolCalls: [],
},
{
id: 6,
title: "Write back to the order record",
status: "pending",
summary: "Marks ORD-99102 refunded and closes the return.",
toolCalls: [],
},
]
@@ -0,0 +1,222 @@
import { useEffect, useRef, type ReactNode } from "react"
import { cn } from "@evofw/ui/lib/utils"
import { Button } from "@evofw/ui/components/button"
import { Field, FieldTitle } from "@evofw/ui/components/field"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
} from "@evofw/ui/components/input-group"
import { Item, ItemMedia } from "@evofw/ui/components/item"
import { Spinner } from "@evofw/ui/components/spinner"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@evofw/ui/components/tooltip"
import { InfoIcon, PencilIcon, XIcon, CheckIcon } from "lucide-react"
interface EditableDetailRowProps {
label: string
hint?: string
editing?: boolean
display: ReactNode
renderEdit?: (active: boolean) => ReactNode
align?: "center" | "start"
actionsDisabled?: boolean
saving?: boolean
onEdit?: () => void
onCancel?: () => void
onSave?: () => void
}
function RowHint({ label, children }: { label: string; children: string }) {
return (
<Tooltip>
<TooltipTrigger
render={
<Button
type="button"
variant="ghost"
size="icon-xs"
className="text-muted-foreground hover:text-foreground -my-1 shrink-0"
aria-label={label}
/>
}
>
<InfoIcon aria-hidden="true" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-64 text-xs leading-relaxed">
{children}
</TooltipContent>
</Tooltip>
)
}
export function EditableDetailRow({
label,
hint,
editing = false,
display,
renderEdit,
align = "center",
actionsDisabled = false,
saving = false,
onEdit,
onCancel,
onSave,
}: EditableDetailRowProps) {
const editable = Boolean(renderEdit && onEdit && onCancel && onSave)
const controlsDisabled = actionsDisabled || saving
const controlActive = !controlsDisabled
const editActionsDisabled = !editing || controlsDisabled
const editRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!editing) {
return
}
const frame = requestAnimationFrame(() => {
const control = editRef.current?.querySelector<HTMLElement>(
[
"[data-slot='input-group-control']:not(:disabled)",
"[data-slot='combobox-chip-input']:not(:disabled)",
"button:not(:disabled)",
"input:not(:disabled)",
].join(",")
)
control?.focus({ preventScroll: true })
})
return () => cancelAnimationFrame(frame)
}, [editing])
return (
<Field
className={cn(
"group/row grid gap-x-2 gap-y-1 px-4 py-1.5 sm:grid-cols-[minmax(7.75rem,0.5fr)_minmax(0,1.5fr)] sm:gap-4",
align === "start" ? "sm:items-start" : "sm:items-center"
)}
>
<FieldTitle
className={cn(
"text-muted-foreground flex min-w-0 items-center gap-1 text-sm font-normal",
align === "start" && "sm:min-h-8"
)}
>
<span className="min-w-0 truncate">{label}</span>
{hint ? <RowHint label={`${label} info`}>{hint}</RowHint> : null}
</FieldTitle>
{renderEdit ? (
<div
className={cn(
"relative col-start-1 row-start-2 min-h-8 min-w-0 sm:col-start-2 sm:row-start-1",
align === "start" ? "items-start" : "items-center"
)}
>
<button
type="button"
disabled={!editable || controlsDisabled || editing}
aria-label={`Edit ${label}`}
aria-hidden={editing}
className={cn(
"group/value flex w-full min-w-0 rounded-md border border-transparent px-2.5 text-left transition-[opacity,color,background-color] duration-150 outline-none",
align === "start"
? "h-auto min-h-8 items-start py-1"
: "h-8 items-center",
editable &&
"hover:bg-muted/40 active:bg-muted/40 sm:group-hover/row:bg-muted/40 focus-visible:border-transparent! focus-visible:ring-0! focus-visible:outline-none!",
controlsDisabled && "pointer-events-none",
editing
? "pointer-events-none absolute inset-x-0 top-0 opacity-0"
: "relative opacity-100"
)}
onClick={onEdit}
>
<span
className={cn(
"flex min-w-0",
align === "start" ? "items-start" : "items-center"
)}
>
{display}
</span>
{editable ? (
<Item
render={<span />}
className={cn(
"p-0",
"text-muted-foreground ml-1.5 flex size-5 shrink-0 items-center justify-center opacity-100 transition-opacity sm:opacity-0 sm:group-hover/row:opacity-100 sm:group-focus-visible/value:opacity-100",
controlsDisabled && "invisible opacity-0 sm:opacity-0"
)}
>
<ItemMedia variant="icon" className="size-auto">
<PencilIcon className="size-3.5" aria-hidden="true" />
</ItemMedia>
</Item>
) : null}
</button>
<div
ref={editRef}
aria-hidden={!editing}
inert={!editing ? true : undefined}
className={cn(
"min-w-0 transition-opacity duration-150",
editing
? "relative opacity-100"
: "pointer-events-none absolute inset-x-0 top-0 opacity-0"
)}
>
<InputGroup
className={cn(
"has-[[data-slot=input-group-control]:focus-visible]:border-input! box-border w-full has-[[data-slot=input-group-control]:focus-visible]:shadow-none! has-[[data-slot=input-group-control]:focus-visible]:ring-0!",
align === "start" ? "h-auto! min-h-8! items-start" : "h-8"
)}
>
{renderEdit(controlActive)}
{editable ? (
<InputGroupAddon
align="inline-end"
className={cn(
"gap-1 pr-2",
align === "start" && "self-start pt-1"
)}
>
<InputGroupButton
size="icon-xs"
aria-label={`Discard ${label}`}
disabled={editActionsDisabled}
onClick={onCancel}
>
<XIcon className="size-4" aria-hidden="true" />
</InputGroupButton>
<InputGroupButton
size="icon-xs"
aria-label={saving ? `Saving ${label}` : `Save ${label}`}
disabled={editActionsDisabled}
onClick={onSave}
>
{saving ? (
<Spinner className="size-3.5" />
) : (
<CheckIcon className="size-4" aria-hidden="true" />
)}
</InputGroupButton>
</InputGroupAddon>
) : null}
</InputGroup>
</div>
</div>
) : (
<div className="col-start-1 row-start-2 flex min-h-8 min-w-0 items-center px-2.5 sm:col-start-2 sm:row-start-1">
{display}
</div>
)}
</Field>
)
}
@@ -0,0 +1,23 @@
import { RunFacts } from "./run-facts"
import { RunHeader } from "./run-header"
import { RunTrace } from "./run-trace"
// One run's full story: header with live actions, then the step trace (with
// each step's tool calls inline) beside the row-editable run settings panel.
export function RunDetail() {
return (
<div className="@container flex w-full flex-col gap-4">
<RunHeader />
<div className="grid grid-cols-1 items-start gap-4 @4xl:grid-cols-3">
<div className="min-w-0 @4xl:col-span-2">
<RunTrace />
</div>
<div className="min-w-0">
<RunFacts />
</div>
</div>
</div>
)
}
@@ -0,0 +1,703 @@
"use client"
import {
Fragment,
useEffect,
useRef,
useState,
type FormEvent,
type ReactNode,
} from "react"
import { Badge, badgeVariants } from "@/components/reui/badge"
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from "@/components/reui/frame"
import { toast } from "sonner"
import { cn } from "@evofw/ui/lib/utils"
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@evofw/ui/components/avatar"
import {
Combobox,
ComboboxChip,
ComboboxChips,
ComboboxChipsInput,
ComboboxContent,
ComboboxEmpty,
ComboboxItem,
ComboboxList,
ComboboxValue,
useComboboxAnchor,
} from "@evofw/ui/components/combobox"
import { FieldLabel } from "@evofw/ui/components/field"
import {
InputGroupAddon,
InputGroupInput,
InputGroupText,
} from "@evofw/ui/components/input-group"
import {
Item,
ItemContent,
ItemDescription,
ItemTitle,
} from "@evofw/ui/components/item"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@evofw/ui/components/select"
import { Separator } from "@evofw/ui/components/separator"
import { Switch } from "@evofw/ui/components/switch"
import { TooltipProvider } from "@evofw/ui/components/tooltip"
import {
DEFAULT_RUN_SETTINGS,
PRIORITY_OPTIONS,
RETRY_POLICY_OPTIONS,
RUN_OWNERS,
RUN_TIMESTAMPS,
STATUS_OPTIONS,
TOAST_SUCCESS_ICON,
type RunOwner,
type RunSelectOption,
type RunSettingsValue,
} from "./data"
import { EditableDetailRow } from "./editable-detail-row"
import { CircleCheckIcon, AlertTriangleIcon, CircleDotIcon, ZapIcon, RefreshCwIcon, BellIcon, HashIcon, ClockIcon } from "lucide-react"
const FORM_ID = "run-settings"
const SAVE_DELAY_MS = 1800
type EditableRowId = keyof RunSettingsValue
function cloneRunSettingField<TKey extends keyof RunSettingsValue>(
value: RunSettingsValue[TKey]
): RunSettingsValue[TKey] {
if (Array.isArray(value)) {
return [...value] as RunSettingsValue[TKey]
}
return value
}
function cloneRunSettings(value: RunSettingsValue): RunSettingsValue {
return {
...value,
ownerIds: [...value.ownerIds],
}
}
function getOptionLabel<TValue extends string>(
options: RunSelectOption<TValue>[],
value: TValue
) {
return options.find((option) => option.value === value)?.label ?? value
}
function getOwners(ids: string[]) {
const selectedIds = new Set(ids)
return RUN_OWNERS.filter((owner) => selectedIds.has(owner.id))
}
function DetailValue({
icon,
children,
className,
}: {
icon?: ReactNode
children: ReactNode
className?: string
}) {
return (
<span
className={cn(
"text-foreground flex min-h-8 min-w-0 items-center gap-2 text-sm font-medium",
className
)}
>
{icon ? (
<span className="text-muted-foreground flex shrink-0 items-center">
{icon}
</span>
) : null}
<span className="min-w-0 truncate">{children}</span>
</span>
)
}
function StatusBadge({ status }: { status: RunSettingsValue["status"] }) {
if (status === "completed") {
return (
<Badge variant="success-light">
<CircleCheckIcon aria-hidden="true" />
Completed
</Badge>
)
}
if (status === "failed") {
return (
<Badge variant="destructive-light">
<AlertTriangleIcon aria-hidden="true" />
Failed
</Badge>
)
}
return (
<Badge variant="info-light">
<CircleDotIcon aria-hidden="true" />
Running
</Badge>
)
}
function PriorityValue({
priority,
}: {
priority: RunSettingsValue["priority"]
}) {
return (
<DetailValue
icon={
<ZapIcon className="size-4" aria-hidden="true" />
}
>
{getOptionLabel(PRIORITY_OPTIONS, priority)}
</DetailValue>
)
}
function RetryPolicyValue({
retryPolicy,
}: {
retryPolicy: RunSettingsValue["retryPolicy"]
}) {
return (
<DetailValue
icon={
<RefreshCwIcon className="size-4" aria-hidden="true" />
}
>
{getOptionLabel(RETRY_POLICY_OPTIONS, retryPolicy)}
</DetailValue>
)
}
function NotifyValue({
notifyOnFailure,
}: {
notifyOnFailure: RunSettingsValue["notifyOnFailure"]
}) {
return (
<DetailValue
icon={
<BellIcon className="size-4" aria-hidden="true" />
}
>
{notifyOnFailure ? "Notify owners" : "Silent"}
</DetailValue>
)
}
function SelectEditor<TValue extends string>({
id,
value,
options,
disabled,
renderValue,
renderOption,
onValueChange,
}: {
id: string
value: TValue
options: RunSelectOption<TValue>[]
disabled: boolean
renderValue?: (value: TValue) => ReactNode
renderOption?: (option: RunSelectOption<TValue>) => ReactNode
onValueChange: (value: TValue) => void
}) {
return (
<Select
value={value}
disabled={disabled}
onValueChange={(nextValue) => nextValue && onValueChange(nextValue)}
>
<SelectTrigger
id={id}
size="sm"
disabled={disabled}
className="h-8 min-w-0 flex-1 border-0 px-2.5 shadow-none focus:ring-0 focus-visible:ring-0"
>
<SelectValue>
{renderValue ? renderValue(value) : getOptionLabel(options, value)}
</SelectValue>
</SelectTrigger>
<SelectContent className="w-(--anchor-width)">
<SelectGroup>
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{renderOption ? renderOption(option) : option.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
)
}
function OwnersCombobox({
selectedOwnerIds,
disabled,
onValueChange,
}: {
selectedOwnerIds: string[]
disabled: boolean
onValueChange: (ownerIds: string[]) => void
}) {
const anchor = useComboboxAnchor()
const selectedOwners = getOwners(selectedOwnerIds)
return (
<Combobox
multiple
items={RUN_OWNERS}
value={selectedOwners}
disabled={disabled}
itemToStringValue={(owner: RunOwner) => owner.name}
isItemEqualToValue={(item, value) => item.id === value.id}
onValueChange={(owners) => onValueChange(owners.map((owner) => owner.id))}
>
<ComboboxChips
ref={anchor}
className="h-auto! min-h-8! flex-1 flex-wrap! items-center gap-1.5 overflow-visible border-0 bg-transparent px-2 py-1! shadow-none ring-0 focus-within:ring-0 has-data-[slot=combobox-chip]:pl-1"
>
<ComboboxValue>
{(owners: RunOwner[]) => (
<Fragment>
{owners.map((owner) => (
<ComboboxChip
key={owner.id}
showRemove={true}
className={cn(
badgeVariants({ variant: "outline" }),
"min-w-0 gap-1.5"
)}
>
<Avatar className="size-4">
{owner.avatarSrc ? (
<AvatarImage src={owner.avatarSrc} alt={owner.name} />
) : null}
<AvatarFallback className="text-[8px]">
{owner.initials}
</AvatarFallback>
</Avatar>
{owner.name}
</ComboboxChip>
))}
<ComboboxChipsInput
disabled={disabled}
placeholder=""
className="min-w-20 flex-1 bg-transparent"
/>
</Fragment>
)}
</ComboboxValue>
</ComboboxChips>
<ComboboxContent
anchor={anchor}
className="max-w-(--anchor-width) min-w-(--anchor-width)"
>
<ComboboxEmpty>No owners found.</ComboboxEmpty>
<ComboboxList>
{(owner) => (
<ComboboxItem key={owner.id} value={owner}>
<Item size="xs" className="p-0">
<Avatar className="size-6">
{owner.avatarSrc ? (
<AvatarImage src={owner.avatarSrc} alt={owner.name} />
) : null}
<AvatarFallback className="text-[10px]">
{owner.initials}
</AvatarFallback>
</Avatar>
<ItemContent>
<ItemTitle className="whitespace-nowrap">
{owner.name}
</ItemTitle>
<ItemDescription>{owner.role}</ItemDescription>
</ItemContent>
</Item>
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
)
}
function OwnerList({ owners }: { owners: RunOwner[] }) {
return (
<span className="flex min-h-6 min-w-0 flex-wrap items-center gap-1.5 overflow-visible">
{owners.map((owner) => (
<Badge key={owner.id} variant="outline" className="min-w-0 gap-1.5">
<Avatar className="size-3.5">
{owner.avatarSrc ? (
<AvatarImage src={owner.avatarSrc} alt={owner.name} />
) : null}
<AvatarFallback className="text-[8px]">
{owner.initials}
</AvatarFallback>
</Avatar>
<span className="max-w-32 truncate">{owner.name}</span>
</Badge>
))}
</span>
)
}
export function RunFacts() {
const [settings, setSettings] = useState<RunSettingsValue>(() =>
cloneRunSettings(DEFAULT_RUN_SETTINGS)
)
const [draft, setDraft] = useState<RunSettingsValue>(() =>
cloneRunSettings(DEFAULT_RUN_SETTINGS)
)
const [editingRows, setEditingRows] = useState<EditableRowId[]>([])
const [savingRows, setSavingRows] = useState<EditableRowId[]>([])
const saveTimersRef = useRef<number[]>([])
const owners = getOwners(settings.ownerIds)
const hasEditingRows = editingRows.length > 0
const isSaving = savingRows.length > 0
useEffect(() => {
return () => {
saveTimersRef.current.forEach((timer) => window.clearTimeout(timer))
}
}, [])
function beginRowEditing(rowId: EditableRowId) {
if (isSaving) {
return
}
setDraft((currentDraft) => ({
...currentDraft,
[rowId]: cloneRunSettingField(settings[rowId]),
}))
setEditingRows((currentRows) =>
currentRows.includes(rowId) ? currentRows : [...currentRows, rowId]
)
}
function cancelRowEditing(rowId: EditableRowId) {
if (isSaving) {
return
}
setDraft((currentDraft) => ({
...currentDraft,
[rowId]: cloneRunSettingField(settings[rowId]),
}))
setEditingRows((currentRows) =>
currentRows.filter((currentRow) => currentRow !== rowId)
)
}
function updateDraft<TKey extends keyof RunSettingsValue>(
key: TKey,
value: RunSettingsValue[TKey]
) {
setDraft((currentDraft) => ({
...currentDraft,
[key]: value,
}))
}
function saveRowEditing(rowId: EditableRowId) {
if (isSaving) {
return
}
const nextValue = cloneRunSettingField(draft[rowId])
setSavingRows([rowId])
const timer = window.setTimeout(() => {
setSettings((currentSettings) => ({
...currentSettings,
[rowId]: nextValue,
}))
setEditingRows((currentRows) =>
currentRows.filter((currentRow) => currentRow !== rowId)
)
setSavingRows([])
saveTimersRef.current = saveTimersRef.current.filter(
(currentTimer) => currentTimer !== timer
)
toast.success("Run setting saved", {
description: "RUN-4822 applies it from the next step on.",
icon: TOAST_SUCCESS_ICON,
})
}, SAVE_DELAY_MS)
saveTimersRef.current = [...saveTimersRef.current, timer]
}
function saveAllEditing() {
if (!hasEditingRows || isSaving) {
return
}
const rowIds = [...editingRows]
const nextSettings = cloneRunSettings(draft)
setSavingRows(rowIds)
const timer = window.setTimeout(() => {
setSettings(nextSettings)
setEditingRows([])
setSavingRows([])
saveTimersRef.current = saveTimersRef.current.filter(
(currentTimer) => currentTimer !== timer
)
toast.success("Run settings saved", {
description: "RUN-4822 applies them from the next step on.",
icon: TOAST_SUCCESS_ICON,
})
}, SAVE_DELAY_MS)
saveTimersRef.current = [...saveTimersRef.current, timer]
}
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
saveAllEditing()
}
function getEditableRowProps(rowId: EditableRowId) {
return {
editing: editingRows.includes(rowId),
actionsDisabled: isSaving,
saving: savingRows.includes(rowId),
onEdit: () => beginRowEditing(rowId),
onCancel: () => cancelRowEditing(rowId),
onSave: () => saveRowEditing(rowId),
}
}
return (
<TooltipProvider delay={180}>
<form id={FORM_ID} onSubmit={handleSubmit} className="w-full">
<Frame stacked spacing="sm" className="group w-full">
<FrameHeader>
<div className="flex min-w-0 flex-col gap-0.5">
<FrameTitle>Run Settings</FrameTitle>
<FrameDescription className="hidden truncate sm:block">
Applied to this run's retries and notifications.
</FrameDescription>
</div>
</FrameHeader>
<FramePanel className="p-0">
<EditableDetailRow
label="Status"
{...getEditableRowProps("status")}
display={<StatusBadge status={settings.status} />}
renderEdit={(active) => (
<SelectEditor
id="run-settings-status"
value={draft.status}
options={STATUS_OPTIONS}
disabled={!active}
renderValue={(value) => <StatusBadge status={value} />}
renderOption={(option) => (
<StatusBadge status={option.value} />
)}
onValueChange={(value) => updateDraft("status", value)}
/>
)}
/>
<Separator />
<EditableDetailRow
label="Priority"
{...getEditableRowProps("priority")}
display={<PriorityValue priority={settings.priority} />}
renderEdit={(active) => (
<SelectEditor
id="run-settings-priority"
value={draft.priority}
options={PRIORITY_OPTIONS}
disabled={!active}
renderValue={(value) => <PriorityValue priority={value} />}
renderOption={(option) => (
<PriorityValue priority={option.value} />
)}
onValueChange={(value) => updateDraft("priority", value)}
/>
)}
/>
<Separator />
<EditableDetailRow
label="Owners"
align="start"
hint="Notified on failures and approvals."
{...getEditableRowProps("ownerIds")}
display={<OwnerList owners={owners} />}
renderEdit={(active) => (
<OwnersCombobox
selectedOwnerIds={draft.ownerIds}
disabled={!active}
onValueChange={(ownerIds) =>
updateDraft("ownerIds", ownerIds)
}
/>
)}
/>
<Separator />
<EditableDetailRow
label="Retry Policy"
{...getEditableRowProps("retryPolicy")}
display={<RetryPolicyValue retryPolicy={settings.retryPolicy} />}
renderEdit={(active) => (
<SelectEditor
id="run-settings-retry-policy"
value={draft.retryPolicy}
options={RETRY_POLICY_OPTIONS}
disabled={!active}
renderValue={(value) => (
<RetryPolicyValue retryPolicy={value} />
)}
renderOption={(option) => (
<span className="flex min-w-0 flex-col">
<span className="truncate">{option.label}</span>
<span className="text-muted-foreground truncate text-xs">
{option.description}
</span>
</span>
)}
onValueChange={(value) => updateDraft("retryPolicy", value)}
/>
)}
/>
<Separator />
<EditableDetailRow
label="Max Retries"
hint="Step 3 has used 2 of 3 retries."
{...getEditableRowProps("maxRetries")}
display={
<DetailValue
icon={
<HashIcon className="size-4" aria-hidden="true" />
}
>
{settings.maxRetries} per step
</DetailValue>
}
renderEdit={(active) => (
<>
<InputGroupAddon>
<InputGroupText>
<HashIcon className="text-muted-foreground size-4" aria-hidden="true" />
</InputGroupText>
</InputGroupAddon>
<InputGroupInput
id="run-settings-max-retries"
value={draft.maxRetries}
disabled={!active}
inputMode="numeric"
onChange={(event) =>
updateDraft("maxRetries", event.target.value)
}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault()
saveRowEditing("maxRetries")
}
if (event.key === "Escape") {
event.preventDefault()
cancelRowEditing("maxRetries")
}
}}
className="h-8 min-w-0 bg-transparent text-left text-sm font-medium focus-visible:ring-0!"
/>
</>
)}
/>
<Separator />
<EditableDetailRow
label="On Failure"
{...getEditableRowProps("notifyOnFailure")}
display={
<NotifyValue notifyOnFailure={settings.notifyOnFailure} />
}
renderEdit={(active) => (
<div className="flex h-8 min-w-0 flex-1 items-center justify-between gap-3 px-2.5">
<FieldLabel htmlFor="run-settings-notify">
<NotifyValue notifyOnFailure={draft.notifyOnFailure} />
</FieldLabel>
<Switch
id="run-settings-notify"
size="sm"
checked={draft.notifyOnFailure}
disabled={!active}
onCheckedChange={(checked) =>
updateDraft("notifyOnFailure", checked)
}
/>
</div>
)}
/>
<Separator />
<EditableDetailRow
label="Started"
display={
<DetailValue
icon={
<ClockIcon className="size-4" aria-hidden="true" />
}
>
{RUN_TIMESTAMPS.started}
</DetailValue>
}
/>
<Separator />
<EditableDetailRow
label="Last Activity"
display={
<DetailValue
icon={
<CircleCheckIcon className="size-4" aria-hidden="true" />
}
>
{RUN_TIMESTAMPS.lastActivity}
</DetailValue>
}
/>
</FramePanel>
</Frame>
</form>
</TooltipProvider>
)
}
@@ -0,0 +1,167 @@
import { Badge } from "@/components/reui/badge"
import { toast } from "sonner"
import {
Avatar,
AvatarFallback,
AvatarGroup,
AvatarImage,
} from "@evofw/ui/components/avatar"
import { Button } from "@evofw/ui/components/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@evofw/ui/components/dropdown-menu"
import { Separator } from "@evofw/ui/components/separator"
import {
RUN_IDENTITY,
RUN_OWNERS,
TOAST_INFO_ICON,
TOAST_SUCCESS_ICON,
} from "./data"
import { ArrowLeftIcon, AlertTriangleIcon, CircleCheckIcon, RefreshCwIcon, PauseIcon, MoreHorizontalIcon, DownloadIcon, CopyIcon } from "lucide-react"
// Content-level secondary header (navbar-5 run-control grammar, non-sticky):
// left is the queue context and run identity, right is the live action bar.
const WATCHING_OWNERS = RUN_OWNERS.filter((owner) =>
["owner-maya", "owner-noa"].includes(owner.id)
)
export function RunHeader() {
function handleApprove() {
toast.success("Run approved", {
description: `${RUN_IDENTITY.key} continues past the approval gate.`,
icon: TOAST_SUCCESS_ICON,
})
}
function handleRetryStep() {
toast.success("Step 3 requeued", {
description:
"Capture the partial refund replays with exponential backoff.",
icon: TOAST_SUCCESS_ICON,
})
}
function handlePause() {
toast.info("Run paused", {
description: `${RUN_IDENTITY.key} holds before step 4 until resumed.`,
icon: TOAST_INFO_ICON,
})
}
function handleExportTrace() {
toast.info("Trace exported", {
description: "The step and tool call trace is ready as JSON.",
icon: TOAST_INFO_ICON,
})
}
function handleCopyRunId() {
if (typeof navigator !== "undefined" && navigator.clipboard) {
void navigator.clipboard.writeText(RUN_IDENTITY.key)
}
toast.success("Run id copied", {
description: RUN_IDENTITY.key,
icon: TOAST_SUCCESS_ICON,
})
}
return (
<header className="flex flex-wrap items-center justify-between gap-3">
{/* Left: queue context + run identity */}
<div className="flex min-w-0 items-center gap-1.5">
<Button type="button" size="icon-sm" variant="ghost" aria-label="Back to the run queue">
<ArrowLeftIcon className="size-4" aria-hidden="true" />
</Button>
<div className="flex min-w-0 flex-col gap-0.5">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<span className="text-muted-foreground text-sm">Run Queue /</span>
<h2 className="text-foreground text-lg leading-tight font-semibold tracking-tight">
{RUN_IDENTITY.key}
</h2>
<Badge variant="destructive-light">
<AlertTriangleIcon aria-hidden="true" />
Failed
</Badge>
</div>
<p className="text-muted-foreground min-w-0 truncate text-sm">
{RUN_IDENTITY.agent} · {RUN_IDENTITY.environment}
</p>
</div>
</div>
{/* Right: watching owners + run action bar */}
<div className="flex shrink-0 items-center gap-2">
<AvatarGroup aria-label="Owners watching this run" className="-space-x-1">
{WATCHING_OWNERS.map((owner) => (
<Avatar key={owner.id} className="ring-background size-6 ring-2">
{owner.avatarSrc ? (
<AvatarImage src={owner.avatarSrc} alt={owner.name} />
) : null}
<AvatarFallback className="text-[10px]">
{owner.initials}
</AvatarFallback>
</Avatar>
))}
</AvatarGroup>
<Separator orientation="vertical" className="my-auto h-4" />
<Button type="button" variant="outline" size="sm" onClick={handleApprove}>
<CircleCheckIcon className="size-4" aria-hidden="true" />
<span className="hidden md:block">Approve</span>
</Button>
<Button type="button" size="sm" onClick={handleRetryStep}>
<RefreshCwIcon className="size-4" aria-hidden="true" />
<span className="hidden md:block">Retry Step</span>
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Pause the run"
onClick={handlePause}
>
<PauseIcon className="size-4" aria-hidden="true" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="More run actions"
/>
}
>
<MoreHorizontalIcon className="size-4" aria-hidden="true" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={8} className="w-48">
<DropdownMenuGroup>
<DropdownMenuItem onClick={handleExportTrace}>
<DownloadIcon className="opacity-60" aria-hidden="true" />
Export Trace
</DropdownMenuItem>
<DropdownMenuItem onClick={handleCopyRunId}>
<CopyIcon className="opacity-60" aria-hidden="true" />
Copy Run Id
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
)
}
@@ -0,0 +1,212 @@
"use client"
import { Badge, type BadgeProps } from "@/components/reui/badge"
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from "@/components/reui/frame"
import {
Timeline,
TimelineContent,
TimelineHeader,
TimelineIndicator,
TimelineItem,
TimelineSeparator,
TimelineTitle,
} from "@/components/reui/timeline"
import { CheckIcon, ChevronRightIcon, CircleIcon, XIcon } from "lucide-react"
import { toast } from "sonner"
import { cn } from "@evofw/ui/lib/utils"
import {
Alert,
AlertAction,
AlertDescription,
AlertTitle,
} from "@/components/reui/alert"
import { Button } from "@evofw/ui/components/button"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@evofw/ui/components/collapsible"
import { Spinner } from "@evofw/ui/components/spinner"
import {
RUN_STEPS,
TOAST_SUCCESS_ICON,
type RunStep,
type StepStatus,
type ToolCall,
} from "./data"
import { AlertCircleIcon } from "lucide-react"
const toolCallVariant: Record<ToolCall["status"], BadgeProps["variant"]> = {
Succeeded: "success-light",
Failed: "destructive-light",
}
const FAILED_STEP = RUN_STEPS.find((step) => step.status === "failed")
const COMPLETED_STEPS = RUN_STEPS.filter(
(step) => step.status === "completed"
).length
function StatusIcon({ status }: { status: StepStatus }) {
if (status === "completed") {
return <CheckIcon className="size-3" />
}
if (status === "active") {
return <Spinner className="size-3" />
}
if (status === "failed") {
return <XIcon className="size-3" />
}
return <CircleIcon className="size-3" />
}
function ToolCallRow({ call }: { call: ToolCall }) {
return (
<div className="flex min-w-0 flex-wrap items-center justify-between gap-x-3 gap-y-1">
<div className="flex min-w-0 flex-col gap-0.5">
<span className="text-foreground truncate font-mono text-xs font-medium">
{call.name}
</span>
{call.note ? (
<span className="text-muted-foreground truncate text-xs">
{call.note}
</span>
) : null}
</div>
<div className="flex shrink-0 items-center gap-2">
<span className="text-muted-foreground text-xs tabular-nums">
{call.latency}
</span>
<Badge variant={toolCallVariant[call.status]}>{call.status}</Badge>
</div>
</div>
)
}
function StepBody({
step,
onRetryStep,
}: {
step: RunStep
onRetryStep: (step: RunStep) => void
}) {
return (
<div className="space-y-3">
<p className="text-muted-foreground text-xs leading-5">{step.summary}</p>
{step.toolCalls.length > 0 ? (
<div className="space-y-2.5 border-t pt-2.5">
{step.toolCalls.map((call) => (
<ToolCallRow key={call.id} call={call} />
))}
</div>
) : null}
{step.error ? (
<Alert variant="destructive">
<AlertCircleIcon aria-hidden="true" />
<AlertTitle>{step.error.title}</AlertTitle>
<AlertAction>
<Button type="button" size="xs" onClick={() => onRetryStep(step)}>
Retry Step
</Button>
</AlertAction>
<AlertDescription>{step.error.detail}</AlertDescription>
</Alert>
) : null}
</div>
)
}
export function RunTrace() {
function handleRetryStep(step: RunStep) {
toast.success(`Step ${step.id} requeued`, {
description: `${step.title} replays with exponential backoff, attempt 3 of 3.`,
icon: TOAST_SUCCESS_ICON,
})
}
return (
<Frame stacked spacing="sm" className="w-full">
<FrameHeader>
<div className="flex min-w-0 flex-col gap-0.5">
<FrameTitle>Step Trace</FrameTitle>
<FrameDescription className="hidden truncate sm:block">
Failed at step 3 of 6
</FrameDescription>
</div>
</FrameHeader>
<FramePanel>
<Timeline defaultValue={COMPLETED_STEPS}>
{RUN_STEPS.map((step) => (
<TimelineItem key={step.id} step={step.id}>
<TimelineHeader>
<TimelineSeparator className="bg-border group-data-[orientation=vertical]/timeline:h-[calc(100%-1.25rem-0.5rem)] group-data-[orientation=vertical]/timeline:translate-y-6" />
<div className="flex min-w-0 flex-wrap items-center gap-2">
<TimelineTitle className="text-sm font-semibold">
{step.title}
</TimelineTitle>
{step.duration ? (
<span className="text-muted-foreground text-xs tabular-nums">
{step.duration}
</span>
) : null}
</div>
<TimelineIndicator
className={cn(
"bg-muted text-muted-foreground group-data-completed/timeline-item:bg-primary group-data-completed/timeline-item:text-primary-foreground flex size-5 items-center justify-center border-none",
step.status === "failed" &&
"bg-destructive/10 text-destructive dark:bg-destructive/20"
)}
>
<StatusIcon status={step.status} />
</TimelineIndicator>
</TimelineHeader>
<TimelineContent className="mt-2">
<Frame stacked dense spacing="sm">
<Collapsible
defaultOpen={step.status === "failed"}
className="group/collapsible"
>
<CollapsibleTrigger
type="button"
className="flex w-full"
aria-label={`Toggle ${step.title} tool calls`}
>
<FrameHeader className="flex grow flex-row items-center justify-between gap-2">
<span className="text-muted-foreground min-w-0 truncate text-sm font-medium">
{step.toolCalls.length === 1
? "1 tool call"
: `${step.toolCalls.length} tool calls`}
</span>
<ChevronRightIcon
className="text-muted-foreground size-4 shrink-0 transition-transform duration-200 group-data-open/collapsible:rotate-90"
aria-hidden="true"
/>
</FrameHeader>
</CollapsibleTrigger>
<CollapsibleContent>
<FramePanel className="space-y-3">
<StepBody step={step} onRetryStep={handleRetryStep} />
</FramePanel>
</CollapsibleContent>
</Collapsible>
</Frame>
</TimelineContent>
</TimelineItem>
))}
</Timeline>
</FramePanel>
</Frame>
)
}
@@ -0,0 +1,15 @@
import { RunDetail } from "./components/run-detail"
export function Page() {
return (
<main
className="bg-background mx-auto flex min-h-svh w-full max-w-[1320px] items-start justify-center p-3 md:p-4"
aria-labelledby="page-heading"
>
<h1 id="page-heading" className="sr-only">
Run Detail
</h1>
<RunDetail />
</main>
)
}
@@ -0,0 +1,263 @@
import { type ReactNode } from "react"
import { type BadgeProps } from "@/components/reui/badge"
import { CircleCheckIcon, AlertCircleIcon, InfoIcon, SmartphoneIcon, MailIcon, CalendarIcon, MessageSquareTextIcon, MonitorIcon } from "lucide-react"
// ── Toast feedback icons ─────────────────────────────────────────────────────
// Success toasts use a green check; the destructive toast uses the alert glyph
// with text-destructive; message toasts use a muted info icon.
export const TOAST_SUCCESS_ICON = (
<CircleCheckIcon className="size-[18px] text-green-600" aria-hidden="true" />
)
export const TOAST_ERROR_ICON = (
<AlertCircleIcon className="text-destructive size-[18px]" aria-hidden="true" />
)
export const TOAST_MESSAGE_ICON = (
<InfoIcon className="text-muted-foreground size-[18px]" aria-hidden="true" />
)
// Mid-market B2B SaaS sales world pack. Each rep keeps one portrait across the
// block so the owner avatar, task owner, and activity authors stay one identity
// (Emma Wilson is the deliberate initials-only fallback).
const PEOPLE = {
mira: {
id: "mira-stone",
name: "Mira Stone",
role: "Account Executive",
initials: "MS",
avatar:
"https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=96&h=96&dpr=2&q=80",
},
leo: {
id: "leo-grant",
name: "Leo Grant",
role: "Account Executive",
initials: "LG",
avatar:
"https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=96&h=96&dpr=2&q=80",
},
nora: {
id: "nora-vale",
name: "Nora Vale",
role: "Sales Manager",
initials: "NV",
avatar:
"https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=96&h=96&dpr=2&q=80",
},
sana: {
id: "sana-qureshi",
name: "Sana Qureshi",
role: "Account Executive",
initials: "SQ",
avatar:
"https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=96&h=96&dpr=2&q=80",
},
emma: {
id: "emma-wilson",
name: "Emma Wilson",
role: "Sales Development Rep",
initials: "EW",
},
} as const
// ── Deal stage ───────────────────────────────────────────────────────────────
export type DealStage =
| "Qualified"
| "Discovery"
| "Proposal"
| "Negotiation"
| "Closed Won"
| "Closed Lost"
export const stageVariant: Record<DealStage, BadgeProps["variant"]> = {
Qualified: "secondary",
Discovery: "secondary",
Proposal: "info-light",
Negotiation: "warning-light",
"Closed Won": "success-light",
"Closed Lost": "destructive-light",
}
// Win probability per stage; the Select moves both the badge and the progress.
export const stageProbability: Record<DealStage, number> = {
Qualified: 20,
Discovery: 35,
Proposal: 55,
Negotiation: 75,
"Closed Won": 100,
"Closed Lost": 0,
}
export const STAGE_OPTIONS: { value: DealStage; label: DealStage }[] = [
{ value: "Qualified", label: "Qualified" },
{ value: "Discovery", label: "Discovery" },
{ value: "Proposal", label: "Proposal" },
{ value: "Negotiation", label: "Negotiation" },
{ value: "Closed Won", label: "Closed Won" },
{ value: "Closed Lost", label: "Closed Lost" },
]
// ── Key fields ───────────────────────────────────────────────────────────────
export type TextSegments = string | readonly string[]
export type DealField = {
label: string
value: TextSegments
}
// ── Deal team ────────────────────────────────────────────────────────────────
export type DealOwner = {
id: string
name: string
role: string
initials: string
avatar?: string
}
// ── Next step / task ─────────────────────────────────────────────────────────
export type TaskStatus = "Due today" | "Upcoming" | "Overdue"
export const taskStatusVariant: Record<TaskStatus, BadgeProps["variant"]> = {
"Due today": "warning-light",
Upcoming: "secondary",
Overdue: "destructive-light",
}
export type DealTask = {
id: string
name: string
type: string
due: TextSegments
status: TaskStatus
owner: DealOwner
}
// ── Activity log (timeline-1 grammar) ────────────────────────────────────────
export type ActivityKind = "call" | "email" | "meeting" | "note" | "demo"
export const activityKindIcon: Record<ActivityKind, ReactNode> = {
call: (
<SmartphoneIcon className="size-3.5" aria-hidden="true" />
),
email: (
<MailIcon className="size-3.5" aria-hidden="true" />
),
meeting: (
<CalendarIcon className="size-3.5" aria-hidden="true" />
),
note: (
<MessageSquareTextIcon className="size-3.5" aria-hidden="true" />
),
demo: (
<MonitorIcon className="size-3.5" aria-hidden="true" />
),
}
export const activityKindIndicatorClass: Record<ActivityKind, string> = {
call: "border-success/20 bg-success/10 text-success dark:bg-success/15",
email: "border-info/20 bg-info/10 text-info dark:bg-info/15",
meeting: "border-primary/20 bg-primary/10 text-primary dark:bg-primary/15",
note: "border-warning/20 bg-warning/10 text-warning dark:bg-warning/15",
demo: "border-info/20 bg-info/10 text-info dark:bg-info/15",
}
export type DealActivity = {
id: string
kind: ActivityKind
title: TextSegments
detail?: string
author: DealOwner
timeLabel: string
}
// ── The deal on the sheet ────────────────────────────────────────────────────
export type DealDetail = {
id: string
name: TextSegments
stage: DealStage
amount: string
delta: string
prior: string
closeLine: TextSegments
fields: DealField[]
financials: DealField[]
owners: DealOwner[]
ownerId: string
noteAuthor: DealOwner
note: string
nextStep: DealTask
}
export const DEAL: DealDetail = {
id: "DEAL-4821",
name: ["Brightwave Media", "Pro Annual"],
stage: "Negotiation",
amount: "$48,000",
delta: "+$6K",
prior: "vs $42K at proposal",
closeLine: ["Closes Jun 28", "41 days in pipeline"],
fields: [
{ label: "Account", value: "Brightwave Media" },
{ label: "Primary contact", value: ["Daniel Cho", "VP Marketing"] },
{ label: "Close date", value: "Jun 28, 2026" },
{ label: "Plan", value: ["Pro Annual", "45 seats"] },
{ label: "Source", value: "Inbound" },
{ label: "Deal ID", value: "DEAL-4821" },
],
financials: [
{ label: "Amount", value: "$48,000 ARR" },
{ label: "Forecast", value: ["Commit", "$36K weighted"] },
{ label: "Discount", value: "12% off list" },
{ label: "Term", value: ["12 months", "auto-renew"] },
],
owners: [PEOPLE.mira, PEOPLE.nora, PEOPLE.emma],
ownerId: PEOPLE.mira.id,
noteAuthor: PEOPLE.mira,
note: "Daniel confirmed budget for 45 seats. Legal reviewing the MSA, redlines back by Jun 24. Security questionnaire cleared on Jun 16.",
nextStep: {
id: "TASK-318",
name: "Send revised order form",
type: "Email",
due: ["Due today", "4:00 PM"],
status: "Due today",
owner: PEOPLE.mira,
},
}
export const DEAL_ACTIVITY: DealActivity[] = [
{
id: "act-1",
kind: "call",
title: ["Pricing call", "Connected"],
detail:
"Daniel Cho agreed to 45 seats at 12% off. Asked for revised order form by end of week.",
author: PEOPLE.mira,
timeLabel: "2h ago",
},
{
id: "act-2",
kind: "email",
title: "Security questionnaire returned",
detail: "SOC 2 report and DPA sent to Brightwave legal. No open items.",
author: PEOPLE.nora,
timeLabel: "Yesterday",
},
{
id: "act-3",
kind: "demo",
title: ["Workflow demo", "6 attendees"],
detail:
"Walked the Brightwave team through automations and reporting. Strong interest from ops.",
author: PEOPLE.mira,
timeLabel: "Jun 11",
},
{
id: "act-4",
kind: "meeting",
title: "Discovery with VP Marketing",
detail: "Scoped 45 seats across two teams. Budget cycle closes end of Q2.",
author: PEOPLE.emma,
timeLabel: "Jun 4",
},
]
@@ -0,0 +1,617 @@
import { useState, type ReactNode } from "react"
import { Badge } from "@/components/reui/badge"
import {
Timeline,
TimelineContent,
TimelineHeader,
TimelineIndicator,
TimelineItem,
TimelineSeparator,
TimelineTitle,
} from "@/components/reui/timeline"
import { toast } from "sonner"
import { cn } from "@evofw/ui/lib/utils"
import {
Avatar,
AvatarFallback,
AvatarGroup,
AvatarImage,
} from "@evofw/ui/components/avatar"
import { Button } from "@evofw/ui/components/button"
import { Progress } from "@evofw/ui/components/progress"
import { ScrollArea } from "@evofw/ui/components/scroll-area"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@evofw/ui/components/select"
import { Separator } from "@evofw/ui/components/separator"
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@evofw/ui/components/sheet"
import {
activityKindIcon,
activityKindIndicatorClass,
DEAL,
DEAL_ACTIVITY,
STAGE_OPTIONS,
stageProbability,
stageVariant,
taskStatusVariant,
TOAST_ERROR_ICON,
TOAST_MESSAGE_ICON,
TOAST_SUCCESS_ICON,
type DealActivity,
type DealField,
type DealOwner,
type DealStage,
type DealTask,
type TextSegments,
} from "./data"
import { ListChecksIcon, PanelRightIcon, CircleDollarSignIcon, XIcon, CheckIcon, PlusIcon } from "lucide-react"
// Section wrapper: the px-5 rhythm and Separator between blocks are lifted from
// sheet-7's notification list so the drawer keeps one vertical spine.
function Section({
label,
action,
children,
}: {
label?: string
action?: ReactNode
children: ReactNode
}) {
return (
<div className="flex flex-col gap-3 px-5 py-4">
{label ? (
<div className="flex items-center justify-between gap-2">
<h3 className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{label}
</h3>
{action}
</div>
) : null}
{children}
</div>
)
}
function DotSeparator({ className }: { className?: string }) {
return (
<span
className={cn(
"bg-muted-foreground/40 size-1 shrink-0 rounded-full",
className
)}
aria-hidden="true"
/>
)
}
function SegmentedText({
value,
className,
}: {
value: TextSegments
className?: string
}) {
const segments = Array.isArray(value) ? value : [value]
if (segments.length === 1) {
return <span className={className}>{segments[0]}</span>
}
return (
<span
className={cn(
"inline-flex min-w-0 flex-wrap items-center gap-x-1.5",
className
)}
>
{segments.map((segment, index) => (
<span
key={`${segment}-${index}`}
className="inline-flex min-w-0 items-center gap-x-1.5"
>
{index > 0 ? <DotSeparator /> : null}
<span className="min-w-0 truncate">{segment}</span>
</span>
))}
</span>
)
}
function formatText(value: TextSegments) {
return Array.isArray(value) ? value.join(", ") : value
}
// MiniProgress: the hatched track + Progress indicator copied verbatim from
// sheet-7; the win-probability bar is half of the block's signature.
function MiniProgress({ value }: { value: number }) {
const indicatorColor =
value >= 80
? "**:data-[slot=progress-indicator]:bg-success"
: value >= 50
? "**:data-[slot=progress-indicator]:bg-primary"
: "**:data-[slot=progress-indicator]:bg-warning"
return (
<div className="bg-muted/55 relative mt-1.5 h-1 overflow-hidden rounded-full">
<div
className="text-muted-foreground pointer-events-none absolute inset-0 bg-[repeating-linear-gradient(-45deg,currentColor_0,currentColor_1px,transparent_0,transparent_4px)] opacity-20"
aria-hidden="true"
/>
<Progress
value={value}
className={cn(
"absolute inset-0 gap-0",
"**:data-[slot=progress-track]:h-full **:data-[slot=progress-track]:rounded-none **:data-[slot=progress-track]:bg-transparent",
"**:data-[slot=progress-indicator]:rounded-none",
indicatorColor
)}
/>
</div>
)
}
// FactRow: key fields as a label/value dl, the grammar from solution-agents-7's
// run-detail sheet.
function FactRow({ fact }: { fact: DealField }) {
return (
<div className="contents">
<dt className="text-muted-foreground text-sm">{fact.label}</dt>
<dd className="text-foreground min-w-0 text-sm">
<SegmentedText value={fact.value} />
</dd>
</div>
)
}
// Owner avatars: sheet-7's AvatarGroup grammar; Emma Wilson resolves to initials
// as the deliberate fallback. The deal-team avatars are the other half of the
// signature.
function OwnerAvatars({
owners,
ownerId,
}: {
owners: DealOwner[]
ownerId: string
}) {
const owner = owners.find((person) => person.id === ownerId)
return (
<div className="flex items-center justify-between gap-3">
<AvatarGroup className="-space-x-1">
{owners.map((person) => (
<Avatar key={person.id} className="size-6">
{person.avatar ? (
<AvatarImage src={person.avatar} alt={person.name} />
) : null}
<AvatarFallback className="text-[10px]">
{person.initials}
</AvatarFallback>
</Avatar>
))}
</AvatarGroup>
{owner ? (
<p className="text-muted-foreground inline-flex min-w-0 flex-wrap items-center justify-end gap-x-1.5 text-xs">
<span className="text-foreground font-medium">{owner.name}</span>
<DotSeparator />
<span className="truncate">owns</span>
</p>
) : null}
</div>
)
}
// Next-step row: sheet-7's notification-item grammar (colored icon cell +
// content column with a title/badge row and a muted meta line), reskinned to the
// deal's one open task. No hand-rolled card or icon box.
function NextStepRow({ task }: { task: DealTask }) {
return (
<div className="flex items-start gap-3">
<div className="text-warning relative grid size-5 shrink-0 place-items-center">
<span className="grid size-5 place-items-center leading-none [&_svg]:block [&_svg]:size-4">
<ListChecksIcon aria-hidden="true" />
</span>
</div>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex items-start justify-between gap-2">
<p className="text-foreground min-w-0 text-sm leading-5 font-medium">
{task.name}
</p>
<Badge variant={taskStatusVariant[task.status]}>{task.status}</Badge>
</div>
<p className="text-muted-foreground inline-flex flex-wrap items-center gap-x-1.5 text-xs">
<span>{task.type}</span>
<DotSeparator />
<SegmentedText value={task.due} />
</p>
<div className="text-muted-foreground mt-0.5 flex items-center gap-1.5 text-xs">
<Avatar className="size-5">
{task.owner.avatar ? (
<AvatarImage src={task.owner.avatar} alt={task.owner.name} />
) : null}
<AvatarFallback className="text-[9px]">
{task.owner.initials}
</AvatarFallback>
</Avatar>
<span>{task.owner.name}</span>
</div>
</div>
</div>
)
}
// ActivityRow: timeline-1's TimelineItem / Header / Indicator grammar (via the
// agents-7 ladder) reskinned to one logged activity.
function ActivityRow({
activity,
step,
}: {
activity: DealActivity
step: number
}) {
return (
<TimelineItem
step={step}
className="has-[+[data-completed]]:[&_[data-slot=timeline-separator]]:bg-border group-data-[orientation=vertical]/timeline:ms-8 group-data-[orientation=vertical]/timeline:not-last:pb-6"
>
<TimelineHeader className="flex min-w-0 items-start justify-between gap-2.5">
<TimelineSeparator className="bg-border group-data-[orientation=vertical]/timeline:-left-6 group-data-[orientation=vertical]/timeline:h-[calc(100%-1.25rem-1rem)] group-data-[orientation=vertical]/timeline:translate-y-7" />
<TimelineIndicator
className={cn(
"group-data-completed/timeline-item:border-border flex size-5 items-center justify-center rounded-full border group-data-[orientation=vertical]/timeline:-left-6 [&_svg]:size-3",
activityKindIndicatorClass[activity.kind]
)}
>
{activityKindIcon[activity.kind]}
</TimelineIndicator>
<TimelineTitle className="min-w-0 text-sm leading-5">
<SegmentedText
value={activity.title}
className="text-foreground font-medium"
/>
</TimelineTitle>
<span className="text-muted-foreground shrink-0 text-xs tabular-nums">
{activity.timeLabel}
</span>
</TimelineHeader>
<TimelineContent className="flex min-w-0 flex-col items-start gap-2 pb-1">
{activity.detail ? (
<p className="text-muted-foreground max-w-[52ch] text-sm leading-5">
{activity.detail}
</p>
) : null}
<div className="text-muted-foreground flex items-center gap-1.5 text-xs">
<Avatar className="size-5">
{activity.author.avatar ? (
<AvatarImage
src={activity.author.avatar}
alt={activity.author.name}
/>
) : null}
<AvatarFallback className="text-[9px]">
{activity.author.initials}
</AvatarFallback>
</Avatar>
<span>{activity.author.name}</span>
</div>
</TimelineContent>
</TimelineItem>
)
}
export function DealDetailSheet() {
const [open, setOpen] = useState(true)
// savedStage is the committed baseline; stage is the in-flight selection.
const [savedStage, setSavedStage] = useState<DealStage>(DEAL.stage)
const [stage, setStage] = useState<DealStage>(DEAL.stage)
const probability = stageProbability[stage]
const dirty = stage !== savedStage
function handleStageChange(value: string | null) {
if (value !== null) {
setStage(value as DealStage)
}
}
function handleSave() {
setSavedStage(stage)
toast.success("Deal updated", {
description: `${formatText(DEAL.name)} moved to ${stage}, ${stageProbability[stage]}% win probability.`,
icon: TOAST_SUCCESS_ICON,
})
}
function handleLogActivity() {
toast.message("Activity logged", {
description:
"Call with Daniel Cho saved to the Brightwave Media timeline.",
icon: TOAST_MESSAGE_ICON,
})
}
function handleAddNote() {
toast.message("Note added", {
description: "Saved to DEAL-4821. Visible to the deal team on next sync.",
icon: TOAST_MESSAGE_ICON,
})
}
function handleWon() {
setStage("Closed Won")
setSavedStage("Closed Won")
toast.success("Deal marked Won", {
description: `${formatText(DEAL.name)} booked at ${DEAL.amount} ARR. Closes Jun 28.`,
icon: TOAST_SUCCESS_ICON,
})
}
function handleLost() {
setStage("Closed Lost")
setSavedStage("Closed Lost")
toast.error("Deal marked Lost", {
description:
"Brightwave Media moved to Closed Lost. Add a reason on next sync.",
icon: TOAST_ERROR_ICON,
})
}
return (
<Sheet open={open} onOpenChange={setOpen}>
{/* Actions */}
<div className="flex min-h-[360px] items-center justify-center">
<SheetTrigger
render={
<Button
type="button"
variant="outline"
size="lg"
onClick={() => setOpen(true)}
>
<PanelRightIcon data-icon="inline-start" aria-hidden="true" />
Open Deal
</Button>
}
/>
</div>
{/* Content */}
<SheetContent
side="right"
showCloseButton={false}
initialFocus={false}
className="bg-popover inset-y-4 right-4 left-auto flex h-[calc(100svh-2rem)] w-[min(30rem,calc(100vw-2rem))] max-w-none flex-col gap-0 overflow-hidden rounded-xl p-0 outline-none"
>
{/* Header */}
<SheetHeader className="shrink-0 gap-0 border-b p-0">
<div className="flex min-h-9 items-center justify-between gap-2 px-4">
<span className="text-muted-foreground inline-flex items-center gap-2 text-xs">
<CircleDollarSignIcon className="size-3.5" aria-hidden="true" />
{DEAL.id}
</span>
<SheetClose
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Close deal detail"
className="shrink-0"
>
<XIcon aria-hidden="true" />
</Button>
}
/>
</div>
<div className="flex min-w-0 flex-col gap-1.5 px-4 pt-0 pb-3">
<div className="flex min-w-0 items-center gap-2">
<SheetTitle className="min-w-0 flex-1 truncate text-lg font-semibold tracking-tight">
<SegmentedText value={DEAL.name} className="flex-nowrap" />
</SheetTitle>
<Badge variant={stageVariant[stage]}>{stage}</Badge>
</div>
<SheetDescription className="text-muted-foreground text-xs">
<SegmentedText value={DEAL.closeLine} />
</SheetDescription>
<div className="flex items-center gap-2 pt-0.5">
<Button type="button" size="sm" onClick={handleWon}>
<CheckIcon data-icon="inline-start" aria-hidden="true" />
Mark Won
</Button>
<Button
type="button"
size="sm"
variant="outline"
onClick={handleLost}
>
Mark Lost
</Button>
</div>
</div>
</SheetHeader>
{/* Body */}
<div className="min-h-0 flex-1">
<ScrollArea className="h-full min-h-0">
{/* Hero: amount, delta, and the win-probability progress */}
<Section>
<div className="flex items-end justify-between gap-3">
<div className="flex items-baseline gap-2">
<span className="text-foreground text-4xl font-semibold tracking-tight tabular-nums">
{DEAL.amount}
</span>
<Badge variant="success-light">{DEAL.delta}</Badge>
</div>
<span className="text-muted-foreground pb-1 text-xs">
{DEAL.prior}
</span>
</div>
<MiniProgress value={probability} />
<div className="text-muted-foreground flex items-center justify-between gap-2 text-xs">
<span>Win probability</span>
<span className="tabular-nums">{probability}%</span>
</div>
</Section>
<Separator className="opacity-60" />
{/* Inline stage change */}
<Section label="Stage">
<div className="flex items-center gap-2">
<Select
value={stage}
onValueChange={handleStageChange}
items={STAGE_OPTIONS}
>
<SelectTrigger
id="deal-stage"
aria-label="Change deal stage"
className="w-full"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{STAGE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
{dirty ? (
<Button type="button" size="sm" onClick={handleSave}>
Save
</Button>
) : null}
</div>
{dirty ? (
<p className="text-warning inline-flex items-center gap-1.5 text-xs">
<span
className="bg-warning size-1.5 shrink-0 rounded-full"
aria-hidden="true"
/>
<span>Unsaved stage change</span>
<DotSeparator className="bg-warning/70" />
<span>win probability now {probability}%</span>
</p>
) : null}
</Section>
<Separator className="opacity-60" />
{/* Key fields */}
<Section label="Key Fields">
<dl className="grid grid-cols-[7.5rem_1fr] gap-x-4 gap-y-2.5">
{DEAL.fields.map((field) => (
<FactRow key={field.label} fact={field} />
))}
</dl>
</Section>
<Separator className="opacity-60" />
{/* Deal team */}
<Section label="Deal Team">
<OwnerAvatars owners={DEAL.owners} ownerId={DEAL.ownerId} />
</Section>
<Separator className="opacity-60" />
{/* Next step */}
<Section
label="Next Step"
action={
<Button
type="button"
variant="ghost"
size="xs"
onClick={handleLogActivity}
>
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Log activity
</Button>
}
>
<NextStepRow task={DEAL.nextStep} />
</Section>
<Separator className="opacity-60" />
{/* Financials */}
<Section label="Financials">
<div className="grid grid-cols-[7.5rem_1fr] gap-x-4 gap-y-2.5">
{DEAL.financials.map((field) => (
<FactRow key={field.label} fact={field} />
))}
</div>
</Section>
<Separator className="opacity-60" />
{/* Note */}
<Section
label="Note"
action={
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleAddNote}
>
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Add note
</Button>
}
>
<div className="flex flex-col gap-2">
<p className="text-foreground text-sm leading-5">{DEAL.note}</p>
<div className="text-muted-foreground flex items-center gap-1.5 text-xs">
<Avatar className="size-5">
{DEAL.noteAuthor.avatar ? (
<AvatarImage
src={DEAL.noteAuthor.avatar}
alt={DEAL.noteAuthor.name}
/>
) : null}
<AvatarFallback className="text-[9px]">
{DEAL.noteAuthor.initials}
</AvatarFallback>
</Avatar>
<span>{DEAL.noteAuthor.name}</span>
</div>
</div>
</Section>
<Separator className="opacity-60" />
{/* Activity log */}
<Section label="Activity">
<Timeline defaultValue={0}>
{DEAL_ACTIVITY.map((item, index) => (
<ActivityRow key={item.id} activity={item} step={index + 1} />
))}
</Timeline>
</Section>
</ScrollArea>
</div>
</SheetContent>
</Sheet>
)
}
@@ -0,0 +1,15 @@
import { DealDetailSheet } from "./components/deal-detail-sheet"
export function Page() {
return (
<main
className="flex min-h-svh w-full items-center justify-center p-6 sm:p-10 md:p-12"
aria-labelledby="page-heading"
>
<h1 id="page-heading" className="sr-only">
Deal detail
</h1>
<DealDetailSheet />
</main>
)
}
@@ -0,0 +1,75 @@
export const SHIPMENT = {
id: "SHP-3827462",
status: "Shipped" as const,
placedDate: "2022-01-01",
orderId: "SO-AMS-4620",
carrier: { name: "DHL Global" },
route: {
from: "1234 Industrial Way, Dallas, TX 75201",
to: "8458 Sunset Blvd #209, Los Angeles, CA 90069",
},
}
// ─────────────────────────────────────────────────────────────────────────
// Status stepper
// ─────────────────────────────────────────────────────────────────────────
export type StepState = "done" | "active" | "pending"
export interface ShippingStep {
label: string
state: StepState
}
export const SHIPPING_STEPS: ShippingStep[] = [
{ label: "Picking", state: "done" },
{ label: "Packed", state: "done" },
{ label: "Shipping", state: "active" },
{ label: "Delivered", state: "pending" },
]
// ─────────────────────────────────────────────────────────────────────────
// Shipping data summary
// ─────────────────────────────────────────────────────────────────────────
export const SHIPPING_DATA = {
totalTime: "19 days, 7 hours",
depTime: "01 Aug, 2025 09:17",
expArrival: "17 Apr, 2025 12:00",
trackingNo: "1Z999AA10123456784",
}
// ─────────────────────────────────────────────────────────────────────────
// Shipping log timeline
// ─────────────────────────────────────────────────────────────────────────
export interface LogEntry {
event: string
datetime: string
description: string
location?: string
}
export const SHIPPING_LOG: LogEntry[] = [
{
event: "Order Placed",
datetime: "28 Jul, 2025 10:02",
description: "Shipment information received by seller",
location: "Silicon Valley, CA",
},
{
event: "Picking",
datetime: "28 Jul, 2025 11:02",
description: "Items being picked from inventory",
},
{
event: "Packed",
datetime: "28 Jul, 2025 12:27",
description: "Shipment information received by seller",
},
{
event: "Shipped",
datetime: "28 Jul, 2025 14:27",
description: "Package handed off to carrier",
},
]
@@ -0,0 +1,428 @@
"use client"
import { useState } from "react"
import { Badge } from "@/components/reui/badge"
import {
Frame,
FrameHeader,
FramePanel,
FrameTitle,
} from "@/components/reui/frame"
import {
Timeline,
TimelineContent,
TimelineIndicator,
TimelineItem,
TimelineSeparator,
} from "@/components/reui/timeline"
import { cn } from "@evofw/ui/lib/utils"
import { Button } from "@evofw/ui/components/button"
import { ScrollArea } from "@evofw/ui/components/scroll-area"
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@evofw/ui/components/sheet"
import {
SHIPMENT,
SHIPPING_DATA,
SHIPPING_LOG,
SHIPPING_STEPS,
type ShippingStep,
} from "./data"
import { CheckIcon, MapPinIcon, XIcon } from "lucide-react"
const ECOMMERCE_LINK_CLASS_NAME =
"underline-offset-4 transition-colors hover:text-primary hover:underline"
const FRAME_PANEL_RESET = "shadow-none!"
// ─────────────────────────────────────────────────────────────────────────
// Carrier chip - mini DHL logo (yellow plate + red italic "DHL" + 3 red bars)
// ─────────────────────────────────────────────────────────────────────────
function DhlLogo(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
viewBox="0 0 40 12"
role="img"
aria-label="DHL"
shapeRendering="geometricPrecision"
{...props}
>
<rect width="40" height="12" rx="1.5" fill="#FFCC00" />
<text
x="3"
y="9.4"
fontFamily="Arial Black, Arial, Helvetica, sans-serif"
fontSize="8"
fontWeight="900"
fontStyle="italic"
letterSpacing="-0.2"
fill="#D40511"
>
DHL
</text>
<g fill="#D40511">
<polygon points="24,3.5 33,3.5 32,4.7 23,4.7" />
<polygon points="25,5.4 34,5.4 33,6.6 24,6.6" />
<polygon points="26,7.3 35,7.3 34,8.5 25,8.5" />
</g>
</svg>
)
}
function CarrierChip({ name }: { name: string }) {
return (
<div className="border-border bg-background inline-flex items-center gap-2 rounded-md border px-2.5 py-1">
<DhlLogo className="h-3 w-auto" />
<span className="text-foreground text-sm font-medium">{name}</span>
</div>
)
}
// ─────────────────────────────────────────────────────────────────────────
// Route + status stepper
// ─────────────────────────────────────────────────────────────────────────
// Shared timeline classes - both Route and Shipping Log use the same pattern.
const TL_ITEM_BASE =
"group-data-[orientation=vertical]/timeline:ms-5"
const TL_SEP_CLASS =
"bg-input group-data-[orientation=vertical]/timeline:-left-3 group-data-[orientation=vertical]/timeline:-translate-x-1/2 group-data-[orientation=vertical]/timeline:h-[calc(100%-1rem)] group-data-[orientation=vertical]/timeline:translate-y-4.5"
const TL_IND_CLASS =
"bg-input size-2 border-0 group-data-[orientation=vertical]/timeline:-left-3 group-data-[orientation=vertical]/timeline:-translate-x-1/2 group-data-[orientation=vertical]/timeline:top-1.5"
function RouteAndStepper() {
const stops = [SHIPMENT.route.from, SHIPMENT.route.to]
return (
<Frame dense spacing="sm" className="w-full">
<FramePanel className={cn("flex flex-col gap-4", FRAME_PANEL_RESET)}>
{/* Route stops */}
<div className="flex items-start justify-between gap-4">
<Timeline defaultValue={0} className="min-w-0 flex-1 gap-0">
{stops.map((address, index) => {
const isLast = index === stops.length - 1
return (
<TimelineItem
key={address}
step={index + 1}
className={cn(
TL_ITEM_BASE,
isLast
? "group-data-[orientation=vertical]/timeline:pb-0"
: "group-data-[orientation=vertical]/timeline:not-last:pb-2"
)}
>
{!isLast ? (
<TimelineSeparator className={TL_SEP_CLASS} />
) : null}
<TimelineIndicator className={TL_IND_CLASS} />
<TimelineContent className="text-foreground text-sm leading-snug">
{address}
</TimelineContent>
</TimelineItem>
)
})}
</Timeline>
<CarrierChip name={SHIPMENT.carrier.name} />
</div>
{/* Stepper */}
<Stepper steps={SHIPPING_STEPS} />
</FramePanel>
</Frame>
)
}
function Stepper({ steps }: { steps: ShippingStep[] }) {
return (
<div className="flex flex-col gap-2">
<div className="grid grid-cols-4 gap-2">
{steps.map((step, i) => (
<span
key={i}
className={cn(
"h-1.5 rounded-full",
step.state === "pending" ? "bg-muted" : "bg-emerald-500"
)}
aria-hidden="true"
/>
))}
</div>
<div className="grid grid-cols-4 gap-2">
{steps.map((step) => (
<div
key={step.label}
className="flex items-center gap-1.5 text-xs"
>
<StepIcon state={step.state} />
<span
className={cn(
step.state === "pending"
? "text-muted-foreground"
: "text-foreground font-medium"
)}
>
{step.label}
</span>
</div>
))}
</div>
</div>
)
}
function StepIcon({ state }: { state: ShippingStep["state"] }) {
if (state === "done") {
return (
<span
className="inline-flex size-4 shrink-0 items-center justify-center rounded-full bg-emerald-500 text-white"
aria-hidden="true"
>
<CheckIcon className="size-2.5" />
</span>
)
}
if (state === "active") {
return (
<span
className="inline-flex size-4 shrink-0 items-center justify-center rounded-full border-2 border-emerald-500 text-emerald-500"
aria-hidden="true"
>
<CheckIcon className="size-2.5" />
</span>
)
}
return (
<span
className="border-muted-foreground/40 inline-flex size-4 shrink-0 rounded-full border-2"
aria-hidden="true"
/>
)
}
// ─────────────────────────────────────────────────────────────────────────
// Shipping data summary
// ─────────────────────────────────────────────────────────────────────────
function ShippingDataSection() {
const stats = [
{ label: "Total Time", value: SHIPPING_DATA.totalTime },
{ label: "Dep. Time", value: SHIPPING_DATA.depTime },
{ label: "Exp. Arrival", value: SHIPPING_DATA.expArrival },
{
label: "Tracking No.",
value: (
<span className="text-foreground font-mono text-sm">
{SHIPPING_DATA.trackingNo}
</span>
),
},
]
return (
<Frame dense spacing="sm" className="w-full">
<FrameHeader className="flex-row items-center justify-between gap-3">
<FrameTitle className="text-sm">Shipping Data</FrameTitle>
</FrameHeader>
<FramePanel className={FRAME_PANEL_RESET}>
<div className="grid grid-cols-2 gap-x-4 gap-y-3 sm:grid-cols-4">
{stats.map((stat) => (
<div key={stat.label} className="flex min-w-0 flex-col gap-1">
<span className="text-muted-foreground text-xs">
{stat.label}
</span>
<div className="text-foreground min-w-0 text-sm font-medium">
{stat.value}
</div>
</div>
))}
</div>
</FramePanel>
</Frame>
)
}
// ─────────────────────────────────────────────────────────────────────────
// Shipping log timeline
// ─────────────────────────────────────────────────────────────────────────
function ShippingLogSection() {
return (
<Frame dense spacing="sm" className="w-full">
<FrameHeader className="flex-row items-center justify-between gap-3">
<FrameTitle className="text-sm">Shipping Log</FrameTitle>
</FrameHeader>
<FramePanel className={FRAME_PANEL_RESET}>
<Timeline defaultValue={0} className="gap-0">
{SHIPPING_LOG.map((entry, index) => {
const isLast = index === SHIPPING_LOG.length - 1
return (
<TimelineItem
key={`${entry.event}-${entry.datetime}`}
step={index + 1}
className={cn(
TL_ITEM_BASE,
isLast
? "group-data-[orientation=vertical]/timeline:pb-0"
: "group-data-[orientation=vertical]/timeline:not-last:pb-4"
)}
>
{!isLast ? (
<TimelineSeparator className={TL_SEP_CLASS} />
) : null}
<TimelineIndicator className={TL_IND_CLASS} />
<TimelineContent className="text-foreground">
<div className="min-w-0 space-y-0.5">
<div className="flex flex-wrap items-baseline gap-x-2">
<span className="text-foreground text-sm font-medium leading-snug">
{entry.event}
</span>
<time
dateTime={entry.datetime}
className="text-muted-foreground text-xs tabular-nums"
>
{entry.datetime}
</time>
</div>
<p className="text-muted-foreground text-xs leading-snug">
{entry.description}
</p>
{entry.location ? (
<p className="text-muted-foreground inline-flex items-center gap-1 text-xs">
<MapPinIcon className="size-3" aria-hidden="true" />
{entry.location}
</p>
) : null}
</div>
</TimelineContent>
</TimelineItem>
)
})}
</Timeline>
</FramePanel>
</Frame>
)
}
// ─────────────────────────────────────────────────────────────────────────
// Sheet shell
// ─────────────────────────────────────────────────────────────────────────
export function TrackShippingSheet() {
const [open, setOpen] = useState(true)
return (
<Sheet open={open} onOpenChange={setOpen}>
<div className="flex min-h-[360px] items-center justify-center">
<SheetTrigger
render={
<Button type="button" variant="outline" size="lg">
Open Track Shipping
</Button>
}
/>
</div>
<SheetContent
side="right"
showCloseButton={false}
initialFocus={false}
className="inset-y-4 right-4 left-auto flex h-[calc(100svh-2rem)] w-[min(48rem,calc(100vw-2rem))] max-w-none flex-col gap-0 overflow-hidden rounded-xl p-0 outline-none"
>
{/* Header */}
<SheetHeader className="shrink-0 gap-0 border-b p-0">
<div className="flex min-h-11 items-center justify-between gap-2 border-b px-4">
<SheetTitle className="truncate text-sm font-medium leading-5">
Track Shipping
</SheetTitle>
<SheetClose
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Close sheet"
className="shrink-0"
>
<XIcon aria-hidden="true" />
</Button>
}
/>
</div>
<SheetDescription className="sr-only">
Shipment {SHIPMENT.id} live tracking and shipping log.
</SheetDescription>
<div className="flex flex-col gap-2 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 flex-col gap-1">
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-foreground text-xl font-semibold tracking-tight">
{SHIPMENT.id}
</h2>
<Badge variant="success-light">{SHIPMENT.status}</Badge>
</div>
<p className="text-muted-foreground inline-flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs">
<span>Placed</span>
<span className="text-foreground font-medium">
{SHIPMENT.placedDate}
</span>
<span
className="bg-muted-foreground/40 size-1 shrink-0 rounded-full"
aria-hidden="true"
/>
<span>Order ID</span>
<a
href={`#order-${SHIPMENT.orderId.toLowerCase()}`}
className={cn(
"text-foreground font-medium",
ECOMMERCE_LINK_CLASS_NAME
)}
>
{SHIPMENT.orderId}
</a>
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button
nativeButton={false}
variant="ghost"
size="sm"
render={<a href="#cancel-order">Cancel Order</a>}
/>
<Button type="button" variant="outline" size="sm">
Notify Customer
</Button>
</div>
</div>
</SheetHeader>
{/* Body - single column with one ScrollArea (works for both mobile and desktop) */}
<div className="min-h-0 flex-1">
<ScrollArea className="h-full">
<div className="flex flex-col gap-3 px-4 py-4">
<RouteAndStepper />
<ShippingDataSection />
<ShippingLogSection />
</div>
</ScrollArea>
</div>
{/* Footer - full-width Close button */}
<SheetFooter className="bg-muted/40 shrink-0 border-t px-4 py-3">
<SheetClose
render={
<Button type="button" variant="outline" className="w-full">
Close
</Button>
}
/>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
@@ -0,0 +1,9 @@
import { TrackShippingSheet } from "./components/track-shipping-sheet"
export function Page() {
return (
<main className="flex min-h-svh w-full items-center justify-center p-6 sm:p-10 md:p-12">
<TrackShippingSheet />
</main>
)
}
@@ -1,4 +1,3 @@
"use client"
"use no memo"
import { useMemo, useState } from "react"
@@ -1,3 +1,4 @@
"use client"
"use no memo"
import { HTMLAttributes, memo, ReactNode, useMemo } from "react"
@@ -1,4 +1,3 @@
"use client"
"use no memo"
import { ReactElement } from "react"
@@ -1,3 +1,4 @@
"use client"
"use no memo"
import React, { ReactNode } from "react"
@@ -1,4 +1,3 @@
"use client"
"use no memo"
import {
@@ -1,3 +1,4 @@
"use client"
"use no memo"
import {
@@ -1,4 +1,3 @@
"use client"
"use no memo"
import {
@@ -1,3 +1,4 @@
"use client"
"use no memo"
import {
@@ -1,4 +1,3 @@
"use client"
"use no memo"
import {
@@ -1,3 +1,4 @@
"use client"
"use no memo"
import { createContext, ReactNode, useContext, useMemo, useRef } from "react"
@@ -1,5 +1,3 @@
"use client"
import { createContext, useCallback, useContext, useState } from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
+8 -278
View File
@@ -1,285 +1,15 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { useRef, useState } from 'react'
import {
BanIcon,
CheckCircle2Icon,
CircleAlertIcon,
ClockIcon,
Copy,
CopyPlusIcon,
CpuIcon,
MoreHorizontalIcon,
ShieldPlusIcon,
TerminalIcon,
} from 'lucide-react'
import { DetailPanel, PageShell } from '@/components/reui-kit'
import {
Alert,
AlertDescription,
AlertTitle,
} from '@/components/reui/alert'
import { StatusBadge } from '@/components/status-badge'
import {
AgentPlatformIcon,
platformLabel,
} from '@/components/agents/agent-platform-icon'
import { AgentPolicySetsSortable } from '@/components/agents/agent-policy-sets-sortable'
import { AgentPolicyTrace } from '@/components/agents/agent-policy-trace'
import { AgentFactsPanel } from '@/components/agents/agent-facts-panel'
import { AgentEffectiveCidrs } from '@/components/agents/agent-effective-cidrs'
import {
AgentCloneSetsSheet,
AgentOverrideSheet,
} from '@/components/agents/agent-settings-sheets'
import {
agentPreviewQueryOptions,
agentQueryOptions,
} from '@/queries'
import { apiFetch } from '@/lib/api'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { Button } from '@evofw/ui/components/button'
import { Skeleton } from '@evofw/ui/components/skeleton'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@evofw/ui/components/dropdown-menu'
import { createFileRoute, redirect } from '@tanstack/react-router'
/**
* Agent detail — SA3 layout DNA (Header + Trace 2/3 + Facts 1/3).
* Preview: https://reui.io/preview/base/solution-agents-3
* · https://reui.io/preview/base/stats-12
* Docs: https://reui.io/blocks/solutions/agents
* Legacy deep-link → list + Sheet (`?agent=`).
* Full UI: AgentDetailSheet on /agents (SA3 / inventory-9).
*/
export const Route = createFileRoute('/_auth/agents/$id')({
loader: async ({ context: { queryClient }, params }) => {
const agent = await queryClient.ensureQueryData(
agentQueryOptions(params.id),
)
void queryClient.ensureQueryData(agentPreviewQueryOptions(params.id))
return { breadcrumb: agent.name }
beforeLoad: ({ params }) => {
throw redirect({
to: '/agents',
search: { agent: params.id, view: 'cards' },
})
},
component: AgentDetailPage,
})
function AgentDetailPage() {
const { id } = Route.useParams()
const qc = useQueryClient()
const { copyToClipboard } = useCopyToClipboard()
const agentQ = useQuery(agentQueryOptions(id))
const previewQ = useQuery(agentPreviewQueryOptions(id))
const installRef = useRef<HTMLDivElement>(null)
const [overrideOpen, setOverrideOpen] = useState(false)
const [cloneOpen, setCloneOpen] = useState(false)
const revoke = useMutation({
mutationFn: () =>
apiFetch(`/api/v1/agents/${id}/revoke`, { method: 'POST' }),
onSuccess: () => {
toast.success('Агент отозван')
void qc.invalidateQueries({ queryKey: ['agents'] })
},
onError: (e: Error) => toast.error(e.message),
})
const approve = useMutation({
mutationFn: () =>
apiFetch(`/api/v1/agents/${id}/approve`, { method: 'POST' }),
onSuccess: () => {
toast.success('Агент одобрен')
void qc.invalidateQueries({ queryKey: ['agents'] })
},
onError: (e: Error) => toast.error(e.message),
})
const a = agentQ.data
if (agentQ.isLoading || !a) {
return (
<PageShell>
<DetailPanel>
<DetailPanel.Header title="Агент" description="Загрузка…" />
</DetailPanel>
<Skeleton className="h-40 w-full rounded-xl" />
</PageShell>
)
}
const headerDesc = [
a.hostname,
platformLabel(a.platform),
`gen ${a.policy_generation}`,
a.default_action === 'drop' ? 'default Drop' : 'default Accept',
]
.filter(Boolean)
.join(' · ')
return (
<PageShell>
<DetailPanel>
<DetailPanel.Header
title={a.name}
description={headerDesc}
actions={
<>
<AgentPlatformIcon platform={a.platform} />
<StatusBadge status={a.status} />
{a.status === 'pending' ? (
<Button
size="sm"
onClick={() => approve.mutate()}
disabled={approve.isPending}
>
Approve
</Button>
) : null}
{a.status === 'approved' ? (
<Button
variant="outline"
size="sm"
onClick={() => revoke.mutate()}
disabled={revoke.isPending}
>
Revoke
</Button>
) : null}
{a.install_curl ? (
<Button
variant="outline"
size="sm"
onClick={() => {
copyToClipboard(a.install_curl!)
toast.success('Скопировано')
}}
>
<Copy data-icon="inline-start" />
Install
</Button>
) : null}
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="outline" size="icon-sm" aria-label="Ещё" />
}
>
<MoreHorizontalIcon className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setOverrideOpen(true)}>
<ShieldPlusIcon className="size-4" />
IP override
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setCloneOpen(true)}>
<CopyPlusIcon className="size-4" />
Копировать наборы
</DropdownMenuItem>
{a.install_curl ? (
<DropdownMenuItem
onClick={() => {
copyToClipboard(a.install_curl!)
toast.success('Скопировано')
installRef.current?.scrollIntoView({
behavior: 'smooth',
})
}}
>
<TerminalIcon className="size-4" />
Install curl
</DropdownMenuItem>
) : null}
<DropdownMenuItem
render={<Link to="/agents" />}
>
К списку
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
}
/>
{a.last_apply_error ? (
<Alert variant="destructive">
<CircleAlertIcon />
<AlertTitle>Ошибка apply</AlertTitle>
<AlertDescription>{a.last_apply_error}</AlertDescription>
</Alert>
) : null}
<DetailPanel.Metrics
cards={[
{
id: 'dropped',
icon: <BanIcon aria-hidden />,
iconClassName: 'text-warning',
label: 'Dropped',
description: String(a.last_apply_packets_dropped ?? 0),
hint: 'сумма counters',
variant: 'warning',
},
{
id: 'accepted',
icon: <CheckCircle2Icon aria-hidden />,
iconClassName: 'text-success',
label: 'Accepted',
description: String(a.last_apply_packets_accepted ?? 0),
hint: 'сумма counters',
},
{
id: 'kernel',
icon: <CpuIcon aria-hidden />,
iconClassName: 'text-info',
label: 'Kernel',
description: a.last_apply_kernel_method ?? '—',
},
{
id: 'apply',
icon: <ClockIcon aria-hidden />,
iconClassName: 'text-primary',
label: 'Last apply',
description: a.last_apply_at ?? '—',
},
]}
/>
<DetailPanel.Section>
<div className="@container flex flex-col gap-4">
<div className="grid gap-4 @4xl:grid-cols-3">
<div className="@4xl:col-span-2">
<AgentPolicyTrace
preview={previewQ.data}
isLoading={previewQ.isLoading}
/>
</div>
<AgentFactsPanel agent={a} />
</div>
<div ref={installRef}>
<AgentPolicySetsSortable agentId={id} />
</div>
<AgentEffectiveCidrs
preview={previewQ.data}
isLoading={previewQ.isLoading}
/>
</div>
</DetailPanel.Section>
</DetailPanel>
<AgentOverrideSheet
agentId={id}
open={overrideOpen}
onOpenChange={setOverrideOpen}
/>
<AgentCloneSetsSheet
agentId={id}
open={cloneOpen}
onOpenChange={setCloneOpen}
/>
</PageShell>
)
}
+162 -26
View File
@@ -1,4 +1,4 @@
import { createFileRoute } from '@tanstack/react-router'
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import {
@@ -7,10 +7,12 @@ import {
CircleAlertIcon,
FilterIcon,
Inbox,
LayoutGridIcon,
ListIcon,
Plus,
SearchIcon,
ShieldIcon,
TableIcon,
UserPlus,
WifiOff,
} from 'lucide-react'
@@ -40,12 +42,14 @@ import { CountedLineTabs } from '@/components/counted-line-tabs'
import { AddAgentSheet } from '@/components/agents/add-agent-sheet'
import { AgentCardsGrid } from '@/components/agents/agent-cards-grid'
import { AgentDetailSheet } from '@/components/agents/agent-detail-sheet'
import { AgentFleetDataGrid } from '@/components/agents/agent-fleet-data-grid'
import {
computeFleetCounts,
fleetKpiCards,
} from '@/components/agents/agents-fleet-kpis'
import { agentsQueryOptions } from '@/queries'
import { apiFetch } from '@/lib/api'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { Button } from '@evofw/ui/components/button'
import {
InputGroup,
@@ -53,14 +57,36 @@ import {
InputGroupInput,
} from '@evofw/ui/components/input-group'
import { Separator } from '@evofw/ui/components/separator'
import {
ToggleGroup,
ToggleGroupItem,
} from '@evofw/ui/components/toggle-group'
import type { Agent } from '@evofw/shared'
/**
* Agents ops console — card catalog + detail Sheet.
* Preview: https://reui.io/preview/base/solution-agents-1 · card-3 · stats-12 · c-sheet-1
* Agents ops console — cards/table toggle + full detail Sheet.
* Preview: https://reui.io/preview/base/solution-agents-1 · card-3 · stats-12
* · https://reui.io/preview/base/data-grid-filtering-2 · solution-agents-2
* · https://reui.io/preview/base/solution-inventory-9 · solution-agents-3
*/
type AgentsSearch = {
agent?: string
view: 'cards' | 'table'
}
function parseAgentsSearch(search: Record<string, unknown>): AgentsSearch {
const view = search.view === 'table' ? 'table' : 'cards'
const agent =
typeof search.agent === 'string' && search.agent.length > 0
? search.agent
: undefined
return { view, agent }
}
export const Route = createFileRoute('/_auth/agents/')({
validateSearch: (search: Record<string, unknown>): AgentsSearch =>
parseAgentsSearch(search),
component: AgentsPage,
})
@@ -73,14 +99,46 @@ const AGENT_TABS = [
] as const
function AgentsPage() {
const navigate = useNavigate({ from: Route.fullPath })
const { agent: detailAgentId, view } = Route.useSearch()
const qc = useQueryClient()
const agentsQ = useQuery(agentsQueryOptions())
const { copyToClipboard } = useCopyToClipboard()
const [createOpen, setCreateOpen] = useState(false)
const [filters, setFilters] = useState<Filter[]>([])
const [searchQuery, setSearchQuery] = useState('')
const [activeTab, setActiveTab] = useState('all')
const [detailAgentId, setDetailAgentId] = useState<string | null>(null)
const [detailOpen, setDetailOpen] = useState(false)
const detailOpen = Boolean(detailAgentId)
const setSearch = useCallback(
(next: Partial<AgentsSearch>) => {
void navigate({
search: (prev) => {
const base = parseAgentsSearch(prev as Record<string, unknown>)
return {
view: next.view ?? base.view,
agent:
next.agent !== undefined
? next.agent || undefined
: base.agent,
} satisfies AgentsSearch
},
replace: true,
})
},
[navigate],
)
const approve = useMutation({
mutationFn: (id: string) =>
apiFetch(`/api/v1/agents/${id}/approve`, { method: 'POST' }),
onSuccess: () => {
toast.success('Агент одобрен')
void qc.invalidateQueries({ queryKey: ['agents'] })
},
onError: (e: Error) => toast.error(e.message),
})
const approveAllPending = useMutation({
mutationFn: async (ids: string[]) => {
@@ -177,6 +235,14 @@ function AgentsPage() {
[searchQuery],
)
const getSearchText = useCallback(
(item: Agent) =>
[item.name, item.hostname ?? '', item.last_seen_ip ?? '']
.filter(Boolean)
.join(' '),
[],
)
const tabFilter = useCallback((item: Agent, tabId: string) => {
if (tabId === 'all') return true
return item.status === tabId
@@ -257,16 +323,50 @@ function AgentsPage() {
[counts.pending],
)
const handleSelectAgent = useCallback((id: string) => {
setDetailAgentId(id)
setDetailOpen(true)
}, [])
const handleSelectAgent = useCallback(
(id: string) => {
setSearch({ agent: id })
},
[setSearch],
)
const handleClearFilters = useCallback(() => {
setFilters([])
setSearchQuery('')
}, [])
const handleCopyInstall = useCallback(
(curl: string) => {
copyToClipboard(curl)
toast.success('Скопировано')
},
[copyToClipboard],
)
const viewToggle = (
<ToggleGroup
multiple={false}
value={[view]}
onValueChange={(next) => {
const v = next[0]
if (v === 'cards' || v === 'table') {
setSearch({ view: v })
}
}}
variant="outline"
size="sm"
spacing={0}
aria-label="Вид списка"
>
<ToggleGroupItem value="cards" aria-label="Карточки">
<LayoutGridIcon className="size-4" />
</ToggleGroupItem>
<ToggleGroupItem value="table" aria-label="Таблица">
<TableIcon className="size-4" />
</ToggleGroupItem>
</ToggleGroup>
)
const addButton = (
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus data-icon="inline-start" />
@@ -329,12 +429,40 @@ function AgentsPage() {
</Alert>
) : null}
{agentsQ.isError ? (
{view === 'table' ? (
<AgentFleetDataGrid
data={items}
filterFields={filterFields}
filters={filters}
onFiltersChange={setFilters}
onClearFilters={handleClearFilters}
getFilterFieldValue={getFilterFieldValue}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
getSearchText={getSearchText}
tabs={[...AGENT_TABS]}
activeTab={activeTab}
onTabChange={setActiveTab}
tabFilter={tabFilter}
isLoading={agentsQ.isLoading}
isError={agentsQ.isError}
error={agentsQ.error}
onRetry={() => void agentsQ.refetch()}
emptyAction={addButton}
toolbarExtra={viewToggle}
onSelect={handleSelectAgent}
onApprove={(id) => approve.mutate(id)}
approvePending={approve.isPending}
onCopyInstall={handleCopyInstall}
/>
) : agentsQ.isError ? (
<Alert variant="destructive">
<CircleAlertIcon />
<AlertTitle>Ошибка загрузки</AlertTitle>
<AlertDescription className="flex flex-col gap-2">
<span>{agentsQ.error?.message ?? 'Не удалось загрузить данные'}</span>
<span>
{agentsQ.error?.message ?? 'Не удалось загрузить данные'}
</span>
<Button
type="button"
variant="outline"
@@ -364,7 +492,11 @@ function AgentsPage() {
onChange={setFilters}
size="default"
trigger={
<Button type="button" variant="outline" aria-label="Фильтры">
<Button
type="button"
variant="outline"
aria-label="Фильтры"
>
<FilterIcon className="size-4" aria-hidden="true" />
Фильтры
</Button>
@@ -382,16 +514,19 @@ function AgentsPage() {
/>
</InputGroup>
</div>
{filters.length > 0 || searchQuery ? (
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleClearFilters}
>
Сбросить
</Button>
) : null}
<div className="flex flex-wrap items-center gap-2">
{viewToggle}
{filters.length > 0 || searchQuery ? (
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleClearFilters}
>
Сбросить
</Button>
) : null}
</div>
</div>
<div className="px-(--frame-panel-header-px) pb-(--frame-panel-header-py)">
<AgentCardsGrid
@@ -400,7 +535,9 @@ function AgentsPage() {
onSelect={handleSelectAgent}
isLoading={agentsQ.isLoading}
emptyTitle={
items.length === 0 ? 'Нет агентов' : 'Нет записей по фильтрам'
items.length === 0
? 'Нет агентов'
: 'Нет записей по фильтрам'
}
emptyDescription={
items.length === 0
@@ -417,11 +554,10 @@ function AgentsPage() {
<AddAgentSheet open={createOpen} onOpenChange={setCreateOpen} />
<AgentDetailSheet
agentId={detailAgentId}
agentId={detailAgentId ?? null}
open={detailOpen}
onOpenChange={(open) => {
setDetailOpen(open)
if (!open) setDetailAgentId(null)
if (!open) setSearch({ agent: '' })
}}
/>
</PageShell>
+4 -4
View File
@@ -161,8 +161,8 @@ function DashboardPage() {
<ItemContent className="flex flex-row items-center justify-between gap-2">
<ItemTitle className="truncate font-medium">
<Link
to="/agents/$id"
params={{ id: a.id }}
to="/agents"
search={{ agent: a.id, view: 'cards' }}
className="hover:underline"
>
{a.name}
@@ -236,8 +236,8 @@ function DashboardPage() {
Pending: <strong>{a.name}</strong>
</span>
<Link
to="/agents/$id"
params={{ id: a.id }}
to="/agents"
search={{ agent: a.id, view: 'cards' }}
className="underline-offset-4 hover:underline"
>
Открыть
@@ -1,5 +1,3 @@
"use client"
import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible"
function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) {
+297
View File
@@ -0,0 +1,297 @@
"use client"
import * as React from "react"
import { Combobox as ComboboxPrimitive } from "@base-ui/react"
import { cn } from "@evofw/ui/lib/utils"
import { Button } from "@evofw/ui/components/button"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@evofw/ui/components/input-group"
import { ChevronDownIcon, XIcon, CheckIcon } from "lucide-react"
const Combobox = ComboboxPrimitive.Root
function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />
}
function ComboboxTrigger({
className,
children,
...props
}: ComboboxPrimitive.Trigger.Props) {
return (
<ComboboxPrimitive.Trigger
data-slot="combobox-trigger"
className={cn("[&_svg:not([class*='size-'])]:size-4", className)}
{...props}
>
{children}
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
</ComboboxPrimitive.Trigger>
)
}
function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
return (
<ComboboxPrimitive.Clear
data-slot="combobox-clear"
render={<InputGroupButton variant="ghost" size="icon-xs" />}
className={cn(className)}
{...props}
>
<XIcon className="pointer-events-none" />
</ComboboxPrimitive.Clear>
)
}
function ComboboxInput({
className,
children,
disabled = false,
showTrigger = true,
showClear = false,
...props
}: ComboboxPrimitive.Input.Props & {
showTrigger?: boolean
showClear?: boolean
}) {
return (
<InputGroup className={cn("w-auto", className)}>
<ComboboxPrimitive.Input
render={<InputGroupInput disabled={disabled} />}
{...props}
/>
<InputGroupAddon align="inline-end">
{showTrigger && (
<InputGroupButton
size="icon-xs"
variant="ghost"
render={<ComboboxTrigger />}
data-slot="input-group-button"
className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent"
disabled={disabled}
/>
)}
{showClear && <ComboboxClear disabled={disabled} />}
</InputGroupAddon>
{children}
</InputGroup>
)
}
function ComboboxContent({
className,
side = "bottom",
sideOffset = 6,
align = "start",
alignOffset = 0,
anchor,
...props
}: ComboboxPrimitive.Popup.Props &
Pick<
ComboboxPrimitive.Positioner.Props,
"side" | "align" | "sideOffset" | "alignOffset" | "anchor"
>) {
return (
<ComboboxPrimitive.Portal>
<ComboboxPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
anchor={anchor}
className="isolate z-50"
>
<ComboboxPrimitive.Popup
data-slot="combobox-content"
data-chips={!!anchor}
className={cn("group/combobox-content relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[chips=true]:min-w-(--anchor-width) data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:border-input/30 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:shadow-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</ComboboxPrimitive.Positioner>
</ComboboxPrimitive.Portal>
)
}
function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
return (
<ComboboxPrimitive.List
data-slot="combobox-list"
className={cn(
"no-scrollbar max-h-[min(calc(--spacing(72)---spacing(9)),calc(var(--available-height)---spacing(9)))] scroll-py-1 overflow-y-auto overscroll-contain p-1 data-empty:p-0",
className
)}
{...props}
/>
)
}
function ComboboxItem({
className,
children,
...props
}: ComboboxPrimitive.Item.Props) {
return (
<ComboboxPrimitive.Item
data-slot="combobox-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground not-data-[variant=destructive]:data-highlighted:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ComboboxPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</ComboboxPrimitive.ItemIndicator>
</ComboboxPrimitive.Item>
)
}
function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
return (
<ComboboxPrimitive.Group
data-slot="combobox-group"
className={cn(className)}
{...props}
/>
)
}
function ComboboxLabel({
className,
...props
}: ComboboxPrimitive.GroupLabel.Props) {
return (
<ComboboxPrimitive.GroupLabel
data-slot="combobox-label"
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) {
return (
<ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />
)
}
function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
return (
<ComboboxPrimitive.Empty
data-slot="combobox-empty"
className={cn(
"hidden w-full justify-center py-2 text-center text-sm text-muted-foreground group-data-empty/combobox-content:flex",
className
)}
{...props}
/>
)
}
function ComboboxSeparator({
className,
...props
}: ComboboxPrimitive.Separator.Props) {
return (
<ComboboxPrimitive.Separator
data-slot="combobox-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function ComboboxChips({
className,
...props
}: React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips> &
ComboboxPrimitive.Chips.Props) {
return (
<ComboboxPrimitive.Chips
data-slot="combobox-chips"
className={cn(
"flex min-h-8 flex-wrap items-center gap-1 rounded-lg border border-input bg-transparent bg-clip-padding px-2.5 py-1 text-sm transition-colors focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 has-aria-invalid:border-destructive has-aria-invalid:ring-3 has-aria-invalid:ring-destructive/20 has-data-[slot=combobox-chip]:px-1 dark:bg-input/30 dark:has-aria-invalid:border-destructive/50 dark:has-aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
function ComboboxChip({
className,
children,
showRemove = true,
...props
}: ComboboxPrimitive.Chip.Props & {
showRemove?: boolean
}) {
return (
<ComboboxPrimitive.Chip
data-slot="combobox-chip"
className={cn(
"flex h-[calc(--spacing(5.25))] w-fit items-center justify-center gap-1 rounded-sm bg-muted px-1.5 text-xs font-medium whitespace-nowrap text-foreground has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50 has-data-[slot=combobox-chip-remove]:pr-0",
className
)}
{...props}
>
{children}
{showRemove && (
<ComboboxPrimitive.ChipRemove
render={<Button variant="ghost" size="icon-xs" />}
className="-ml-1 opacity-50 hover:opacity-100"
data-slot="combobox-chip-remove"
>
<XIcon className="pointer-events-none" />
</ComboboxPrimitive.ChipRemove>
)}
</ComboboxPrimitive.Chip>
)
}
function ComboboxChipsInput({
className,
...props
}: ComboboxPrimitive.Input.Props) {
return (
<ComboboxPrimitive.Input
data-slot="combobox-chip-input"
className={cn("min-w-16 flex-1 outline-none", className)}
{...props}
/>
)
}
function useComboboxAnchor() {
return React.useRef<HTMLDivElement | null>(null)
}
export {
Combobox,
ComboboxInput,
ComboboxContent,
ComboboxList,
ComboboxItem,
ComboboxGroup,
ComboboxLabel,
ComboboxCollection,
ComboboxEmpty,
ComboboxSeparator,
ComboboxChips,
ComboboxChip,
ComboboxChipsInput,
ComboboxTrigger,
ComboboxValue,
useComboboxAnchor,
}
-2
View File
@@ -1,5 +1,3 @@
"use client"
import { useMemo } from "react"
import { cva, type VariantProps } from "class-variance-authority"
+2
View File
@@ -1,3 +1,5 @@
"use client"
import { Progress as ProgressPrimitive } from "@base-ui/react/progress"
import { cn } from "@evofw/ui/lib/utils"
@@ -1,3 +1,5 @@
"use client"
import * as React from "react"
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
+3 -1
View File
@@ -1,3 +1,5 @@
"use client"
import * as React from "react"
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
@@ -92,7 +94,7 @@ function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-row flex-wrap gap-2 p-4", className)}
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
+2
View File
@@ -1,3 +1,5 @@
"use client"
import { Switch as SwitchPrimitive } from "@base-ui/react/switch"
import { cn } from "@evofw/ui/lib/utils"