feat: align hermes tasks with shared ui
This commit is contained in:
parent
8085501506
commit
1b957cf6d9
1
dashboard/.npmrc
Normal file
1
dashboard/.npmrc
Normal file
@ -0,0 +1 @@
|
||||
@bytelyst:registry=http://localhost:3300/api/packages/learning_ai_user/npm/
|
||||
959
dashboard/pnpm-lock.yaml
generated
959
dashboard/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -13,9 +13,13 @@ RUN corepack enable && corepack prepare pnpm@10.6.5 --activate
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ARG GITEA_NPM_HOST=host.docker.internal
|
||||
ARG GITEA_NPM_OWNER=learning_ai_user
|
||||
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .pnpmfile.cjs ./
|
||||
COPY web/package.json ./web/
|
||||
|
||||
RUN printf '@bytelyst:registry=http://%s:3300/api/packages/%s/npm/\n' "$GITEA_NPM_HOST" "$GITEA_NPM_OWNER" > .npmrc
|
||||
RUN pnpm install --frozen-lockfile --filter "@bytelyst/devops-web..." --ignore-scripts
|
||||
|
||||
COPY web/tsconfig.json ./web/
|
||||
|
||||
@ -16,6 +16,8 @@
|
||||
"test:e2e:ui": "playwright test --ui"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bytelyst/design-tokens": "0.2.0",
|
||||
"@bytelyst/ui": "0.1.11",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.562.0",
|
||||
"next": "16.0.0",
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
@import "../styles/tokens.css";
|
||||
@import "@bytelyst/design-tokens/css";
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
|
||||
@ -4,7 +4,7 @@ import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { ArrowLeft, CircleDashed, Clock3, ShieldAlert, Sparkles } from 'lucide-react';
|
||||
import { Badge, Button } from '@/components/ui/Primitives';
|
||||
import { Badge, Button, Card, Timeline, type TimelineItem } from '@/components/ui/Primitives';
|
||||
import { HermesShell, MetricCard, SectionCard } from '@/components/hermes-shell';
|
||||
import { getHermesProductById, getHermesTaskById, getHermesTaskEvents } from '@/lib/hermes';
|
||||
import {
|
||||
@ -17,11 +17,11 @@ import {
|
||||
|
||||
const fmt = new Intl.DateTimeFormat('en', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
|
||||
|
||||
function levelTone(level: 'debug' | 'info' | 'warn' | 'error' | 'success'): 'success' | 'warning' | 'error' | 'neutral' | 'info' {
|
||||
function levelTone(level: 'debug' | 'info' | 'warn' | 'error' | 'success'): 'success' | 'warning' | 'danger' | 'neutral' | 'info' {
|
||||
switch (level) {
|
||||
case 'success': return 'success';
|
||||
case 'warn': return 'warning';
|
||||
case 'error': return 'error';
|
||||
case 'error': return 'danger';
|
||||
case 'debug': return 'neutral';
|
||||
default: return 'info';
|
||||
}
|
||||
@ -72,6 +72,20 @@ export default function HermesTaskDetailPage({ params }: { params: { id: string
|
||||
|
||||
const product = getHermesProductById(task.productId);
|
||||
const timeline = events.slice().sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
|
||||
const timelineItems: TimelineItem[] = timeline.map((event) => ({
|
||||
id: event.id,
|
||||
title: event.message,
|
||||
description: (
|
||||
<div className="grid gap-1">
|
||||
{event.command ? <p><span className="text-[var(--bl-text-tertiary)]">Command:</span> {event.command}</p> : null}
|
||||
{event.toolName ? <p><span className="text-[var(--bl-text-tertiary)]">Tool:</span> {event.toolName}</p> : null}
|
||||
{event.artifactUrl ? <p><span className="text-[var(--bl-text-tertiary)]">Artifact:</span> {event.artifactUrl}</p> : null}
|
||||
</div>
|
||||
),
|
||||
meta: fmt.format(new Date(event.timestamp)),
|
||||
status: event.eventType,
|
||||
tone: levelTone(event.level),
|
||||
}));
|
||||
|
||||
return (
|
||||
<HermesShell
|
||||
@ -89,7 +103,7 @@ export default function HermesTaskDetailPage({ params }: { params: { id: string
|
||||
<div className="grid gap-6 xl:grid-cols-[1.2fr_0.8fr]">
|
||||
<SectionCard title="Summary" subtitle="Everything Hermes knows about this task in one place.">
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<div className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-4 space-y-3">
|
||||
<Card variant="muted" padding="sm" className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={task.status === 'completed' ? 'success' : task.status === 'failed' ? 'error' : task.status === 'blocked' ? 'warning' : 'neutral'}>{task.status}</Badge>
|
||||
<Badge variant="neutral">{task.source}</Badge>
|
||||
@ -101,8 +115,8 @@ export default function HermesTaskDetailPage({ params }: { params: { id: string
|
||||
<p><span className="text-[var(--bl-text-tertiary)]">Result:</span> {task.result ?? 'n/a'}</p>
|
||||
<p><span className="text-[var(--bl-text-tertiary)]">Blocker:</span> {task.blockerReason ?? 'n/a'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-4 space-y-3">
|
||||
</Card>
|
||||
<Card variant="muted" padding="sm" className="space-y-3">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-[var(--bl-text-tertiary)]">Execution details</p>
|
||||
<div className="space-y-2 text-sm text-[var(--bl-text-secondary)]">
|
||||
<p><span className="text-[var(--bl-text-tertiary)]">Created:</span> {fmt.format(new Date(task.createdAt))}</p>
|
||||
@ -114,28 +128,28 @@ export default function HermesTaskDetailPage({ params }: { params: { id: string
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{task.tags.map((tag) => <Badge key={tag} variant="neutral">{tag}</Badge>)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title="Hermes learning" subtitle="A place to capture the memory and prevention pattern for next time.">
|
||||
<div className="space-y-3 text-sm text-[var(--bl-text-secondary)]">
|
||||
<div className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-4">
|
||||
<Card variant="muted" padding="sm">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-[var(--bl-text-tertiary)]">Lesson learned</p>
|
||||
<p className="mt-2">{task.status === 'failed' ? 'Capture the failing command, dependency, and the exact resolution before retrying the lane.' : 'Preserve the successful execution path as a repeatable pattern.'}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-4">
|
||||
</Card>
|
||||
<Card variant="muted" padding="sm">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-[var(--bl-text-tertiary)]">Suggested memory update</p>
|
||||
<p className="mt-2">{task.status === 'blocked' ? 'Remember that this workflow requires founder approval or a credential refresh before execution can continue.' : 'Document the command sequence and verification checks for future reuse.'}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-4">
|
||||
</Card>
|
||||
<Card variant="muted" padding="sm">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-[var(--bl-text-tertiary)]">Prevention for next time</p>
|
||||
<p className="mt-2">{task.nextAction ?? 'Keep telemetry wired into the dashboard for follow-up visibility.'}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-4">
|
||||
</Card>
|
||||
<Card variant="muted" padding="sm">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-[var(--bl-text-tertiary)]">Recurring issue detection</p>
|
||||
<p className="mt-2">{task.retryCount > 0 ? 'Multiple retries detected; this lane should be watched for recurrence.' : 'No recurring pattern detected for this task.'}</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</SectionCard>
|
||||
</div>
|
||||
@ -192,24 +206,7 @@ export default function HermesTaskDetailPage({ params }: { params: { id: string
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title="Timeline" subtitle="Chronological event stream for the task lifecycle.">
|
||||
<ol className="space-y-4">
|
||||
{timeline.map((event) => (
|
||||
<li key={event.id} className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant={levelTone(event.level)}>{event.eventType}</Badge>
|
||||
<span className="text-sm font-medium text-[var(--bl-text-primary)]">{event.message}</span>
|
||||
</div>
|
||||
{event.command ? <p className="text-sm text-[var(--bl-text-secondary)]"><span className="text-[var(--bl-text-tertiary)]">Command:</span> {event.command}</p> : null}
|
||||
{event.toolName ? <p className="text-sm text-[var(--bl-text-secondary)]"><span className="text-[var(--bl-text-tertiary)]">Tool:</span> {event.toolName}</p> : null}
|
||||
{event.artifactUrl ? <p className="text-sm text-[var(--bl-text-secondary)]"><span className="text-[var(--bl-text-tertiary)]">Artifact:</span> {event.artifactUrl}</p> : null}
|
||||
</div>
|
||||
<p className="text-xs text-[var(--bl-text-tertiary)]">{fmt.format(new Date(event.timestamp))}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<Timeline items={timelineItems} />
|
||||
</SectionCard>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
|
||||
@ -3,7 +3,19 @@
|
||||
import { Fragment, useEffect, useMemo, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Download, Filter, Search, ChevronDown, ChevronUp, ArrowLeftRight, Activity } from 'lucide-react';
|
||||
import { Badge, Button, Input } from '@/components/ui/Primitives';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
DataTable,
|
||||
DataTableBody,
|
||||
DataTableCell,
|
||||
DataTableHead,
|
||||
DataTableHeader,
|
||||
DataTableRow,
|
||||
Input,
|
||||
Select,
|
||||
} from '@/components/ui/Primitives';
|
||||
import { HermesShell, MetricCard, SectionCard } from '@/components/hermes-shell';
|
||||
import { HermesInstanceBadge } from '@/components/hermes-instance-switcher';
|
||||
import { useHermesInstance } from '@/lib/hermes-instance-context';
|
||||
@ -35,6 +47,11 @@ const sources: Array<HermesTaskSource | 'all'> = ['all', 'manual', 'cron', 'gith
|
||||
const sortOptions = ['newest', 'oldest', 'priority', 'status'] as const;
|
||||
const pageSize = 8;
|
||||
|
||||
const optionize = (items: string[], allLabel: string) => items.map((item) => ({
|
||||
value: item,
|
||||
label: item === 'all' ? allLabel : item,
|
||||
}));
|
||||
|
||||
function prettyDate(value?: string) {
|
||||
return value ? new Intl.DateTimeFormat('en', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }).format(new Date(value)) : '—';
|
||||
}
|
||||
@ -225,31 +242,13 @@ export default function HermesTaskLedgerPage() {
|
||||
<SectionCard title="Filters" subtitle="Find work by status, product, priority, type, source, or age.">
|
||||
<div className="grid gap-3 lg:grid-cols-4 xl:grid-cols-7">
|
||||
<Input value={query} onChange={(event) => { setQuery(event.target.value); setPage(1); }} placeholder="Search tasks..." aria-label="Search tasks" className="xl:col-span-2" />
|
||||
<select value={status} onChange={(event) => { setStatus(event.target.value as HermesTaskStatus | 'all'); setPage(1); }} className="h-10 rounded-md border border-[var(--bl-border)] bg-[var(--bl-surface-card)] px-3 text-sm text-[var(--bl-text-primary)]">
|
||||
{statuses.map((item) => <option key={item} value={item}>{item === 'all' ? 'All statuses' : item}</option>)}
|
||||
</select>
|
||||
<select value={productId} onChange={(event) => { setProductId(event.target.value); setPage(1); }} className="h-10 rounded-md border border-[var(--bl-border)] bg-[var(--bl-surface-card)] px-3 text-sm text-[var(--bl-text-primary)]">
|
||||
<option value="all">All products</option>
|
||||
{visibleProducts.map((product) => <option key={product.id} value={product.id}>{product.name}</option>)}
|
||||
</select>
|
||||
<select value={priority} onChange={(event) => { setPriority(event.target.value as HermesPriority | 'all'); setPage(1); }} className="h-10 rounded-md border border-[var(--bl-border)] bg-[var(--bl-surface-card)] px-3 text-sm text-[var(--bl-text-primary)]">
|
||||
{priorities.map((item) => <option key={item} value={item}>{item === 'all' ? 'All priorities' : item}</option>)}
|
||||
</select>
|
||||
<select value={type} onChange={(event) => { setType(event.target.value as HermesTaskType | 'all'); setPage(1); }} className="h-10 rounded-md border border-[var(--bl-border)] bg-[var(--bl-surface-card)] px-3 text-sm text-[var(--bl-text-primary)]">
|
||||
{taskTypes.map((item) => <option key={item} value={item}>{item === 'all' ? 'All types' : item}</option>)}
|
||||
</select>
|
||||
<select value={source} onChange={(event) => { setSource(event.target.value as HermesTaskSource | 'all'); setPage(1); }} className="h-10 rounded-md border border-[var(--bl-border)] bg-[var(--bl-surface-card)] px-3 text-sm text-[var(--bl-text-primary)]">
|
||||
{sources.map((item) => <option key={item} value={item}>{item === 'all' ? 'All sources' : item}</option>)}
|
||||
</select>
|
||||
<select value={updatedWithinDays} onChange={(event) => { setUpdatedWithinDays(event.target.value === 'all' ? 'all' : Number(event.target.value)); setPage(1); }} className="h-10 rounded-md border border-[var(--bl-border)] bg-[var(--bl-surface-card)] px-3 text-sm text-[var(--bl-text-primary)]">
|
||||
<option value="all">Any time</option>
|
||||
<option value="1">Last 24h</option>
|
||||
<option value="7">Last 7d</option>
|
||||
<option value="30">Last 30d</option>
|
||||
</select>
|
||||
<select value={sort} onChange={(event) => { setSort(event.target.value as typeof sort); setPage(1); }} className="h-10 rounded-md border border-[var(--bl-border)] bg-[var(--bl-surface-card)] px-3 text-sm text-[var(--bl-text-primary)]">
|
||||
{sortOptions.map((item) => <option key={item} value={item}>{item}</option>)}
|
||||
</select>
|
||||
<Select value={status} onChange={(event) => { setStatus(event.target.value as HermesTaskStatus | 'all'); setPage(1); }} options={optionize(statuses, 'All statuses')} aria-label="Status" />
|
||||
<Select value={productId} onChange={(event) => { setProductId(event.target.value); setPage(1); }} options={[{ value: 'all', label: 'All products' }, ...visibleProducts.map((product) => ({ value: product.id, label: product.name }))]} aria-label="Product" />
|
||||
<Select value={priority} onChange={(event) => { setPriority(event.target.value as HermesPriority | 'all'); setPage(1); }} options={optionize(priorities, 'All priorities')} aria-label="Priority" />
|
||||
<Select value={type} onChange={(event) => { setType(event.target.value as HermesTaskType | 'all'); setPage(1); }} options={optionize(taskTypes, 'All types')} aria-label="Type" />
|
||||
<Select value={source} onChange={(event) => { setSource(event.target.value as HermesTaskSource | 'all'); setPage(1); }} options={optionize(sources, 'All sources')} aria-label="Source" />
|
||||
<Select value={updatedWithinDays} onChange={(event) => { setUpdatedWithinDays(event.target.value === 'all' ? 'all' : Number(event.target.value)); setPage(1); }} options={[{ value: 'all', label: 'Any time' }, { value: '1', label: 'Last 24h' }, { value: '7', label: 'Last 7d' }, { value: '30', label: 'Last 30d' }]} aria-label="Updated within" />
|
||||
<Select value={sort} onChange={(event) => { setSort(event.target.value as typeof sort); setPage(1); }} options={sortOptions.map((item) => ({ value: item, label: item }))} aria-label="Sort" />
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2 text-sm text-[var(--bl-text-secondary)]">
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] px-3 py-1"><Filter className="h-3.5 w-3.5" />{tasks.length} matches</span>
|
||||
@ -258,48 +257,46 @@ export default function HermesTaskLedgerPage() {
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title="Task table" subtitle="Click any task title to inspect the full detail view." actions={<Badge variant="neutral">Page {page} of {totalPages}</Badge>}>
|
||||
<div className="overflow-hidden rounded-2xl border border-[var(--bl-border)]">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-[var(--bl-border)] text-left text-sm">
|
||||
<thead className="bg-[var(--bl-surface-muted)] text-xs uppercase tracking-[0.18em] text-[var(--bl-text-tertiary)]">
|
||||
<tr>
|
||||
<th className="px-4 py-3">Task</th>
|
||||
<th className="px-4 py-3">Product</th>
|
||||
<th className="px-4 py-3">Status</th>
|
||||
<th className="px-4 py-3">Priority</th>
|
||||
<th className="px-4 py-3">Type</th>
|
||||
<th className="px-4 py-3">Source</th>
|
||||
<th className="px-4 py-3">Created</th>
|
||||
<th className="px-4 py-3">Duration</th>
|
||||
<th className="px-4 py-3 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--bl-border)] bg-[var(--bl-surface-card)]">
|
||||
<DataTable>
|
||||
<DataTableHeader>
|
||||
<DataTableRow>
|
||||
<DataTableHead>Task</DataTableHead>
|
||||
<DataTableHead>Product</DataTableHead>
|
||||
<DataTableHead>Status</DataTableHead>
|
||||
<DataTableHead>Priority</DataTableHead>
|
||||
<DataTableHead>Type</DataTableHead>
|
||||
<DataTableHead>Source</DataTableHead>
|
||||
<DataTableHead>Created</DataTableHead>
|
||||
<DataTableHead>Duration</DataTableHead>
|
||||
<DataTableHead className="text-right">Actions</DataTableHead>
|
||||
</DataTableRow>
|
||||
</DataTableHeader>
|
||||
<DataTableBody>
|
||||
{pagedTasks.map((task) => {
|
||||
const product = getHermesProductById(task.productId);
|
||||
const expanded = expandedTaskId === task.id;
|
||||
return (
|
||||
<Fragment key={task.id}>
|
||||
<tr className="align-top hover:bg-[var(--bl-surface-muted)]/60">
|
||||
<td className="px-4 py-4">
|
||||
<DataTableRow className="align-top">
|
||||
<DataTableCell>
|
||||
<div className="max-w-[24rem] space-y-1">
|
||||
<Link href={`/hermes/tasks/${task.id}`} className="font-medium text-[var(--bl-text-primary)] hover:underline">{task.title}</Link>
|
||||
<p className="line-clamp-2 text-xs leading-5 text-[var(--bl-text-secondary)]">{task.description}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-4 text-[var(--bl-text-secondary)]">
|
||||
</DataTableCell>
|
||||
<DataTableCell className="text-[var(--bl-text-secondary)]">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span>{product?.name ?? 'Unknown'}</span>
|
||||
<HermesInstanceBadge instanceId={task.instanceId} />
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-4"><Badge variant={task.status === 'completed' ? 'success' : task.status === 'failed' ? 'error' : task.status === 'blocked' ? 'warning' : 'neutral'}>{task.status}</Badge></td>
|
||||
<td className="px-4 py-4"><Badge variant={task.priority === 'P0' ? 'error' : task.priority === 'P1' ? 'warning' : 'neutral'}>{task.priority}</Badge></td>
|
||||
<td className="px-4 py-4 text-[var(--bl-text-secondary)]">{task.type}</td>
|
||||
<td className="px-4 py-4 text-[var(--bl-text-secondary)]">{task.source}</td>
|
||||
<td className="px-4 py-4 text-[var(--bl-text-secondary)]">{prettyDate(task.createdAt)}</td>
|
||||
<td className="px-4 py-4 text-[var(--bl-text-secondary)]">{task.durationMs ? `${Math.round(task.durationMs / 60000)}m` : '—'}</td>
|
||||
<td className="px-4 py-4 text-right">
|
||||
</DataTableCell>
|
||||
<DataTableCell><Badge variant={task.status === 'completed' ? 'success' : task.status === 'failed' ? 'error' : task.status === 'blocked' ? 'warning' : 'neutral'}>{task.status}</Badge></DataTableCell>
|
||||
<DataTableCell><Badge variant={task.priority === 'P0' ? 'error' : task.priority === 'P1' ? 'warning' : 'neutral'}>{task.priority}</Badge></DataTableCell>
|
||||
<DataTableCell className="text-[var(--bl-text-secondary)]">{task.type}</DataTableCell>
|
||||
<DataTableCell className="text-[var(--bl-text-secondary)]">{task.source}</DataTableCell>
|
||||
<DataTableCell className="text-[var(--bl-text-secondary)]">{prettyDate(task.createdAt)}</DataTableCell>
|
||||
<DataTableCell className="text-[var(--bl-text-secondary)]">{task.durationMs ? `${Math.round(task.durationMs / 60000)}m` : '—'}</DataTableCell>
|
||||
<DataTableCell className="text-right">
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => setExpandedTaskId(expanded ? null : task.id)}>
|
||||
{expanded ? <ChevronUp className="mr-1 h-4 w-4" /> : <ChevronDown className="mr-1 h-4 w-4" />}
|
||||
@ -307,13 +304,13 @@ export default function HermesTaskLedgerPage() {
|
||||
</Button>
|
||||
<Button asChild variant="secondary" size="sm"><Link href={`/hermes/tasks/${task.id}`}>Open</Link></Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</DataTableCell>
|
||||
</DataTableRow>
|
||||
{expanded ? (
|
||||
<tr key={`${task.id}-details`}>
|
||||
<td colSpan={9} className="bg-[var(--bl-surface-muted)] px-4 py-4">
|
||||
<DataTableRow key={`${task.id}-details`}>
|
||||
<DataTableCell colSpan={9} className="bg-[var(--bl-surface-muted)]">
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<div className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-card)] p-4">
|
||||
<Card padding="sm">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-[var(--bl-text-tertiary)]">Summary</p>
|
||||
<p className="mt-2 text-sm text-[var(--bl-text-secondary)]">{task.summary}</p>
|
||||
<div className="mt-3 space-y-2 text-sm text-[var(--bl-text-secondary)]">
|
||||
@ -321,8 +318,8 @@ export default function HermesTaskLedgerPage() {
|
||||
<div>Last action: {task.lastAction ?? 'n/a'}</div>
|
||||
<div>Next action: {task.nextAction ?? 'n/a'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-card)] p-4">
|
||||
</Card>
|
||||
<Card padding="sm">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-[var(--bl-text-tertiary)]">Signals</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{task.tags.map((tag) => <Badge key={tag} variant="neutral">{tag}</Badge>)}
|
||||
@ -333,23 +330,21 @@ export default function HermesTaskLedgerPage() {
|
||||
<div>Started: {prettyDate(task.startedAt)}</div>
|
||||
<div>Completed: {prettyDate(task.completedAt)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</DataTableCell>
|
||||
</DataTableRow>
|
||||
) : null}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
{pagedTasks.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={9} className="px-4 py-10 text-center text-[var(--bl-text-secondary)]">No tasks matched the current filters.</td>
|
||||
</tr>
|
||||
<DataTableRow>
|
||||
<DataTableCell colSpan={9} className="px-4 py-10 text-center text-[var(--bl-text-secondary)]">No tasks matched the current filters.</DataTableCell>
|
||||
</DataTableRow>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</DataTableBody>
|
||||
</DataTable>
|
||||
<div className="mt-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-sm text-[var(--bl-text-secondary)]">Showing {pagedTasks.length} of {tasks.length} filtered tasks.</p>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@ -1,6 +1,15 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Badge } from '@/components/ui/Primitives';
|
||||
import {
|
||||
Badge,
|
||||
MetricCard as CommonMetricCard,
|
||||
PageHeader,
|
||||
Panel,
|
||||
PanelBody,
|
||||
PanelDescription,
|
||||
PanelHeader,
|
||||
PanelTitle,
|
||||
} from '@/components/ui/Primitives';
|
||||
|
||||
interface HermesShellProps {
|
||||
title: string;
|
||||
@ -14,18 +23,12 @@ interface HermesShellProps {
|
||||
export function HermesShell({ title, description, badge = 'Hermes Mission Control', actions, children, className }: HermesShellProps) {
|
||||
return (
|
||||
<div className={cn('space-y-6', className)}>
|
||||
<header className="rounded-3xl border border-[var(--bl-border)] bg-[linear-gradient(135deg,rgba(90,140,255,0.18),rgba(46,230,214,0.08))] p-6 shadow-[var(--bl-shadow-md)] backdrop-blur-sm lg:p-8">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="max-w-3xl space-y-3">
|
||||
<Badge variant="info">{badge}</Badge>
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold tracking-tight text-[var(--bl-text-primary)] lg:text-4xl">{title}</h1>
|
||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-[var(--bl-text-secondary)] lg:text-base">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
{actions ? <div className="flex flex-wrap gap-3">{actions}</div> : null}
|
||||
</div>
|
||||
</header>
|
||||
<PageHeader
|
||||
eyebrow={<Badge variant="info">{badge}</Badge>}
|
||||
title={title}
|
||||
description={description}
|
||||
actions={actions}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@ -41,16 +44,16 @@ interface SectionCardProps {
|
||||
|
||||
export function SectionCard({ title, subtitle, actions, children, className }: SectionCardProps) {
|
||||
return (
|
||||
<section className={cn('rounded-3xl border border-[var(--bl-border)] bg-[var(--bl-surface-card)] p-5 shadow-[var(--bl-shadow-sm)] lg:p-6', className)}>
|
||||
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-[var(--bl-text-primary)]">{title}</h2>
|
||||
{subtitle ? <p className="mt-1 text-sm text-[var(--bl-text-secondary)]">{subtitle}</p> : null}
|
||||
<Panel density="normal" className={className}>
|
||||
<PanelHeader className="mb-4">
|
||||
<div className="min-w-0">
|
||||
<PanelTitle>{title}</PanelTitle>
|
||||
{subtitle ? <PanelDescription>{subtitle}</PanelDescription> : null}
|
||||
</div>
|
||||
{actions}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
</PanelHeader>
|
||||
<PanelBody>{children}</PanelBody>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
@ -63,24 +66,20 @@ interface MetricCardProps {
|
||||
}
|
||||
|
||||
export function MetricCard({ label, value, helpText, tone = 'default', icon }: MetricCardProps) {
|
||||
const toneStyles: Record<NonNullable<MetricCardProps['tone']>, string> = {
|
||||
default: 'text-[var(--bl-text-primary)]',
|
||||
success: 'text-[var(--bl-success)]',
|
||||
warning: 'text-[var(--bl-warning)]',
|
||||
danger: 'text-[var(--bl-danger)]',
|
||||
info: 'text-[var(--bl-accent)]',
|
||||
const commonTone: Record<NonNullable<MetricCardProps['tone']>, 'neutral' | 'success' | 'warning' | 'danger' | 'info'> = {
|
||||
default: 'neutral',
|
||||
success: 'success',
|
||||
warning: 'warning',
|
||||
danger: 'danger',
|
||||
info: 'info',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-card)] p-4 shadow-[var(--bl-shadow-sm)]">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-[0.22em] text-[var(--bl-text-tertiary)]">{label}</p>
|
||||
<p className={cn('mt-2 text-3xl font-semibold tracking-tight', toneStyles[tone])}>{value}</p>
|
||||
{helpText ? <p className="mt-1 text-sm text-[var(--bl-text-secondary)]">{helpText}</p> : null}
|
||||
</div>
|
||||
{icon ? <div className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-2 text-[var(--bl-text-secondary)]">{icon}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
<CommonMetricCard
|
||||
label={<span className="flex items-center justify-between gap-3">{label}{icon ? <span className="text-[var(--bl-text-tertiary)]">{icon}</span> : null}</span>}
|
||||
value={value}
|
||||
helper={helpText}
|
||||
tone={commonTone[tone]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,107 +1,116 @@
|
||||
import * as React from 'react';
|
||||
import {
|
||||
Badge as CommonBadge,
|
||||
Button as CommonButton,
|
||||
Input as CommonInput,
|
||||
Select as CommonSelect,
|
||||
type BadgeProps as CommonBadgeProps,
|
||||
type ButtonProps as CommonButtonProps,
|
||||
type InputProps as CommonInputProps,
|
||||
type SelectProps as CommonSelectProps,
|
||||
} from '@bytelyst/ui';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// Basic button component using design tokens
|
||||
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'ghost' | 'link';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
asChild?: boolean;
|
||||
}
|
||||
export {
|
||||
Card,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
DataTable,
|
||||
DataTableBody,
|
||||
DataTableCell,
|
||||
DataTableHead,
|
||||
DataTableHeader,
|
||||
DataTableRow,
|
||||
MetricCard,
|
||||
PageHeader,
|
||||
Panel,
|
||||
PanelBody,
|
||||
PanelDescription,
|
||||
PanelHeader,
|
||||
PanelTitle,
|
||||
Timeline,
|
||||
type TimelineItem,
|
||||
} from '@bytelyst/ui';
|
||||
|
||||
export type ButtonProps = CommonButtonProps;
|
||||
|
||||
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ variant = 'primary', size = 'md', asChild = false, className, children, ...props }, ref) => {
|
||||
const baseStyles = 'inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50';
|
||||
({ variant = 'primary', size = 'md', asChild, className, children, ...props }, ref) => {
|
||||
if (asChild) {
|
||||
const child = React.Children.toArray(children).find(React.isValidElement);
|
||||
const classes = cn(
|
||||
'inline-flex shrink-0 items-center justify-center whitespace-nowrap rounded-lg font-semibold tracking-normal transition duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--bl-focus-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bl-bg-canvas)] disabled:pointer-events-none disabled:opacity-50',
|
||||
variant === 'primary' && 'border border-transparent bg-[var(--bl-accent)] text-[var(--bl-accent-foreground)] shadow-sm shadow-black/10 hover:brightness-105 active:brightness-95',
|
||||
variant === 'secondary' && 'border border-[var(--bl-border)] bg-[var(--bl-surface-card)] text-[var(--bl-text-primary)] shadow-sm shadow-black/5 hover:border-[var(--bl-border-strong)] hover:bg-[var(--bl-surface-highlight)]',
|
||||
variant === 'ghost' && 'border border-transparent text-[var(--bl-text-secondary)] hover:bg-[var(--bl-surface-muted)] hover:text-[var(--bl-text-primary)]',
|
||||
variant === 'destructive' && 'border border-transparent bg-[var(--bl-danger)] text-[var(--bl-danger-foreground)] shadow-sm shadow-black/10 hover:brightness-105 active:brightness-95',
|
||||
variant === 'outline' && 'border border-[var(--bl-border)] bg-transparent text-[var(--bl-text-primary)] hover:border-[var(--bl-accent)] hover:bg-[var(--bl-accent-muted)]',
|
||||
variant === 'subtle' && 'border border-transparent bg-[var(--bl-surface-muted)] text-[var(--bl-text-primary)] hover:bg-[var(--bl-surface-highlight)]',
|
||||
variant === 'link' && 'h-auto rounded-md border border-transparent p-0 text-[var(--bl-accent)] underline-offset-4 hover:underline',
|
||||
size === 'sm' && 'h-8 px-3 text-xs gap-1.5',
|
||||
size === 'md' && 'h-10 px-4 text-sm gap-2',
|
||||
size === 'lg' && 'h-11 px-5 text-sm gap-2.5',
|
||||
className,
|
||||
);
|
||||
|
||||
const variantStyles = {
|
||||
primary: 'bg-[var(--bl-accent)] text-[var(--bl-accent-foreground)] hover:opacity-90 focus-visible:ring-[var(--bl-focus-ring)]',
|
||||
secondary: 'bg-[var(--bl-surface-muted)] text-[var(--bl-text-primary)] hover:bg-[var(--bl-surface-highlight)] focus-visible:ring-[var(--bl-focus-ring)]',
|
||||
ghost: 'text-[var(--bl-text-primary)] hover:bg-[var(--bl-surface-muted)] focus-visible:ring-[var(--bl-focus-ring)]',
|
||||
link: 'text-[var(--bl-accent)] hover:underline focus-visible:ring-[var(--bl-focus-ring)]',
|
||||
};
|
||||
|
||||
const sizeStyles = {
|
||||
sm: 'h-9 px-3 text-sm',
|
||||
md: 'h-10 px-4 text-sm',
|
||||
lg: 'h-11 px-8 text-base',
|
||||
};
|
||||
|
||||
const classes = cn(baseStyles, variantStyles[variant], sizeStyles[size], className);
|
||||
|
||||
if (asChild && React.isValidElement(children)) {
|
||||
const typedChild = children as React.ReactElement<{ className?: string }>;
|
||||
return React.cloneElement(typedChild, {
|
||||
className: cn(typedChild.props.className, classes),
|
||||
});
|
||||
if (React.isValidElement<{ className?: string }>(child)) {
|
||||
return React.cloneElement(child, {
|
||||
className: cn(child.props.className, classes),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
<CommonButton
|
||||
ref={ref}
|
||||
className={classes}
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
</CommonButton>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Button.displayName = 'Button';
|
||||
|
||||
// Basic badge component using design tokens
|
||||
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
variant?: 'neutral' | 'success' | 'warning' | 'error' | 'info';
|
||||
dot?: boolean;
|
||||
export interface BadgeProps extends Omit<CommonBadgeProps, 'variant'> {
|
||||
variant?: CommonBadgeProps['variant'] | 'danger';
|
||||
}
|
||||
|
||||
export function Badge({ variant = 'neutral', dot = false, className, children, ...props }: BadgeProps) {
|
||||
const variantStyles = {
|
||||
neutral: 'bg-[var(--bl-surface-muted)] text-[var(--bl-fg-muted)]',
|
||||
success: 'bg-[var(--bl-success-bg)] text-[var(--bl-success-fg)]',
|
||||
warning: 'bg-[var(--bl-warning-bg)] text-[var(--bl-warning-fg)]',
|
||||
error: 'bg-[var(--bl-danger-bg)] text-[var(--bl-danger-fg)]',
|
||||
info: 'bg-[var(--bl-info-bg)] text-[var(--bl-info-fg)]',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium',
|
||||
variantStyles[variant],
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{dot && <span className="h-1.5 w-1.5 rounded-full current-color" />}
|
||||
<CommonBadge variant={variant === 'danger' ? 'error' : variant} dot={dot} className={className} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
</CommonBadge>
|
||||
);
|
||||
}
|
||||
|
||||
// Input component using design tokens
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
variant?: 'surface' | 'muted';
|
||||
}
|
||||
export type InputProps = CommonInputProps;
|
||||
|
||||
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ variant = 'surface', className, ...props }, ref) => {
|
||||
const variantStyles = {
|
||||
surface: 'bg-[var(--bl-input)] border-[var(--bl-border)]',
|
||||
muted: 'bg-[var(--bl-surface-muted)] border-[var(--bl-border)]',
|
||||
};
|
||||
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-md border px-3.5 py-2.5 text-sm placeholder:text-[var(--bl-fg-muted)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--bl-primary)] focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
variantStyles[variant],
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
({ className, ...props }, ref) => <CommonInput ref={ref} className={className} {...props} />,
|
||||
);
|
||||
|
||||
Input.displayName = 'Input';
|
||||
|
||||
export interface SelectProps extends Omit<CommonSelectProps, 'options'> {
|
||||
options: CommonSelectProps['options'];
|
||||
}
|
||||
|
||||
export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
|
||||
({ controlSize = 'md', variant = 'surface', className, ...props }, ref) => (
|
||||
<CommonSelect
|
||||
ref={ref}
|
||||
controlSize={controlSize}
|
||||
variant={variant}
|
||||
className={cn('min-w-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
|
||||
Select.displayName = 'Select';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user