Files
legal-ai/web-ui/src/app/settings/_components/agents-tab.tsx
Chaim 67c2c43777
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 3s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
fix(web-ui): display all human-facing timestamps in Asia/Jerusalem (deterministic)
Storage stays UTC (DB TIMESTAMPTZ, API ISO-UTC) — only the display layer is
localized, and now deterministically: every timestamp renders pinned to
Asia/Jerusalem via a single Intl-based formatter, so SSR (UTC container) and
the browser agree on any runtime. No layout/visible-format change — only the tz.

- New single date formatter web-ui/src/lib/format-date.ts (G2): formatDate /
  formatDateShort / formatDateLong / formatDateTime / formatDateTimeFull /
  formatTime / formatIsoDate + Israel helpers getIsraelYear / israelDayKey /
  israelMidnightMs / israelParts + formatRelative (long/short/tight wording).
- Routed all ad-hoc toLocaleDateString/toLocaleTimeString/toLocaleString +
  hand-rolled new Date(...).get*() / toISOString().slice(0,10) timestamp call
  sites (30 files) through it. Number toLocaleString left untouched.
- Spec: added INV-UI9 to docs/spec/X6 (UTC storage, Asia/Jerusalem display).

Display-only; no layout/design/IA change → Claude Design gate N/A.
Invariants: G2 (single date formatter, no parallel ad-hoc formatting), INV-UI9.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 17:18:09 +00:00

