- {agent ? (
-
- ) : null}
-
-
- {agent?.name ?? 'Агент'}
-
- {subtitle}
-
+
+
+
+ {title}
+
+
+
+
+ }
+ />
+
+ Политика, apply counters и identity агента
+
- {!agentId || agentsQ.isLoading ? (
-
-
-
-
- ) : !agent ? (
-
- Агент удалён или недоступен.
-
- ) : (
-
-
-
-
- {agent.default_action === 'drop' ? 'Drop' : 'Accept'}
-
-
-
- {agent.last_apply_error ? (
-
-
- Ошибка apply
- {agent.last_apply_error}
-
+
+
+ {agentId && open ? (
+
) : null}
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
|
- {previewQ.isLoading ? (
-
-
-
- ) : previewQ.data ? (
- <>
-
|
-
|
-
|
-
|
-
|
- >
- ) : (
-
|
- )}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {agent.status === 'pending' ? (
- approve.mutate()}
- >
-
- Approve
-
- ) : null}
- {agent.status === 'approved' ? (
- revoke.mutate()}
- >
-
- Revoke
-
- ) : null}
- {agent.install_curl ? (
- {
- copyToClipboard(agent.install_curl!)
- toast.success('Скопировано')
- }}
- >
-
- Install
-
- ) : null}
-
-
- }
- >
- Открыть карточку агента
+
+
+ Закрыть
-
-
- )}
+ }
+ />
+
)
diff --git a/apps/web/src/components/agents/agent-detail-view.tsx b/apps/web/src/components/agents/agent-detail-view.tsx
new file mode 100644
index 0000000..5b6e6c7
--- /dev/null
+++ b/apps/web/src/components/agents/agent-detail-view.tsx
@@ -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
(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 (
+
+
+
+
+
+ )
+ }
+
+ if (agentQ.isError) {
+ return (
+
+
+
+ Ошибка загрузки
+
+ {agentQ.error?.message ?? 'Не удалось загрузить агента'}
+
+
+
+ )
+ }
+
+ const headerDesc = [
+ a.hostname,
+ platformLabel(a.platform),
+ `gen ${a.policy_generation}`,
+ a.default_action === 'drop' ? 'default Drop' : 'default Accept',
+ ]
+ .filter(Boolean)
+ .join(' · ')
+
+ return (
+ <>
+
+
+
+
+
+
+ {a.default_action === 'drop' ? 'Drop' : 'Accept'}
+
+ {a.status === 'pending' ? (
+ approve.mutate()}
+ disabled={approve.isPending}
+ >
+ Approve
+
+ ) : null}
+ {a.status === 'approved' ? (
+ revoke.mutate()}
+ disabled={revoke.isPending}
+ >
+ Revoke
+
+ ) : null}
+ {a.install_curl ? (
+ {
+ copyToClipboard(a.install_curl!)
+ toast.success('Скопировано')
+ }}
+ >
+
+ Install
+
+ ) : null}
+
+
+ }
+ >
+
+
+
+ setOverrideOpen(true)}>
+
+ IP override
+
+ setCloneOpen(true)}>
+
+ Копировать наборы
+
+ {a.install_curl ? (
+ {
+ copyToClipboard(a.install_curl!)
+ toast.success('Скопировано')
+ installRef.current?.scrollIntoView({
+ behavior: 'smooth',
+ })
+ }}
+ >
+
+ Install curl
+
+ ) : null}
+
+
+ >
+ }
+ />
+
+ {a.last_apply_error ? (
+
+
+ Ошибка apply
+ {a.last_apply_error}
+
+ ) : null}
+
+ ,
+ iconClassName: 'text-warning',
+ label: 'Dropped',
+ description: String(a.last_apply_packets_dropped ?? 0),
+ hint: 'сумма counters',
+ variant: 'warning',
+ },
+ {
+ id: 'accepted',
+ icon: ,
+ iconClassName: 'text-success',
+ label: 'Accepted',
+ description: String(a.last_apply_packets_accepted ?? 0),
+ hint: 'сумма counters',
+ },
+ {
+ id: 'kernel',
+ icon: ,
+ iconClassName: 'text-info',
+ label: 'Kernel',
+ description: a.last_apply_kernel_method ?? '—',
+ },
+ {
+ id: 'apply',
+ icon: ,
+ iconClassName: 'text-primary',
+ label: 'Last apply',
+ description: a.last_apply_at ?? '—',
+ },
+ ]}
+ />
+
+
+
+
+
+
+
+
+
+ >
+ )
+}
diff --git a/apps/web/src/components/agents/agent-fleet-data-grid.tsx b/apps/web/src/components/agents/agent-fleet-data-grid.tsx
new file mode 100644
index 0000000..f53676d
--- /dev/null
+++ b/apps/web/src/components/agents/agent-fleet-data-grid.tsx
@@ -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[] = useMemo(
+ () => [
+ {
+ accessorKey: 'name',
+ size: 260,
+ minSize: 180,
+ maxSize: 420,
+ header: ({ column }) => (
+
+ ),
+ cell: ({ row }) => {
+ const a = row.original
+ return (
+
+ )
+ },
+ },
+ {
+ accessorKey: 'status',
+ size: 110,
+ minSize: 100,
+ maxSize: 130,
+ header: ({ column }) => (
+
+ ),
+ cell: ({ row }) => ,
+ },
+ {
+ id: 'default_action',
+ size: 100,
+ minSize: 90,
+ maxSize: 120,
+ accessorFn: (row) => row.default_action,
+ header: ({ column }) => (
+
+ ),
+ cell: ({ row }) => {
+ const drop = row.original.default_action === 'drop'
+ return (
+
+ {drop ? 'Drop' : 'Accept'}
+
+ )
+ },
+ },
+ {
+ id: 'apply',
+ size: 100,
+ minSize: 90,
+ maxSize: 120,
+ accessorFn: (row) => row.last_apply_status ?? '',
+ header: ({ column }) => (
+
+ ),
+ cell: ({ row }) => {
+ const a = row.original
+ if (a.last_apply_error) {
+ return (
+
+
+ }
+ >
+
+ error
+
+
+
+ {a.last_apply_error}
+
+
+ )
+ }
+ if (!a.last_apply_status && !a.last_apply_at) {
+ return —
+ }
+ return (
+
+ {a.last_apply_status ?? 'ok'}
+
+ )
+ },
+ },
+ {
+ id: 'dropped',
+ size: 90,
+ minSize: 80,
+ maxSize: 110,
+ accessorFn: (row) => row.last_apply_packets_dropped ?? -1,
+ header: ({ column }) => (
+
+ ),
+ 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 —
+ }
+ return (
+
+ {text}
+
+ )
+ },
+ },
+ {
+ id: 'accepted',
+ size: 90,
+ minSize: 80,
+ maxSize: 110,
+ accessorFn: (row) => row.last_apply_packets_accepted ?? -1,
+ header: ({ column }) => (
+
+ ),
+ 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 —
+ }
+ return (
+
+ {text}
+
+ )
+ },
+ },
+ {
+ accessorKey: 'last_seen_at',
+ size: 140,
+ minSize: 120,
+ maxSize: 180,
+ header: ({ column }) => (
+
+ ),
+ cell: ({ row }) => {
+ const a = row.original
+ const short = formatAgentSeen(a.last_seen_at)
+ if (!a.last_seen_at || short === '—') {
+ return —
+ }
+ return (
+
+ )
+ },
+ },
+ {
+ id: 'gen',
+ size: 70,
+ minSize: 60,
+ maxSize: 90,
+ accessorFn: (row) => row.policy_generation,
+ header: ({ column }) => (
+
+ ),
+ cell: ({ row }) => (
+
+ {row.original.policy_generation}
+
+ ),
+ },
+ {
+ id: 'actions',
+ size: 120,
+ minSize: 120,
+ maxSize: 120,
+ enableSorting: false,
+ enableResizing: false,
+ header: () => Действия ,
+ cell: ({ row }) => {
+ const a = row.original
+ return (
+
+ {a.status === 'pending' ? (
+
{
+ e.stopPropagation()
+ onApprove(a.id)
+ }}
+ >
+
+
+ ) : null}
+ {a.install_curl ? (
+
handleCopy(a.install_curl!, e)}
+ >
+
+
+ ) : null}
+
{
+ e.stopPropagation()
+ onSelect(a.id)
+ }}
+ >
+
+
+
+ )
+ },
+ },
+ ],
+ [approvePending, handleCopy, onApprove, onSelect],
+ )
+
+ return (
+ 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,
+ }}
+ />
+ )
+}
diff --git a/apps/web/src/components/blocks/solution-agents-2/components/data.tsx b/apps/web/src/components/blocks/solution-agents-2/components/data.tsx
new file mode 100644
index 0000000..30f2fe4
--- /dev/null
+++ b/apps/web/src/components/blocks/solution-agents-2/components/data.tsx
@@ -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 = (
+
+)
+
+export const TOAST_INFO_ICON = (
+
+)
+
+export const AGENT_DETAILS: Record<
+ AgentName,
+ { label: AgentName; icon: ReactNode }
+> = {
+ "Refund Resolver": {
+ label: "Refund Resolver",
+ icon: (
+
+ ),
+ },
+ "Outreach Composer": {
+ label: "Outreach Composer",
+ icon: (
+
+ ),
+ },
+ "Data Steward": {
+ label: "Data Steward",
+ icon: (
+
+ ),
+ },
+ "Invoice Reconciler": {
+ label: "Invoice Reconciler",
+ icon: (
+
+ ),
+ },
+ "Lead Enricher": {
+ label: "Lead Enricher",
+ icon: (
+
+ ),
+ },
+ "Contract Summarizer": {
+ label: "Contract Summarizer",
+ icon: (
+
+ ),
+ },
+}
+
+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",
+ },
+]
\ No newline at end of file
diff --git a/apps/web/src/components/blocks/solution-agents-2/components/run-queue-columns.tsx b/apps/web/src/components/blocks/solution-agents-2/components/run-queue-columns.tsx
new file mode 100644
index 0000000..0952eec
--- /dev/null
+++ b/apps/web/src/components/blocks/solution-agents-2/components/run-queue-columns.tsx
@@ -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["variant"]
+> = {
+ Escalated: "destructive-light",
+ "Needs approval": "warning-light",
+ Retrying: "info-light",
+ "On track": "success-light",
+ Done: "secondary",
+}
+
+const environmentDotClass: Record = {
+ Production: "bg-primary",
+ Staging: "bg-warning",
+ Development: "bg-info",
+}
+
+const agentTileClass = "bg-background border-border text-foreground"
+
+const statusDotClass: Record = {
+ 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((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 (
+
+ {owner?.avatarSrc ? (
+
+ ) : null}
+
+ {owner?.initials ?? "--"}
+
+
+ )
+})
+
+function EnvironmentBadge({ environment }: { environment: RunEnvironment }) {
+ return (
+
+
+ {environment}
+
+ )
+}
+
+function AttentionBadge({ attention }: { attention: RunAttention }) {
+ return {attention}
+}
+
+function StatusMark({ statusId }: { statusId: RunStatusId }) {
+ return (
+
+ )
+}
+
+function GroupExpandButton({
+ label,
+ expanded,
+ onToggle,
+}: {
+ label: string
+ expanded: boolean
+ onToggle: () => void
+}) {
+ return (
+ {
+ event.preventDefault()
+ event.stopPropagation()
+ onToggle()
+ }}
+ >
+
+
+ )
+}
+
+function StatusGroupCell({
+ row,
+ expanded,
+ onToggle,
+}: {
+ row: RunGroupRow
+ expanded: boolean
+ onToggle: () => void
+}) {
+ return (
+
+
+
+
+
+ {row.group.label}
+
+
+ {row.subRows?.length ?? 0}
+
+
+
+ )
+}
+
+function AgentIcon({ run }: { run: AgentRun }) {
+ return (
+ }
+ className={cn(
+ "p-0",
+ "flex size-7 shrink-0 items-center justify-center border [&_svg]:opacity-95",
+ agentTileClass
+ )}
+ >
+
+ {AGENT_DETAILS[run.agent].icon}
+
+
+ )
+}
+
+function RunTitleAffordance({ title }: { title: string }) {
+ return (
+
+
+ {title}
+
+
+
+ )
+}
+
+function RunTitleCell({
+ run,
+ showContext,
+}: {
+ run: AgentRun
+ showContext: boolean
+}) {
+ return (
+
+
+
+
+
+
+ {showContext ? (
+
+ {run.context}
+
+ ) : null}
+
+
+ )
+}
+
+function RunCell({
+ row,
+ expanded,
+ onToggle,
+ showContext,
+}: {
+ row: RunTableRow
+ expanded: boolean
+ onToggle: () => void
+ showContext: boolean
+}) {
+ if (isRunRow(row)) {
+ return
+ }
+
+ return
+}
+
+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 (
+
+
+ Unassigned owner
+
+ )
+ }
+
+ return (
+
+
+ {visibleOwners.map((owner) => (
+
+ ))}
+ {overflowCount > 0 ? (
+
+ +{overflowCount}
+
+ ) : null}
+
+
+ {owners.map((owner) => owner.name).join(",")}
+
+
+ )
+}
+
+function StartedCell({ run }: { run: AgentRun }) {
+ return (
+
+
+ {run.startedLabel}
+
+ )
+}
+
+function StepProgressCell({ run }: { run: AgentRun }) {
+ if (run.stepProgress === null) {
+ return (
+
+ -
+
+ )
+ }
+
+ const rate = run.stepProgress
+ const radius = 9
+ const circumference = 2 * Math.PI * radius
+ const dashOffset = circumference - (rate / 100) * circumference
+
+ return (
+
+
+
+
+
+ {rate}%
+
+ )
+}
+
+function GroupSummaryCell({ row }: { row: RunGroupRow }) {
+ return (
+
+
+ {row.group.summary}
+
+
+ )
+}
+
+function RunActionsCell({
+ run,
+ onAction,
+}: {
+ run: AgentRun
+ onAction: (action: RunAction, run: AgentRun) => void
+}) {
+ return (
+
+
+ }
+ >
+
+
+ {/* Content */}
+
+
+ onAction("open", run)}>
+
+ Open run
+
+ onAction("copy", run)}>
+
+ Copy run id
+
+
+ onAction("retry", run)}>
+
+ Retry run
+
+ onAction("pause", run)}>
+
+ Pause run
+
+
+
+
+ )
+}
+
+export function createRunColumns({
+ showContext,
+ onAction,
+}: {
+ showContext: boolean
+ onAction: (action: RunAction, run: AgentRun) => void
+}): ColumnDef[] {
+ return [
+ {
+ accessorFn: (row) => (isRunRow(row) ? row.run.title : row.group.label),
+ id: "run",
+ header: ({ column }) => (
+
+ ),
+ cell: ({ row }) => (
+
+ ),
+ 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 }) => (
+
+ ),
+ cell: ({ row }) =>
+ isRunRow(row.original) ? (
+
+ ) : (
+ {row.original.group.focus}
+ ),
+ size: 120,
+ enableSorting: false,
+ meta: {
+ headerTitle: "Owner",
+ },
+ },
+ {
+ accessorFn: (row) =>
+ isRunRow(row) ? row.run.environment : row.group.focus,
+ id: "environment",
+ header: ({ column }) => (
+
+ ),
+ cell: ({ row }) =>
+ isRunRow(row.original) ? (
+
+
+
+ ) : (
+ {row.original.group.focus}
+ ),
+ size: 96,
+ enableSorting: false,
+ meta: {
+ headerTitle: "Env",
+ },
+ },
+ {
+ accessorFn: (row) =>
+ isRunRow(row)
+ ? row.run.startedAt
+ : (getLatestRun(getGroupRuns(row))?.startedAt ?? ""),
+ id: "started",
+ header: ({ column }) => (
+
+ ),
+ cell: ({ row }) => {
+ if (isRunRow(row.original)) {
+ return (
+
+
+
+ )
+ }
+
+ return (
+
+ Latest {getLatestRun(getGroupRuns(row.original))?.startedLabel}
+
+ )
+ },
+ 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 }) => (
+
+ ),
+ cell: ({ row }) => {
+ if (isRunRow(row.original)) {
+ return
+ }
+
+ return null
+ },
+ size: 84,
+ enableSorting: false,
+ meta: {
+ headerTitle: "Steps",
+ },
+ },
+ {
+ accessorFn: (row) =>
+ isRunRow(row)
+ ? row.run.attention
+ : getGroupAttention(getGroupRuns(row)),
+ id: "attention",
+ header: ({ column }) => (
+
+ ),
+ cell: ({ row }) =>
+ isRunRow(row.original) ? (
+
+ ) : (
+
+ {getGroupAttention(getGroupRuns(row.original))}
+
+ ),
+ size: 80,
+ enableSorting: false,
+ meta: {
+ headerTitle: "Attention",
+ },
+ },
+ {
+ id: "actions",
+ header: "",
+ cell: ({ row }) =>
+ isRunRow(row.original) ? (
+
+
+
+ ) : (
+
+ ),
+ size: 60,
+ enableHiding: false,
+ enableSorting: false,
+ },
+ ]
+}
\ No newline at end of file
diff --git a/apps/web/src/components/blocks/solution-agents-2/components/run-queue.tsx b/apps/web/src/components/blocks/solution-agents-2/components/run-queue.tsx
new file mode 100644
index 0000000..88a7d9c
--- /dev/null
+++ b/apps/web/src/components/blocks/solution-agents-2/components/run-queue.tsx
@@ -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>((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["variant"]
+}) {
+ return (
+
+
+ {label}
+
+ {value}
+
+ )
+}
+
+export function RunQueue() {
+ const [searchQuery, setSearchQuery] = useState("")
+ const [selectedAttention, setSelectedAttention] = useState([])
+ const [showContext, setShowContext] = useState(true)
+ const [tableDensity, setTableDensity] = useState("compact")
+ const [expandedRows, setExpandedRows] = useState(() =>
+ 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 (
+ 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",
+ }}
+ >
+
+ {/* Header */}
+
+
+
+
+ Run Queue
+
+
+
+
+
+
+
+ Live
+
+
+
+
+ Triage the live agent run backlog.
+
+
+
+
+
+ 0 ? "destructive-light" : "secondary"
+ }
+ />
+ 0 ? "warning-light" : "secondary"}
+ />
+
+
+
+
+ {/* Toolbar */}
+
+
+
+
+
+ setSearchQuery(event.target.value)}
+ placeholder="Search runs..."
+ aria-label="Search agent runs"
+ />
+ {searchQuery.length > 0 ? (
+
+ setSearchQuery("")}
+ >
+
+
+
+ ) : null}
+
+
+
+
+
+
+ Attention
+ {activeFilterCount > 0 ? (
+ {activeFilterCount}
+ ) : null}
+
+ }
+ />
+
+
+ Attention
+ {RUN_ATTENTION_OPTIONS.map((attention) => (
+
+ handleAttentionToggle(attention, checked === true)
+ }
+ >
+ {attention}
+
+ ))}
+
+ {activeFilterCount > 0 ? (
+ <>
+
+ setSelectedAttention([])}
+ >
+ Reset attention
+
+ >
+ ) : null}
+
+
+
+
+
+
+ Display
+
+ }
+ />
+
+
+
+
+ Table
+
+
+
+
+ Density
+
+
+ setTableDensity(value as TableDensity)
+ }
+ >
+
+
+ {
+ TABLE_DENSITY_OPTIONS.find(
+ (option) => option.value === tableDensity
+ )?.label
+ }
+
+
+
+
+ {TABLE_DENSITY_OPTIONS.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+
+
+
+
+
+ Latest step line
+
+
+
+
+
+
+
+
+
+
+ {allGroupsExpanded ? "Collapse groups" : "Expand groups"}
+
+
+ {searchQuery.length > 0 || selectedAttention.length > 0 ? (
+
+ Clear
+
+ ) : null}
+
+
+
+ New run
+
+
+
+
+ {/* Content */}
+
+
+
+
+
+
+ {/* Footer */}
+
+
+ Queue Mix
+ {RUN_STATUS_GROUPS.map((group) => (
+
+ {group.label}: {getGroupRunCount(group, filteredRuns)}
+
+ ))}
+
+
+
+ {filteredRuns.length} visible
+
+
+ of {AGENT_RUNS.length} runs
+
+ 0 || searchQuery.length > 0
+ ? "info-light"
+ : "secondary"
+ }
+ >
+ {activeFilterCount > 0 || searchQuery.length > 0
+ ? "Filtered"
+ : "All runs"}
+
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/apps/web/src/components/blocks/solution-agents-2/page.tsx b/apps/web/src/components/blocks/solution-agents-2/page.tsx
new file mode 100644
index 0000000..bbc9ccf
--- /dev/null
+++ b/apps/web/src/components/blocks/solution-agents-2/page.tsx
@@ -0,0 +1,15 @@
+import { RunQueue } from "./components/run-queue"
+
+export function Page() {
+ return (
+
+
+ Run Queue
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/apps/web/src/components/blocks/solution-agents-3/components/data.tsx b/apps/web/src/components/blocks/solution-agents-3/components/data.tsx
new file mode 100644
index 0000000..ae5e019
--- /dev/null
+++ b/apps/web/src/components/blocks/solution-agents-3/components/data.tsx
@@ -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 = {
+ 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 = (
+
+)
+
+export const TOAST_INFO_ICON = (
+
+)
+
+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[] = [
+ {
+ 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[] = [
+ { value: "low", label: "Low" },
+ { value: "medium", label: "Medium" },
+ { value: "high", label: "High" },
+ { value: "urgent", label: "Urgent" },
+]
+
+export const RETRY_POLICY_OPTIONS: RunSelectOption[] = [
+ {
+ 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: [],
+ },
+]
\ No newline at end of file
diff --git a/apps/web/src/components/blocks/solution-agents-3/components/editable-detail-row.tsx b/apps/web/src/components/blocks/solution-agents-3/components/editable-detail-row.tsx
new file mode 100644
index 0000000..23fe95e
--- /dev/null
+++ b/apps/web/src/components/blocks/solution-agents-3/components/editable-detail-row.tsx
@@ -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 (
+
+
+ }
+ >
+
+
+
+ {children}
+
+
+ )
+}
+
+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(null)
+
+ useEffect(() => {
+ if (!editing) {
+ return
+ }
+
+ const frame = requestAnimationFrame(() => {
+ const control = editRef.current?.querySelector(
+ [
+ "[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 (
+
+
+ {label}
+ {hint ? {hint} : null}
+
+
+ {renderEdit ? (
+
+
+
+ {display}
+
+ {editable ? (
+ }
+ 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"
+ )}
+ >
+
+
+
+
+ ) : null}
+
+
+
+
+ {renderEdit(controlActive)}
+ {editable ? (
+
+
+
+
+
+ {saving ? (
+
+ ) : (
+
+ )}
+
+
+ ) : null}
+
+
+
+ ) : (
+
+ {display}
+
+ )}
+
+ )
+}
\ No newline at end of file
diff --git a/apps/web/src/components/blocks/solution-agents-3/components/run-detail.tsx b/apps/web/src/components/blocks/solution-agents-3/components/run-detail.tsx
new file mode 100644
index 0000000..7d89b30
--- /dev/null
+++ b/apps/web/src/components/blocks/solution-agents-3/components/run-detail.tsx
@@ -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 (
+
+ )
+}
\ No newline at end of file
diff --git a/apps/web/src/components/blocks/solution-agents-3/components/run-facts.tsx b/apps/web/src/components/blocks/solution-agents-3/components/run-facts.tsx
new file mode 100644
index 0000000..94a3de6
--- /dev/null
+++ b/apps/web/src/components/blocks/solution-agents-3/components/run-facts.tsx
@@ -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(
+ 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(
+ options: RunSelectOption[],
+ 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 (
+
+ {icon ? (
+
+ {icon}
+
+ ) : null}
+ {children}
+
+ )
+}
+
+function StatusBadge({ status }: { status: RunSettingsValue["status"] }) {
+ if (status === "completed") {
+ return (
+
+
+ Completed
+
+ )
+ }
+
+ if (status === "failed") {
+ return (
+
+
+ Failed
+
+ )
+ }
+
+ return (
+
+
+ Running
+
+ )
+}
+
+function PriorityValue({
+ priority,
+}: {
+ priority: RunSettingsValue["priority"]
+}) {
+ return (
+
+ }
+ >
+ {getOptionLabel(PRIORITY_OPTIONS, priority)}
+
+ )
+}
+
+function RetryPolicyValue({
+ retryPolicy,
+}: {
+ retryPolicy: RunSettingsValue["retryPolicy"]
+}) {
+ return (
+
+ }
+ >
+ {getOptionLabel(RETRY_POLICY_OPTIONS, retryPolicy)}
+
+ )
+}
+
+function NotifyValue({
+ notifyOnFailure,
+}: {
+ notifyOnFailure: RunSettingsValue["notifyOnFailure"]
+}) {
+ return (
+
+ }
+ >
+ {notifyOnFailure ? "Notify owners" : "Silent"}
+
+ )
+}
+
+function SelectEditor({
+ id,
+ value,
+ options,
+ disabled,
+ renderValue,
+ renderOption,
+ onValueChange,
+}: {
+ id: string
+ value: TValue
+ options: RunSelectOption[]
+ disabled: boolean
+ renderValue?: (value: TValue) => ReactNode
+ renderOption?: (option: RunSelectOption) => ReactNode
+ onValueChange: (value: TValue) => void
+}) {
+ return (
+ nextValue && onValueChange(nextValue)}
+ >
+
+
+ {renderValue ? renderValue(value) : getOptionLabel(options, value)}
+
+
+
+
+ {options.map((option) => (
+
+ {renderOption ? renderOption(option) : option.label}
+
+ ))}
+
+
+
+ )
+}
+
+function OwnersCombobox({
+ selectedOwnerIds,
+ disabled,
+ onValueChange,
+}: {
+ selectedOwnerIds: string[]
+ disabled: boolean
+ onValueChange: (ownerIds: string[]) => void
+}) {
+ const anchor = useComboboxAnchor()
+ const selectedOwners = getOwners(selectedOwnerIds)
+
+ return (
+ owner.name}
+ isItemEqualToValue={(item, value) => item.id === value.id}
+ onValueChange={(owners) => onValueChange(owners.map((owner) => owner.id))}
+ >
+
+
+ {(owners: RunOwner[]) => (
+
+ {owners.map((owner) => (
+
+
+ {owner.avatarSrc ? (
+
+ ) : null}
+
+ {owner.initials}
+
+
+ {owner.name}
+
+ ))}
+
+
+ )}
+
+
+
+ No owners found.
+
+ {(owner) => (
+
+ -
+
+ {owner.avatarSrc ? (
+
+ ) : null}
+
+ {owner.initials}
+
+
+
+
+ {owner.name}
+
+ {owner.role}
+
+
+
+ )}
+
+
+
+ )
+}
+
+function OwnerList({ owners }: { owners: RunOwner[] }) {
+ return (
+
+ {owners.map((owner) => (
+
+
+ {owner.avatarSrc ? (
+
+ ) : null}
+
+ {owner.initials}
+
+
+ {owner.name}
+
+ ))}
+
+ )
+}
+
+export function RunFacts() {
+ const [settings, setSettings] = useState(() =>
+ cloneRunSettings(DEFAULT_RUN_SETTINGS)
+ )
+ const [draft, setDraft] = useState(() =>
+ cloneRunSettings(DEFAULT_RUN_SETTINGS)
+ )
+ const [editingRows, setEditingRows] = useState([])
+ const [savingRows, setSavingRows] = useState([])
+ const saveTimersRef = useRef([])
+
+ 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(
+ 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) {
+ 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 (
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/apps/web/src/components/blocks/solution-agents-3/components/run-header.tsx b/apps/web/src/components/blocks/solution-agents-3/components/run-header.tsx
new file mode 100644
index 0000000..b655c96
--- /dev/null
+++ b/apps/web/src/components/blocks/solution-agents-3/components/run-header.tsx
@@ -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 (
+
+ {/* Left: queue context + run identity */}
+
+
+
+
+
+
+
Run Queue /
+
+ {RUN_IDENTITY.key}
+
+
+
+ Failed
+
+
+
+ {RUN_IDENTITY.agent} · {RUN_IDENTITY.environment}
+
+
+
+
+ {/* Right: watching owners + run action bar */}
+
+
+ {WATCHING_OWNERS.map((owner) => (
+
+ {owner.avatarSrc ? (
+
+ ) : null}
+
+ {owner.initials}
+
+
+ ))}
+
+
+
+
+
+
+ Approve
+
+
+
+
+ Retry Step
+
+
+
+
+
+
+
+
+ }
+ >
+
+
+
+
+
+
+
+ Export Trace
+
+
+
+
+ Copy Run Id
+
+
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/apps/web/src/components/blocks/solution-agents-3/components/run-trace.tsx b/apps/web/src/components/blocks/solution-agents-3/components/run-trace.tsx
new file mode 100644
index 0000000..79f229a
--- /dev/null
+++ b/apps/web/src/components/blocks/solution-agents-3/components/run-trace.tsx
@@ -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 = {
+ 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
+ }
+
+ if (status === "active") {
+ return
+ }
+
+ if (status === "failed") {
+ return
+ }
+
+ return
+}
+
+function ToolCallRow({ call }: { call: ToolCall }) {
+ return (
+
+
+
+ {call.name}
+
+ {call.note ? (
+
+ {call.note}
+
+ ) : null}
+
+
+
+ {call.latency}
+
+ {call.status}
+
+
+ )
+}
+
+function StepBody({
+ step,
+ onRetryStep,
+}: {
+ step: RunStep
+ onRetryStep: (step: RunStep) => void
+}) {
+ return (
+
+
{step.summary}
+
+ {step.toolCalls.length > 0 ? (
+
+ {step.toolCalls.map((call) => (
+
+ ))}
+
+ ) : null}
+
+ {step.error ? (
+
+
+ {step.error.title}
+
+ onRetryStep(step)}>
+ Retry Step
+
+
+ {step.error.detail}
+
+ ) : null}
+
+ )
+}
+
+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 (
+
+
+
+ Step Trace
+
+ Failed at step 3 of 6
+
+
+
+
+
+
+ {RUN_STEPS.map((step) => (
+
+
+
+
+
+ {step.title}
+
+ {step.duration ? (
+
+ {step.duration}
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+
+
+ {step.toolCalls.length === 1
+ ? "1 tool call"
+ : `${step.toolCalls.length} tool calls`}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/apps/web/src/components/blocks/solution-agents-3/page.tsx b/apps/web/src/components/blocks/solution-agents-3/page.tsx
new file mode 100644
index 0000000..d9090b9
--- /dev/null
+++ b/apps/web/src/components/blocks/solution-agents-3/page.tsx
@@ -0,0 +1,15 @@
+import { RunDetail } from "./components/run-detail"
+
+export function Page() {
+ return (
+
+
+ Run Detail
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/apps/web/src/components/blocks/solution-crm-4/components/data.tsx b/apps/web/src/components/blocks/solution-crm-4/components/data.tsx
new file mode 100644
index 0000000..6f8b531
--- /dev/null
+++ b/apps/web/src/components/blocks/solution-crm-4/components/data.tsx
@@ -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 = (
+
+)
+
+export const TOAST_ERROR_ICON = (
+
+)
+
+export const TOAST_MESSAGE_ICON = (
+
+)
+
+// 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 = {
+ 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 = {
+ 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 = {
+ "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 = {
+ call: (
+
+ ),
+ email: (
+
+ ),
+ meeting: (
+
+ ),
+ note: (
+
+ ),
+ demo: (
+
+ ),
+}
+
+export const activityKindIndicatorClass: Record = {
+ 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",
+ },
+]
\ No newline at end of file
diff --git a/apps/web/src/components/blocks/solution-crm-4/components/deal-detail-sheet.tsx b/apps/web/src/components/blocks/solution-crm-4/components/deal-detail-sheet.tsx
new file mode 100644
index 0000000..47e9815
--- /dev/null
+++ b/apps/web/src/components/blocks/solution-crm-4/components/deal-detail-sheet.tsx
@@ -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 (
+
+ {label ? (
+
+
+ {label}
+
+ {action}
+
+ ) : null}
+ {children}
+
+ )
+}
+
+function DotSeparator({ className }: { className?: string }) {
+ return (
+
+ )
+}
+
+function SegmentedText({
+ value,
+ className,
+}: {
+ value: TextSegments
+ className?: string
+}) {
+ const segments = Array.isArray(value) ? value : [value]
+
+ if (segments.length === 1) {
+ return {segments[0]}
+ }
+
+ return (
+
+ {segments.map((segment, index) => (
+
+ {index > 0 ? : null}
+ {segment}
+
+ ))}
+
+ )
+}
+
+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 (
+
+ )
+}
+
+// FactRow: key fields as a label/value dl, the grammar from solution-agents-7's
+// run-detail sheet.
+function FactRow({ fact }: { fact: DealField }) {
+ return (
+
+
{fact.label}
+
+
+
+
+ )
+}
+
+// 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 (
+
+
+ {owners.map((person) => (
+
+ {person.avatar ? (
+
+ ) : null}
+
+ {person.initials}
+
+
+ ))}
+
+ {owner ? (
+
+ {owner.name}
+
+ owns
+
+ ) : null}
+
+ )
+}
+
+// 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 (
+
+
+
+
+
+
+
+
+
+ {task.name}
+
+
{task.status}
+
+
+ {task.type}
+
+
+
+
+
+ {task.owner.avatar ? (
+
+ ) : null}
+
+ {task.owner.initials}
+
+
+
{task.owner.name}
+
+
+
+ )
+}
+
+// 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 (
+
+
+
+
+ {activityKindIcon[activity.kind]}
+
+
+
+
+
+
+
+ {activity.timeLabel}
+
+
+
+
+ {activity.detail ? (
+
+ {activity.detail}
+
+ ) : null}
+
+
+ {activity.author.avatar ? (
+
+ ) : null}
+
+ {activity.author.initials}
+
+
+
{activity.author.name}
+
+
+
+ )
+}
+
+export function DealDetailSheet() {
+ const [open, setOpen] = useState(true)
+ // savedStage is the committed baseline; stage is the in-flight selection.
+ const [savedStage, setSavedStage] = useState(DEAL.stage)
+ const [stage, setStage] = useState(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 (
+
+ {/* Actions */}
+
+
setOpen(true)}
+ >
+
+ Open Deal
+
+ }
+ />
+
+
+ {/* Content */}
+
+ {/* Header */}
+
+
+
+
+ {DEAL.id}
+
+
+
+
+ }
+ />
+
+
+
+
+
+
+ {stage}
+
+
+
+
+
+
+
+ Mark Won
+
+
+ Mark Lost
+
+
+
+
+
+ {/* Body */}
+
+
+ {/* Hero: amount, delta, and the win-probability progress */}
+
+
+
+
+ {DEAL.amount}
+
+ {DEAL.delta}
+
+
+ {DEAL.prior}
+
+
+
+
+ Win probability
+ {probability}%
+
+
+
+
+
+ {/* Inline stage change */}
+
+
+
+
+
+
+
+
+ {STAGE_OPTIONS.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+
+ {dirty ? (
+
+ Save
+
+ ) : null}
+
+ {dirty ? (
+
+
+ Unsaved stage change
+
+ win probability now {probability}%
+
+ ) : null}
+
+
+
+
+ {/* Key fields */}
+
+
+ {DEAL.fields.map((field) => (
+
+ ))}
+
+
+
+
+
+ {/* Deal team */}
+
+
+
+
+ {/* Next step */}
+
+
+ Log activity
+
+ }
+ >
+
+
+
+
+
+ {/* Financials */}
+
+
+ {DEAL.financials.map((field) => (
+
+ ))}
+
+
+
+
+
+ {/* Note */}
+
+
+ Add note
+
+ }
+ >
+
+
{DEAL.note}
+
+
+ {DEAL.noteAuthor.avatar ? (
+
+ ) : null}
+
+ {DEAL.noteAuthor.initials}
+
+
+
{DEAL.noteAuthor.name}
+
+
+
+
+
+
+ {/* Activity log */}
+
+
+ {DEAL_ACTIVITY.map((item, index) => (
+
+ ))}
+
+
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/apps/web/src/components/blocks/solution-crm-4/page.tsx b/apps/web/src/components/blocks/solution-crm-4/page.tsx
new file mode 100644
index 0000000..fa454ff
--- /dev/null
+++ b/apps/web/src/components/blocks/solution-crm-4/page.tsx
@@ -0,0 +1,15 @@
+import { DealDetailSheet } from "./components/deal-detail-sheet"
+
+export function Page() {
+ return (
+
+
+ Deal detail
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/apps/web/src/components/blocks/solution-inventory-9/components/data.ts b/apps/web/src/components/blocks/solution-inventory-9/components/data.ts
new file mode 100644
index 0000000..da0d65d
--- /dev/null
+++ b/apps/web/src/components/blocks/solution-inventory-9/components/data.ts
@@ -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",
+ },
+]
\ No newline at end of file
diff --git a/apps/web/src/components/blocks/solution-inventory-9/components/track-shipping-sheet.tsx b/apps/web/src/components/blocks/solution-inventory-9/components/track-shipping-sheet.tsx
new file mode 100644
index 0000000..423c87f
--- /dev/null
+++ b/apps/web/src/components/blocks/solution-inventory-9/components/track-shipping-sheet.tsx
@@ -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) {
+ return (
+
+
+
+ DHL
+
+
+
+
+
+
+
+ )
+}
+
+function CarrierChip({ name }: { name: string }) {
+ return (
+
+
+ {name}
+
+ )
+}
+
+// ─────────────────────────────────────────────────────────────────────────
+// 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 (
+
+
+ {/* Route stops */}
+
+
+ {stops.map((address, index) => {
+ const isLast = index === stops.length - 1
+ return (
+
+ {!isLast ? (
+
+ ) : null}
+
+
+ {address}
+
+
+ )
+ })}
+
+
+
+
+ {/* Stepper */}
+
+
+
+ )
+}
+
+function Stepper({ steps }: { steps: ShippingStep[] }) {
+ return (
+
+
+ {steps.map((step, i) => (
+
+ ))}
+
+
+ {steps.map((step) => (
+
+
+
+ {step.label}
+
+
+ ))}
+
+
+ )
+}
+
+function StepIcon({ state }: { state: ShippingStep["state"] }) {
+ if (state === "done") {
+ return (
+
+
+
+ )
+ }
+ if (state === "active") {
+ return (
+
+
+
+ )
+ }
+ return (
+
+ )
+}
+
+// ─────────────────────────────────────────────────────────────────────────
+// 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: (
+
+ {SHIPPING_DATA.trackingNo}
+
+ ),
+ },
+ ]
+ return (
+
+
+ Shipping Data
+
+
+
+ {stats.map((stat) => (
+
+
+ {stat.label}
+
+
+ {stat.value}
+
+
+ ))}
+
+
+
+ )
+}
+
+// ─────────────────────────────────────────────────────────────────────────
+// Shipping log timeline
+// ─────────────────────────────────────────────────────────────────────────
+
+function ShippingLogSection() {
+ return (
+
+
+ Shipping Log
+
+
+
+ {SHIPPING_LOG.map((entry, index) => {
+ const isLast = index === SHIPPING_LOG.length - 1
+ return (
+
+ {!isLast ? (
+
+ ) : null}
+
+
+
+
+
+ {entry.event}
+
+
+ {entry.datetime}
+
+
+
+ {entry.description}
+
+ {entry.location ? (
+
+
+ {entry.location}
+
+ ) : null}
+
+
+
+ )
+ })}
+
+
+
+ )
+}
+
+// ─────────────────────────────────────────────────────────────────────────
+// Sheet shell
+// ─────────────────────────────────────────────────────────────────────────
+
+export function TrackShippingSheet() {
+ const [open, setOpen] = useState(true)
+ return (
+
+
+
+ Open Track Shipping
+
+ }
+ />
+
+
+
+ {/* Header */}
+
+
+
+ Track Shipping
+
+
+
+
+ }
+ />
+
+
+ Shipment {SHIPMENT.id} live tracking and shipping log.
+
+
+
+
+
+ {SHIPMENT.id}
+
+ {SHIPMENT.status}
+
+
+ Placed
+
+ {SHIPMENT.placedDate}
+
+
+ Order ID
+
+ {SHIPMENT.orderId}
+
+
+
+
+ Cancel Order}
+ />
+
+ Notify Customer
+
+
+
+
+
+ {/* Body - single column with one ScrollArea (works for both mobile and desktop) */}
+
+
+ {/* Footer - full-width Close button */}
+
+
+ Close
+
+ }
+ />
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/apps/web/src/components/blocks/solution-inventory-9/page.tsx b/apps/web/src/components/blocks/solution-inventory-9/page.tsx
new file mode 100644
index 0000000..98cc5e0
--- /dev/null
+++ b/apps/web/src/components/blocks/solution-inventory-9/page.tsx
@@ -0,0 +1,9 @@
+import { TrackShippingSheet } from "./components/track-shipping-sheet"
+
+export function Page() {
+ return (
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/apps/web/src/components/reui/data-grid/data-grid-column-filter.tsx b/apps/web/src/components/reui/data-grid/data-grid-column-filter.tsx
index 01ce928..1278daa 100644
--- a/apps/web/src/components/reui/data-grid/data-grid-column-filter.tsx
+++ b/apps/web/src/components/reui/data-grid/data-grid-column-filter.tsx
@@ -1,4 +1,3 @@
-"use client"
"use no memo"
import { useMemo, useState } from "react"
diff --git a/apps/web/src/components/reui/data-grid/data-grid-column-header.tsx b/apps/web/src/components/reui/data-grid/data-grid-column-header.tsx
index c4c98aa..172a134 100644
--- a/apps/web/src/components/reui/data-grid/data-grid-column-header.tsx
+++ b/apps/web/src/components/reui/data-grid/data-grid-column-header.tsx
@@ -1,3 +1,4 @@
+"use client"
"use no memo"
import { HTMLAttributes, memo, ReactNode, useMemo } from "react"
diff --git a/apps/web/src/components/reui/data-grid/data-grid-column-visibility.tsx b/apps/web/src/components/reui/data-grid/data-grid-column-visibility.tsx
index 601f7c4..4001f3b 100644
--- a/apps/web/src/components/reui/data-grid/data-grid-column-visibility.tsx
+++ b/apps/web/src/components/reui/data-grid/data-grid-column-visibility.tsx
@@ -1,4 +1,3 @@
-"use client"
"use no memo"
import { ReactElement } from "react"
diff --git a/apps/web/src/components/reui/data-grid/data-grid-pagination.tsx b/apps/web/src/components/reui/data-grid/data-grid-pagination.tsx
index d8b3743..ea0c885 100644
--- a/apps/web/src/components/reui/data-grid/data-grid-pagination.tsx
+++ b/apps/web/src/components/reui/data-grid/data-grid-pagination.tsx
@@ -1,3 +1,4 @@
+"use client"
"use no memo"
import React, { ReactNode } from "react"
diff --git a/apps/web/src/components/reui/data-grid/data-grid-scroll-area.tsx b/apps/web/src/components/reui/data-grid/data-grid-scroll-area.tsx
index 3ce9295..8a7e474 100644
--- a/apps/web/src/components/reui/data-grid/data-grid-scroll-area.tsx
+++ b/apps/web/src/components/reui/data-grid/data-grid-scroll-area.tsx
@@ -1,4 +1,3 @@
-"use client"
"use no memo"
import {
diff --git a/apps/web/src/components/reui/data-grid/data-grid-table-dnd-rows.tsx b/apps/web/src/components/reui/data-grid/data-grid-table-dnd-rows.tsx
index 9850a0c..da1df51 100644
--- a/apps/web/src/components/reui/data-grid/data-grid-table-dnd-rows.tsx
+++ b/apps/web/src/components/reui/data-grid/data-grid-table-dnd-rows.tsx
@@ -1,3 +1,4 @@
+"use client"
"use no memo"
import {
diff --git a/apps/web/src/components/reui/data-grid/data-grid-table-dnd.tsx b/apps/web/src/components/reui/data-grid/data-grid-table-dnd.tsx
index 3951dc3..01b8880 100644
--- a/apps/web/src/components/reui/data-grid/data-grid-table-dnd.tsx
+++ b/apps/web/src/components/reui/data-grid/data-grid-table-dnd.tsx
@@ -1,4 +1,3 @@
-"use client"
"use no memo"
import {
diff --git a/apps/web/src/components/reui/data-grid/data-grid-table-virtual.tsx b/apps/web/src/components/reui/data-grid/data-grid-table-virtual.tsx
index 7055e85..22d408d 100644
--- a/apps/web/src/components/reui/data-grid/data-grid-table-virtual.tsx
+++ b/apps/web/src/components/reui/data-grid/data-grid-table-virtual.tsx
@@ -1,3 +1,4 @@
+"use client"
"use no memo"
import {
diff --git a/apps/web/src/components/reui/data-grid/data-grid-table.tsx b/apps/web/src/components/reui/data-grid/data-grid-table.tsx
index 3624d2f..e2e77b3 100644
--- a/apps/web/src/components/reui/data-grid/data-grid-table.tsx
+++ b/apps/web/src/components/reui/data-grid/data-grid-table.tsx
@@ -1,4 +1,3 @@
-"use client"
"use no memo"
import {
diff --git a/apps/web/src/components/reui/data-grid/data-grid.tsx b/apps/web/src/components/reui/data-grid/data-grid.tsx
index e40ddc5..a9dc74c 100644
--- a/apps/web/src/components/reui/data-grid/data-grid.tsx
+++ b/apps/web/src/components/reui/data-grid/data-grid.tsx
@@ -1,3 +1,4 @@
+"use client"
"use no memo"
import { createContext, ReactNode, useContext, useMemo, useRef } from "react"
diff --git a/apps/web/src/components/reui/timeline.tsx b/apps/web/src/components/reui/timeline.tsx
index 493579e..ee75e6e 100644
--- a/apps/web/src/components/reui/timeline.tsx
+++ b/apps/web/src/components/reui/timeline.tsx
@@ -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"
diff --git a/apps/web/src/routes/_auth/agents/$id.tsx b/apps/web/src/routes/_auth/agents/$id.tsx
index 5e88c8d..56f2559 100644
--- a/apps/web/src/routes/_auth/agents/$id.tsx
+++ b/apps/web/src/routes/_auth/agents/$id.tsx
@@ -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(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 (
-
-
-
-
-
-
- )
- }
-
- const headerDesc = [
- a.hostname,
- platformLabel(a.platform),
- `gen ${a.policy_generation}`,
- a.default_action === 'drop' ? 'default Drop' : 'default Accept',
- ]
- .filter(Boolean)
- .join(' · ')
-
- return (
-
-
-
-
-
- {a.status === 'pending' ? (
- approve.mutate()}
- disabled={approve.isPending}
- >
- Approve
-
- ) : null}
- {a.status === 'approved' ? (
- revoke.mutate()}
- disabled={revoke.isPending}
- >
- Revoke
-
- ) : null}
- {a.install_curl ? (
- {
- copyToClipboard(a.install_curl!)
- toast.success('Скопировано')
- }}
- >
-
- Install
-
- ) : null}
-
-
- }
- >
-
-
-
- setOverrideOpen(true)}>
-
- IP override
-
- setCloneOpen(true)}>
-
- Копировать наборы
-
- {a.install_curl ? (
- {
- copyToClipboard(a.install_curl!)
- toast.success('Скопировано')
- installRef.current?.scrollIntoView({
- behavior: 'smooth',
- })
- }}
- >
-
- Install curl
-
- ) : null}
- }
- >
- К списку
-
-
-
- >
- }
- />
-
- {a.last_apply_error ? (
-
-
- Ошибка apply
- {a.last_apply_error}
-
- ) : null}
-
- ,
- iconClassName: 'text-warning',
- label: 'Dropped',
- description: String(a.last_apply_packets_dropped ?? 0),
- hint: 'сумма counters',
- variant: 'warning',
- },
- {
- id: 'accepted',
- icon: ,
- iconClassName: 'text-success',
- label: 'Accepted',
- description: String(a.last_apply_packets_accepted ?? 0),
- hint: 'сумма counters',
- },
- {
- id: 'kernel',
- icon: ,
- iconClassName: 'text-info',
- label: 'Kernel',
- description: a.last_apply_kernel_method ?? '—',
- },
- {
- id: 'apply',
- icon: ,
- iconClassName: 'text-primary',
- label: 'Last apply',
- description: a.last_apply_at ?? '—',
- },
- ]}
- />
-
-
-
-
-
-
-
-
-
- )
-}
diff --git a/apps/web/src/routes/_auth/agents/index.tsx b/apps/web/src/routes/_auth/agents/index.tsx
index 62fd6c3..46a1c1c 100644
--- a/apps/web/src/routes/_auth/agents/index.tsx
+++ b/apps/web/src/routes/_auth/agents/index.tsx
@@ -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): 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): 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([])
const [searchQuery, setSearchQuery] = useState('')
const [activeTab, setActiveTab] = useState('all')
- const [detailAgentId, setDetailAgentId] = useState(null)
- const [detailOpen, setDetailOpen] = useState(false)
+
+ const detailOpen = Boolean(detailAgentId)
+
+ const setSearch = useCallback(
+ (next: Partial) => {
+ void navigate({
+ search: (prev) => {
+ const base = parseAgentsSearch(prev as Record)
+ 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 = (
+ {
+ const v = next[0]
+ if (v === 'cards' || v === 'table') {
+ setSearch({ view: v })
+ }
+ }}
+ variant="outline"
+ size="sm"
+ spacing={0}
+ aria-label="Вид списка"
+ >
+
+
+
+
+
+
+
+ )
+
const addButton = (
setCreateOpen(true)}>
@@ -329,12 +429,40 @@ function AgentsPage() {
) : null}
- {agentsQ.isError ? (
+ {view === 'table' ? (
+ void agentsQ.refetch()}
+ emptyAction={addButton}
+ toolbarExtra={viewToggle}
+ onSelect={handleSelectAgent}
+ onApprove={(id) => approve.mutate(id)}
+ approvePending={approve.isPending}
+ onCopyInstall={handleCopyInstall}
+ />
+ ) : agentsQ.isError ? (
Ошибка загрузки
- {agentsQ.error?.message ?? 'Не удалось загрузить данные'}
+
+ {agentsQ.error?.message ?? 'Не удалось загрузить данные'}
+
+
Фильтры
@@ -382,16 +514,19 @@ function AgentsPage() {
/>
- {filters.length > 0 || searchQuery ? (
-
+ {viewToggle}
+ {filters.length > 0 || searchQuery ? (
+
+ Сбросить
+
+ ) : null}
+