374 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState } from "react";
import {
AlertCircle,
Bot,
ChevronDown,
ChevronUp,
PauseCircle,
PlayCircle,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import {
usePaperclipAgents,
type AgentPair,
type DriftEntry,
type PaperclipAgent,
} from "@/lib/api/paperclip-agents";
import { formatDateShort } from "@/lib/format-date";
const ROLE_LABEL: Record<string, string> = {
ceo: "CEO",
researcher: "מחקר",
engineer: "כתיבה",
qa: "בקרה",
general: "כללי",
};
const FIELD_LABEL: Record<string, string> = {
model: "מודל",
effort: "effort",
timeoutSec: "timeout (שניות)",
maxTurnsPerRun: "max turns",
desiredSkills: "skills",
instructionsBundleMode: "bundle mode",
instructionsEntryFile: "entry file",
graceSec: "grace (שניות)",
cooldownSec: "cooldown (שניות)",
wakeOnDemand: "wake on demand",
maxConcurrentRuns: "max concurrent",
budget_monthly_cents: "תקציב חודשי",
status: "סטטוס",
};
function formatCents(cents: number | null): string {
if (cents == null) return "—";
return `$${(cents / 100).toFixed(2)}`;
}
function StatusBadge({ agent }: { agent: PaperclipAgent }) {
const status = agent.status ?? "unknown";
if (status === "paused" || status === "terminated") {
return (
<Badge variant="outline" className="bg-warn-bg text-warn border-warn/40">
<PauseCircle className="w-3 h-3 me-1" />
{status === "paused" ? "מושהה" : "סיים"}
</Badge>
);
}
return (
<Badge variant="outline" className="bg-success-bg text-success border-success/40">
<PlayCircle className="w-3 h-3 me-1" />
פעיל
</Badge>
);
}
function FieldRow({
label,
master,
mirror,
drifted,
mono,
}: {
label: string;
master: React.ReactNode;
mirror: React.ReactNode;
drifted: boolean;
mono?: boolean;
}) {
const cellBase = `tabular-nums text-[0.82rem] ${mono ? "font-mono" : ""}`;
const cellCls = (val: React.ReactNode) =>
`${cellBase} px-2 py-1 rounded ${
drifted ? "bg-warn-bg text-warn border border-warn/40" : "text-ink"
} ${val == null || val === "—" ? "text-ink-light" : ""}`;
return (
<div className="grid grid-cols-[7rem_1fr_1fr] gap-2 items-center">
<div className="text-[0.75rem] text-ink-muted">{label}</div>
<div className={cellCls(master)} dir="ltr">{master ?? "—"}</div>
<div className={cellCls(mirror)} dir="ltr">{mirror ?? "—"}</div>
</div>
);
}
function PairCard({ pair }: { pair: AgentPair }) {
const [expanded, setExpanded] = useState(false);
const driftFields = new Set(pair.drift.map((d) => d.field));
const driftCount = pair.drift.length;
const pairMissing = driftFields.has("_pair_missing");
const a = pair.master ?? pair.mirror;
if (!a) return null;
const fieldVal = (
side: "master" | "mirror",
key: keyof PaperclipAgent,
): React.ReactNode => {
const agent = pair[side];
if (!agent) return <span className="text-ink-light"></span>;
const v = agent[key];
if (v == null) return "—";
if (typeof v === "boolean") return v ? "✓" : "✗";
if (Array.isArray(v)) return `${v.length}`;
return String(v);
};
const skillsList = (agent: PaperclipAgent | null) =>
agent?.desiredSkills?.length ? agent.desiredSkills : [];
return (
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-5 py-4 space-y-4">
<div className="flex items-start justify-between gap-3 flex-wrap">
<div className="flex items-center gap-2 min-w-0">
<Bot className="w-5 h-5 text-gold-deep shrink-0" />
<div>
<h3 className="text-navy font-semibold text-base mb-0">{pair.name}</h3>
<div className="flex items-center gap-2 mt-0.5">
<Badge variant="outline" className="text-[0.7rem]">
{ROLE_LABEL[pair.role ?? ""] ?? pair.role ?? "—"}
</Badge>
{pair.master && <StatusBadge agent={pair.master} />}
</div>
</div>
</div>
{pairMissing ? (
<Badge variant="outline" className="bg-danger-bg text-danger border-danger/40">
<AlertCircle className="w-3 h-3 me-1" />
{pair.master ? "חסר ב-CMPA" : "חסר ב-CMP"}
</Badge>
) : driftCount > 0 ? (
<Badge variant="outline" className="bg-warn-bg text-warn border-warn/40">
<AlertCircle className="w-3 h-3 me-1" />
{driftCount} פערים
</Badge>
) : (
<Badge variant="outline" className="bg-success-bg text-success border-success/40">
מסונכרן
</Badge>
)}
</div>
<div className="grid grid-cols-[7rem_1fr_1fr] gap-2 text-[0.7rem] uppercase tracking-wide text-ink-muted border-b border-rule pb-1">
<div></div>
<div>CMP (1xxx)</div>
<div>CMPA (8xxx)</div>
</div>
<div className="space-y-1">
<FieldRow label={FIELD_LABEL.model} master={fieldVal("master", "model")} mirror={fieldVal("mirror", "model")} drifted={driftFields.has("model")} mono />
<FieldRow label={FIELD_LABEL.effort} master={fieldVal("master", "effort")} mirror={fieldVal("mirror", "effort")} drifted={driftFields.has("effort")} />
<FieldRow label={FIELD_LABEL.timeoutSec} master={fieldVal("master", "timeoutSec")} mirror={fieldVal("mirror", "timeoutSec")} drifted={driftFields.has("timeoutSec")} />
<FieldRow label={FIELD_LABEL.maxTurnsPerRun} master={fieldVal("master", "maxTurnsPerRun")} mirror={fieldVal("mirror", "maxTurnsPerRun")} drifted={driftFields.has("maxTurnsPerRun")} />
<FieldRow
label={FIELD_LABEL.desiredSkills}
master={pair.master ? `${pair.master.desiredSkills.length}` : "—"}
mirror={pair.mirror ? `${pair.mirror.desiredSkills.length}` : "—"}
drifted={driftFields.has("desiredSkills")}
/>
<FieldRow label={FIELD_LABEL.graceSec} master={fieldVal("master", "graceSec")} mirror={fieldVal("mirror", "graceSec")} drifted={driftFields.has("graceSec")} />
<FieldRow label={FIELD_LABEL.cooldownSec} master={fieldVal("master", "cooldownSec")} mirror={fieldVal("mirror", "cooldownSec")} drifted={driftFields.has("cooldownSec")} />
<FieldRow label={FIELD_LABEL.wakeOnDemand} master={fieldVal("master", "wakeOnDemand")} mirror={fieldVal("mirror", "wakeOnDemand")} drifted={driftFields.has("wakeOnDemand")} />
<FieldRow label={FIELD_LABEL.maxConcurrentRuns} master={fieldVal("master", "maxConcurrentRuns")} mirror={fieldVal("mirror", "maxConcurrentRuns")} drifted={driftFields.has("maxConcurrentRuns")} />
<FieldRow
label={FIELD_LABEL.budget_monthly_cents}
master={
pair.master
? `${formatCents(pair.master.spent_monthly_cents)} / ${formatCents(pair.master.budget_monthly_cents)}`
: "—"
}
mirror={
pair.mirror
? `${formatCents(pair.mirror.spent_monthly_cents)} / ${formatCents(pair.mirror.budget_monthly_cents)}`
: "—"
}
drifted={driftFields.has("budget_monthly_cents")}
/>
<FieldRow label={FIELD_LABEL.instructionsBundleMode} master={fieldVal("master", "instructionsBundleMode")} mirror={fieldVal("mirror", "instructionsBundleMode")} drifted={driftFields.has("instructionsBundleMode")} mono />
<FieldRow label={FIELD_LABEL.instructionsEntryFile} master={fieldVal("master", "instructionsEntryFile")} mirror={fieldVal("mirror", "instructionsEntryFile")} drifted={driftFields.has("instructionsEntryFile")} mono />
</div>
<div className="flex items-center justify-between pt-1 border-t border-rule">
<Button
variant="ghost"
size="sm"
className="text-[0.78rem] text-ink-muted"
onClick={() => setExpanded((v) => !v)}
>
{expanded ? (
<>
<ChevronUp className="w-3 h-3 me-1" />
כיווץ
</>
) : (
<>
<ChevronDown className="w-3 h-3 me-1" />
פרטים מלאים
</>
)}
</Button>
{pair.master?.updated_at && (
<span className="text-[0.7rem] text-ink-light">
עודכן: {formatDateShort(pair.master.updated_at)}
</span>
)}
</div>
{expanded && (
<div className="pt-2 border-t border-rule space-y-3">
{pair.drift.length > 0 && !pairMissing && (
<div className="rounded-md bg-warn-bg/40 border border-warn/30 p-3">
<div className="text-[0.78rem] text-warn font-medium mb-2">פערי סנכרון</div>
<ul className="space-y-1 text-[0.78rem]">
{pair.drift.map((d: DriftEntry) => (
<li key={d.field} className="flex items-center gap-2 flex-wrap">
<code dir="ltr" className="text-[0.72rem]">{FIELD_LABEL[d.field] ?? d.field}</code>
<span className="text-ink-muted">CMP:</span>
<code dir="ltr" className="text-[0.72rem] text-ink">{JSON.stringify(d.master)}</code>
<span className="text-ink-muted">CMPA:</span>
<code dir="ltr" className="text-[0.72rem] text-ink">{JSON.stringify(d.mirror)}</code>
</li>
))}
</ul>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{(["master", "mirror"] as const).map((side) => {
const agent = pair[side];
const skills = skillsList(agent);
return (
<div key={side} className="rounded-md border border-rule p-3 space-y-2">
<div className="text-[0.75rem] text-ink-muted">
{side === "master" ? "CMP" : "CMPA"}
</div>
{agent ? (
<>
<div className="text-[0.72rem] font-mono text-ink-muted" dir="ltr">
id: {agent.id}
</div>
<div>
<div className="text-[0.72rem] text-ink-muted mb-1">
skills ({skills.length})
</div>
{skills.length === 0 ? (
<span className="text-[0.78rem] text-ink-light"></span>
) : (
<ul className="space-y-0.5">
{skills.map((s) => (
<li key={s} className="text-[0.72rem] font-mono" dir="ltr">
{s}
</li>
))}
</ul>
)}
</div>
{agent.instructionsFilePath && (
<div>
<div className="text-[0.72rem] text-ink-muted">instructions path</div>
<code className="text-[0.72rem] font-mono break-all" dir="ltr">
{agent.instructionsFilePath}
</code>
</div>
)}
{agent.pause_reason && (
<div className="text-[0.78rem] text-warn">
סיבת השהיה: {agent.pause_reason}
</div>
)}
</>
) : (
<span className="text-[0.78rem] text-ink-light">חסר</span>
)}
</div>
);
})}
</div>
</div>
)}
</CardContent>
</Card>
);
}
export function AgentsTab() {
const { data, isPending, error, refetch, isFetching } = usePaperclipAgents();
if (error) {
return (
<Card className="bg-surface border-danger/40">
<CardContent className="p-6 flex items-center gap-3 text-danger">
<AlertCircle className="w-5 h-5" />
<span>שגיאה: {error.message}</span>
</CardContent>
</Card>
);
}
if (isPending) {
return (
<div className="space-y-3">
{[...Array(7)].map((_, i) => (
<Skeleton key={i} className="h-48 w-full rounded-lg" />
))}
</div>
);
}
if (!data || data.pairs.length === 0) {
return (
<Card className="bg-surface border-rule">
<CardContent className="px-6 py-12 text-center text-ink-muted">
לא נמצאו סוכנים
</CardContent>
</Card>
);
}
const totalDrift = data.pairs.reduce(
(sum, p) => sum + p.drift.filter((d) => d.field !== "_pair_missing").length,
0,
);
const missingCount = data.pairs.filter((p) => !p.master || !p.mirror).length;
return (
<div className="space-y-4">
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-5 py-4 flex items-center justify-between gap-3 flex-wrap">
<div className="space-y-1">
<div className="text-[0.85rem] text-ink-muted">
{data.pairs.length} סוכנים × 2 חברות (CMP master / CMPA mirror)
{totalDrift > 0 && (
<span className="text-warn ms-2">
· {totalDrift} פערי סנכרון
</span>
)}
{missingCount > 0 && (
<span className="text-danger ms-2">· {missingCount} זוגות לא שלמים</span>
)}
</div>
<div className="text-[0.7rem] text-ink-light">
פערי skills מחושבים על paperclipai/* בלבד. local/* ו-company/* מסוננים שם שונה בין החברות הוא צפוי.
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={() => refetch()}
disabled={isFetching}
>
רענון
</Button>
</CardContent>
</Card>
<div className="space-y-3">
{data.pairs.map((pair) => (
<PairCard key={pair.name} pair={pair} />
))}
</div>
</div>
);
}