This commit is contained in:
ZacharyZhang-NY committed 2026-03-30 00:15:21 -04:00
1 parent 65e85135c1
commit 6f1de8047e
64 files changed
+1269 -515

No files matched your search

@@ -1,5 +1,7 @@
import { useEffect } from "react";
import { X } from "@phosphor-icons/react";
import type { AppLocale } from "@shared/locale";
import { useFocusTrap } from "../ui";
interface ClaudeSettingsDialogProps {
claudeApiKey: string;
@@ -26,17 +28,20 @@ export function ClaudeSettingsDialog({
saving,
currentLocale,
}: ClaudeSettingsDialogProps) {
const trapRef = useFocusTrap(true);
useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose() }; document.addEventListener("keydown", onKey); return () => document.removeEventListener("keydown", onKey) }, [onClose]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/72 backdrop-blur-sm" onClick={onClose}>
<div className="w-full max-w-[420px] rounded-lg border border-border bg-card p-6 shadow-lg" onClick={(e) => e.stopPropagation()}>
<div ref={trapRef} role="dialog" aria-modal="true" aria-label={currentLocale === "zh" ? "Claude Code 设置" : "Claude Code Settings"} className="w-full max-w-[min(420px,90vw)] rounded-lg border border-border bg-card p-6 shadow-lg" onClick={(e) => e.stopPropagation()}>
<div className="mb-5 flex items-center justify-between">
<h3 className="text-[16px] font-semibold text-foreground">Claude Code {currentLocale === "zh" ? "设置" : "Settings"}</h3>
<button type="button" onClick={onClose} className="rounded-lg p-1 text-muted-foreground transition hover:text-foreground"><X size={16} /></button>
</div>
<div className="space-y-4">
<div>
<label className="mb-1.5 block text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">API Key</label>
<label htmlFor="claude-api-key" className="mb-1.5 block text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">API Key</label>
<input
id="claude-api-key"
type="password"
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 w-full rounded-lg border border-border bg-card px-3 py-2.5 text-[13px] text-foreground placeholder:text-muted-foreground"
value={claudeApiKey}
@@ -46,8 +51,9 @@ export function ClaudeSettingsDialog({
<div className="mt-1 text-[11px] text-muted-foreground">{currentLocale === "zh" ? "留空使用订阅认证" : "Leave empty to use subscription auth"}</div>
</div>
<div>
<label className="mb-1.5 block text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">Base URL</label>
<label htmlFor="claude-base-url" className="mb-1.5 block text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">Base URL</label>
<input
id="claude-base-url"
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 w-full rounded-lg border border-border bg-card px-3 py-2.5 text-[13px] text-foreground placeholder:text-muted-foreground"
value={claudeBaseUrl}
onChange={(e) => setClaudeBaseUrl(e.target.value)}
@@ -56,8 +62,9 @@ export function ClaudeSettingsDialog({
<div className="mt-1 text-[11px] text-muted-foreground">{currentLocale === "zh" ? "留空使用默认端点" : "Leave empty for default endpoint"}</div>
</div>
<div>
<label className="mb-1.5 block text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">Model</label>
<label htmlFor="claude-model" className="mb-1.5 block text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">Model</label>
<input
id="claude-model"
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 w-full rounded-lg border border-border bg-card px-3 py-2.5 text-[13px] text-foreground placeholder:text-muted-foreground"
value={claudeModel}
onChange={(e) => setClaudeModel(e.target.value)}
@@ -162,7 +162,7 @@ export function CompanyStep({
</div>
{error ? (
<div className="mt-5 rounded-lg border border-[color:var(--destructive)] bg-red-100 dark:bg-red-900/50 px-4 py-3 text-[13px] text-destructive">
<div className="mt-5 rounded-lg border border-[color:var(--destructive)] bg-destructive-muted px-4 py-3 text-[13px] text-destructive">
{error}
</div>
) : null}
@@ -39,11 +39,11 @@ export function ConnectorReadiness({
{isChecking ? (
<span className="mt-0.5 h-4 w-4 shrink-0 animate-spin rounded-full border-2 border-[color:var(--muted-foreground)] border-t-[color:var(--primary)]" />
) : isReady ? (
<CheckCircle size={16} weight="fill" className="shrink-0 text-green-600 dark:text-green-400" />
<CheckCircle size={16} weight="fill" className="shrink-0 text-success" />
) : isMissing ? (
<XCircle size={16} weight="fill" className="shrink-0 text-muted-foreground" />
) : (
<WarningCircle size={16} weight="fill" className="shrink-0 text-yellow-600 dark:text-yellow-400" />
<WarningCircle size={16} weight="fill" className="shrink-0 text-warning" />
)}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
@@ -75,7 +75,7 @@ export function ConnectorReadiness({
})}
</div>
{!hasRunnableConnector ? (
<div className="mt-4 rounded-lg border border-[color:var(--destructive)] bg-yellow-100 dark:bg-yellow-900/50 px-4 py-3 text-[12px] text-yellow-600 dark:text-yellow-400">
<div className="mt-4 rounded-lg border border-[color:var(--destructive)] bg-warning-muted px-4 py-3 text-[12px] text-warning">
{t("onboarding.connectorWarning")}
</div>
) : null}
@@ -28,7 +28,7 @@ export function LaunchStep({
transition={{ duration: 0.36, ease: [0.16, 1, 0.3, 1] }}
className="bg-card border border-border shadow-sm w-full rounded-lg p-7"
>
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/50 text-green-600 dark:text-green-400">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-success-muted text-success">
<CheckCircle size={24} weight="fill" />
</div>
<h2 className="mt-5 text-[28px] tracking-[-0.03em] text-foreground">
@@ -72,7 +72,7 @@ export function LaunchStep({
</div>
{error ? (
<div className="mt-5 rounded-lg border border-[color:var(--destructive)] bg-red-100 dark:bg-red-900/50 px-4 py-3 text-[13px] text-destructive">
<div className="mt-5 rounded-lg border border-[color:var(--destructive)] bg-destructive-muted px-4 py-3 text-[13px] text-destructive">
{error}
</div>
) : null}
@@ -70,7 +70,7 @@ export function MissionStep({
</div>
{error ? (
<div className="mt-5 rounded-lg border border-[color:var(--destructive)] bg-red-100 dark:bg-red-900/50 px-4 py-3 text-[13px] text-destructive">
<div className="mt-5 rounded-lg border border-[color:var(--destructive)] bg-destructive-muted px-4 py-3 text-[13px] text-destructive">
{error}
</div>
) : null}
@@ -80,7 +80,7 @@ export function NodeCheckStep({
{checking ? (
<span className="h-4 w-4 animate-spin rounded-full border-2 border-muted-foreground border-t-primary" />
) : nodeStatus?.installed ? (
<CheckCircle size={18} weight="fill" className="shrink-0 text-green-600 dark:text-green-400" />
<CheckCircle size={18} weight="fill" className="shrink-0 text-success" />
) : nodeStatus ? (
<XCircle size={18} weight="fill" className="shrink-0 text-destructive" />
) : null}
@@ -88,7 +88,7 @@ export function NodeCheckStep({
{checking ? (
<span className="text-muted-foreground">{t("onboarding.providerChecking")}...</span>
) : nodeStatus?.installed ? (
<span className="font-medium text-green-600 dark:text-green-400">
<span className="font-medium text-success">
{t("onboarding.nodeInstalled").replace("{version}", nodeStatus.version ?? "")}
</span>
) : nodeStatus ? (
@@ -63,7 +63,7 @@ export function OnboardingHero({
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.42, ease: [0.16, 1, 0.3, 1] }}
className="relative z-10 mx-auto w-full max-w-[460px]"
className="relative z-10 mx-auto w-full max-w-[min(460px,90vw)]"
>
{/* Signature line */}
<motion.div
@@ -120,7 +120,7 @@ export function OnboardingHero({
<div
className={`flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[10px] font-medium transition-colors duration-300 ${
index < stepIndex
? "bg-green-100 dark:bg-green-900/50 text-green-600 dark:text-green-400"
? "bg-success-muted text-success"
: index === stepIndex
? "bg-accent text-primary"
: "bg-muted text-muted-foreground"
@@ -61,8 +61,8 @@ type ConnectorStatus = ProfileSnapshot["connectors"][number]["status"];
function StatusBadge({ status, t }: { status: ConnectorStatus; t: (key: string) => string }) {
if (status === "ready") {
return (
<span className="inline-flex items-center gap-1.5 rounded-full bg-green-100 px-2 py-0.5 text-[11px] font-medium text-green-700 dark:bg-green-900/40 dark:text-green-400">
<span className="h-1.5 w-1.5 rounded-full bg-green-600 dark:bg-green-400" />
<span className="inline-flex items-center gap-1.5 rounded-full bg-success-muted px-2 py-0.5 text-[11px] font-medium text-success">
<span className="h-1.5 w-1.5 rounded-full bg-success" />
{t("onboarding.providerReady")}
</span>
);
@@ -77,8 +77,8 @@ function StatusBadge({ status, t }: { status: ConnectorStatus; t: (key: string)
}
if (status === "auth_required") {
return (
<span className="inline-flex items-center gap-1.5 rounded-full bg-yellow-100 px-2 py-0.5 text-[11px] font-medium text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-400">
<span className="h-1.5 w-1.5 rounded-full bg-yellow-600 dark:bg-yellow-400" />
<span className="inline-flex items-center gap-1.5 rounded-full bg-warning-muted px-2 py-0.5 text-[11px] font-medium text-warning">
<span className="h-1.5 w-1.5 rounded-full bg-warning" />
{t("onboarding.providerAuthRequired")}
</span>
);
@@ -92,8 +92,8 @@ function StatusBadge({ status, t }: { status: ConnectorStatus; t: (key: string)
);
}
return (
<span className="inline-flex items-center gap-1.5 rounded-full bg-orange-100 px-2 py-0.5 text-[11px] font-medium text-orange-700 dark:bg-orange-900/40 dark:text-orange-400">
<span className="h-1.5 w-1.5 rounded-full bg-orange-600 dark:bg-orange-400" />
<span className="inline-flex items-center gap-1.5 rounded-full bg-caution-muted px-2 py-0.5 text-[11px] font-medium text-caution">
<span className="h-1.5 w-1.5 rounded-full bg-caution" />
{status.replaceAll("_", " ")}
</span>
);
@@ -611,7 +611,7 @@ export function ProvidersStep({
{/* Error message */}
{error ? (
<div className="mt-5 rounded-lg border border-[color:var(--destructive)] bg-red-100 px-4 py-3 text-[13px] text-destructive dark:bg-red-900/50">
<div className="mt-5 rounded-lg border border-[color:var(--destructive)] bg-destructive-muted px-4 py-3 text-[13px] text-destructive">
{error}
</div>
) : null}
+1 -1
View File
@@ -6,7 +6,7 @@ import { useReducedMotion } from "./ui"
const TONE_CONFIG = {
success: { icon: CheckCircle, bg: "var(--muted-foreground)", border: "var(--primary)", text: "var(--primary)" },
danger: { icon: XCircle, bg: "var(--destructive)", border: "var(--destructive)", text: "var(--destructive)" },
warn: { icon: Warning, bg: "var(--muted-foreground)", border: "var(--destructive)", text: "var(--destructive)" },
warn: { icon: Warning, bg: "var(--muted-foreground)", border: "var(--warning)", text: "var(--warning)" },
} as const
export default function ToastContainer() {
@@ -15,8 +15,8 @@ function DeleteConfirmDialog({ open, entityName, onConfirm, onCancel }: { open:
if (!open) return null
return (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={onCancel}>
<motion.div initial={{ scale: 0.95, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} exit={{ scale: 0.95, opacity: 0 }} transition={{ duration: 0.15 }} className="w-full max-w-[380px] rounded-lg border bg-card p-6 shadow-xl" style={{ borderColor: LINE }} onClick={(e) => e.stopPropagation()}>
<div className="mb-1 flex items-center gap-2"><div className="flex h-8 w-8 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/50"><Trash size={16} className="text-destructive" /></div><h3 className="text-[15px] font-bold text-foreground">Delete</h3></div>
<motion.div initial={{ scale: 0.95, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} exit={{ scale: 0.95, opacity: 0 }} transition={{ duration: 0.15 }} className="w-full max-w-[min(380px,90vw)] rounded-lg border bg-card p-6 shadow-xl" style={{ borderColor: LINE }} onClick={(e) => e.stopPropagation()}>
<div className="mb-1 flex items-center gap-2"><div className="flex h-8 w-8 items-center justify-center rounded-full bg-destructive-muted"><Trash size={16} className="text-destructive" /></div><h3 className="text-[15px] font-bold text-foreground">Delete</h3></div>
<p className="mt-2 text-[13px] leading-relaxed text-muted-foreground">Are you sure you want to delete <strong className="text-foreground">{entityName}</strong>? This action cannot be undone.</p>
<div className="mt-5 flex justify-end gap-3"><button type="button" onClick={onCancel} className="rounded-lg px-4 py-2 text-[12px] font-medium text-muted-foreground transition hover:bg-muted">Cancel</button><ActionButton label="Delete" onClick={onConfirm} icon={Trash} tone="danger" /></div>
</motion.div>
@@ -41,15 +41,15 @@ function RulesPanel({ rules, companyId, onRefresh }: { rules: AutomationRuleReco
useEffect(() => { void window.agentCompany.getAutomationLog(companyId).then((r) => { if (r.ok) setActLog(r.data) }) }, [companyId, rules])
const filtered = useMemo(() => filterStatus === "all" ? rules : rules.filter((r) => r.status === filterStatus), [rules, filterStatus])
const counts = useMemo(() => ({ total: rules.length, active: rules.filter((r) => r.status === "active").length, paused: rules.filter((r) => r.status === "paused").length, error: rules.filter((r) => r.status === "error").length }), [rules])
const counts = useMemo(() => { let active = 0, paused = 0, error = 0; for (const r of rules) { if (r.status === "active") active++; else if (r.status === "paused") paused++; else if (r.status === "error") error++; } return { total: rules.length, active, paused, error } }, [rules])
const handleToggle = useCallback(async (rule: AutomationRuleRecord) => { setActionError(null); try { const r = await window.agentCompany.toggleAutomationRule({ id: rule.id, companyId, status: (rule.status === "active" ? "paused" : "active") as "active"|"paused" }); if (!r.ok) { setActionError(r.error.message); return }; onRefresh() } catch (e) { setActionError(e instanceof Error ? e.message : "Failed") } }, [companyId, onRefresh])
const handleDelete = useCallback(async () => { if (!delTarget) return; setActionError(null); try { const r = await window.agentCompany.deleteAutomationRule({ id: delTarget.id, companyId }); if (!r.ok) { setActionError(r.error.message); setDelTarget(null); return }; setDelTarget(null); onRefresh() } catch (e) { setActionError(e instanceof Error ? e.message : "Failed"); setDelTarget(null) } }, [delTarget, companyId, onRefresh])
return (
<div className="space-y-5">
{actionError ? <div className="flex items-center justify-between rounded-lg bg-red-100 dark:bg-red-900/50 px-3.5 py-2.5 text-[12px] font-medium text-destructive"><span>{actionError}</span><button type="button" onClick={() => setActionError(null)} className="ml-2 rounded p-0.5 hover:bg-destructive hover:text-primary-foreground"><X size={12} /></button></div> : null}
{actionError ? <div className="flex items-center justify-between rounded-lg bg-destructive-muted px-3.5 py-2.5 text-[12px] font-medium text-destructive"><span>{actionError}</span><button type="button" onClick={() => setActionError(null)} className="ml-2 rounded p-0.5 hover:bg-destructive hover:text-primary-foreground"><X size={12} /></button></div> : null}
<div className="flex items-center justify-between"><div className="flex items-center gap-3"><div className="flex h-10 w-10 items-center justify-center rounded-lg" style={{ backgroundColor: `${ACCENT}15`, color: ACCENT }}><Lightning size={20} weight="fill" /></div><div><h2 className="text-[16px] font-bold text-foreground">Automation Rules</h2><p className="text-[12px] text-muted-foreground">{counts.active} active, {counts.paused} paused{counts.error > 0 ? `, ${counts.error} errored` : ""}</p></div></div><ActionButton label="New Rule" onClick={() => setShowCreate(true)} icon={Plus} tone="accent" /></div>
{rules.length > 0 ? <div className="grid grid-cols-3 gap-2">{[{ l: "Total Rules", v: counts.total, c: undefined }, { l: "Total Executions", v: rules.reduce((s, r) => s + r.executionCount, 0), c: ACCENT }, { l: "Recent Activity", v: actLog.length, c: "var(--primary)" }].map((s) => <div key={s.l} className="rounded-lg border bg-card px-4 py-3.5" style={{ borderColor: LINE }}><div className="text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{s.l}</div><div className="text-[26px] font-bold tabular-nums" style={s.c ? { color: s.c } : undefined}>{s.v}</div></div>)}</div> : null}
{rules.length > 0 ? <div className="grid grid-cols-1 gap-2 sm:grid-cols-3">{[{ l: "Total Rules", v: counts.total, c: undefined }, { l: "Total Executions", v: rules.reduce((s, r) => s + r.executionCount, 0), c: ACCENT }, { l: "Recent Activity", v: actLog.length, c: "var(--primary)" }].map((s) => <div key={s.l} className="rounded-lg border bg-card px-4 py-3.5" style={{ borderColor: LINE }}><div className="text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{s.l}</div><div className="text-[26px] font-bold tabular-nums" style={s.c ? { color: s.c } : undefined}>{s.v}</div></div>)}</div> : null}
<div className="flex gap-1 rounded-lg bg-muted p-1"><button type="button" onClick={() => setActiveTab("rules")} className={`flex-1 rounded-lg px-3 py-1.5 text-[12px] font-medium transition ${activeTab === "rules" ? "bg-card text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}>Rules ({rules.length})</button><button type="button" onClick={() => setActiveTab("activity")} className={`flex-1 rounded-lg px-3 py-1.5 text-[12px] font-medium transition ${activeTab === "activity" ? "bg-card text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}>Activity Log ({actLog.length})</button></div>
{activeTab === "rules" ? <>
{rules.length > 0 ? <div className="flex flex-wrap items-center gap-1.5">{(["all","active","paused","error"] as const).map((s) => <button key={s} type="button" onClick={() => setFilterStatus(s)} className={`rounded-full px-3 py-1.5 text-[11px] font-medium transition ${filterStatus === s ? "text-primary-foreground" : "bg-muted text-muted-foreground hover:bg-[color:var(--border)]"}`} style={filterStatus === s ? { backgroundColor: ACCENT } : undefined}>{s === "all" ? "All" : s.charAt(0).toUpperCase() + s.slice(1)}</button>)}</div> : null}
@@ -71,7 +71,7 @@ function WorkflowsPanel({ workflows, companyId, onRefresh }: { workflows: Workfl
const counts = useMemo(() => ({ total: workflows.length, active: workflows.filter((w) => w.status === "active").length, draft: workflows.filter((w) => w.status === "draft").length }), [workflows])
return (
<div className="space-y-5">
{actionError ? <div className="flex items-center justify-between rounded-lg bg-red-100 dark:bg-red-900/50 px-3.5 py-2.5 text-[12px] font-medium text-destructive"><span>{actionError}</span><button type="button" onClick={() => setActionError(null)} className="ml-2 rounded p-0.5 hover:bg-destructive hover:text-primary-foreground"><X size={12} /></button></div> : null}
{actionError ? <div className="flex items-center justify-between rounded-lg bg-destructive-muted px-3.5 py-2.5 text-[12px] font-medium text-destructive"><span>{actionError}</span><button type="button" onClick={() => setActionError(null)} className="ml-2 rounded p-0.5 hover:bg-destructive hover:text-primary-foreground"><X size={12} /></button></div> : null}
<div className="flex items-center justify-between"><div className="flex items-center gap-3"><div className="flex h-10 w-10 items-center justify-center rounded-lg" style={{ backgroundColor: `${ACCENT}12`, color: ACCENT }}><ArrowsClockwise size={20} weight="fill" /></div><div><h2 className="text-[16px] font-bold text-foreground">Workflow Pipelines</h2><p className="text-[12px] text-muted-foreground">{counts.active} active, {counts.draft} draft{counts.total > counts.active + counts.draft ? `, ${counts.total - counts.active - counts.draft} other` : ""}</p></div></div><ActionButton label="New Workflow" onClick={() => setShowCreate(true)} icon={Plus} tone="accent" /></div>
{workflows.length === 0 ? <div className="flex flex-col items-center justify-center rounded-lg border border-dashed py-16 text-center" style={{ borderColor: LINE }}><ArrowsClockwise size={36} className="mb-3 text-muted-foreground" /><div className="text-[14px] font-medium text-muted-foreground">No workflow pipelines</div><div className="mx-auto mt-1 max-w-[42ch] text-[12px] leading-relaxed text-muted-foreground">Workflows chain actions into multi-step pipelines.</div><div className="mt-4"><ActionButton label="Create First Workflow" onClick={() => setShowCreate(true)} icon={ArrowsClockwise} tone="accent" /></div></div>
: <div className="space-y-2"><AnimatePresence>{workflows.map((wf) => <WorkflowCard key={wf.id} workflow={wf} isRunning={runningId === wf.id} onDelete={() => setDelTarget(wf)} onEdit={() => setEditWf(wf)} onRun={() => void handleRun(wf)} />)}</AnimatePresence></div>}
@@ -27,7 +27,7 @@ export function RuleCard({ rule, onToggle, onDelete, onEdit }: { rule: Automatio
</button>
<div className="flex shrink-0 items-center gap-1">
<button type="button" onClick={onToggle} aria-label={rule.status === "active" ? "Pause rule" : "Activate rule"} title={rule.status === "active" ? "Pause rule" : "Activate rule"} className="rounded-lg p-2 text-muted-foreground transition hover:bg-muted hover:text-foreground">{rule.status === "active" ? <Pause size={15} /> : <Play size={15} />}</button>
<button type="button" onClick={onDelete} aria-label="Delete rule" title="Delete rule" className="rounded-lg p-2 text-muted-foreground opacity-0 transition hover:bg-red-100 dark:bg-red-900/50 hover:text-destructive group-hover:opacity-100"><Trash size={15} /></button>
<button type="button" onClick={onDelete} aria-label="Delete rule" title="Delete rule" className="rounded-lg p-2 text-muted-foreground opacity-0 transition hover:bg-destructive-muted hover:text-destructive group-hover:opacity-100"><Trash size={15} /></button>
</div>
</div>
</motion.div>
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from "react"
import { motion } from "framer-motion"
import { Gear, Lightning, X } from "@phosphor-icons/react"
import type { AutomationAction, AutomationRuleRecord, AutomationTrigger, DepartmentId } from "@shared/types"
import { ActionButton, Select } from "../ui"
import { ActionButton, Select, useFocusTrap } from "../ui"
import { ACCENT, LINE, triggerConfig, actionConfig, departmentOptions, safeJsonStringify } from "./automation-config"
interface RuleFormState { name: string; description: string; trigger: AutomationTrigger; conditionsJson: string; action: AutomationAction; actionConfigJson: string; sourceDepartment: string; targetDepartment: string; priority: number }
@@ -20,18 +20,20 @@ export function RuleFormModal({ open, editingRule, companyId, onClose, onSaved }
setSaving(true); setError(null)
try { const result = await window.agentCompany.saveAutomationRule({ id: editingRule?.id ?? null, companyId, name: form.name.trim(), description: form.description.trim(), trigger: form.trigger, conditionsJson: form.conditionsJson, action: form.action, actionConfigJson: form.actionConfigJson, sourceDepartment: (form.sourceDepartment || null) as DepartmentId | null, targetDepartment: (form.targetDepartment || null) as DepartmentId | null, priority: form.priority, status: editingRule?.status ?? "active" }); if (!result.ok) { setError(result.error.message); return }; onSaved(); onClose() } catch (err) { setError(err instanceof Error ? err.message : "Failed to save rule") } finally { setSaving(false) }
}, [form, editingRule, companyId, onSaved, onClose])
const trapRef = useFocusTrap(open)
useEffect(() => { if (!open) return; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose() }; document.addEventListener("keydown", onKey); return () => document.removeEventListener("keydown", onKey) }, [open, onClose])
if (!open) return null
const triggerOptions = Object.entries(triggerConfig).map(([key, c]) => ({ value: key, label: c.label })); const actionOptions = Object.entries(actionConfig).map(([key, c]) => ({ value: key, label: c.label }))
const selectedTriggerInfo = triggerConfig[form.trigger]; const selectedActionInfo = actionConfig[form.action]
return (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={onClose}>
<motion.div initial={{ scale: 0.95, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} exit={{ scale: 0.95, opacity: 0 }} transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }} className="flex w-full max-w-[580px] flex-col rounded-lg border bg-card shadow-xl" style={{ borderColor: LINE, maxHeight: "85vh" }} onClick={(e) => e.stopPropagation()}>
<motion.div ref={trapRef} role="dialog" aria-modal="true" aria-label={isEditing ? "Edit Automation Rule" : "New Automation Rule"} initial={{ scale: 0.95, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} exit={{ scale: 0.95, opacity: 0 }} transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }} className="flex w-full max-w-[min(580px,90vw)] flex-col rounded-lg border bg-card shadow-xl" style={{ borderColor: LINE, maxHeight: "85vh" }} onClick={(e) => e.stopPropagation()}>
<div className="flex shrink-0 items-center justify-between border-b px-6 py-4" style={{ borderColor: LINE }}>
<div className="flex items-center gap-3"><div className="flex h-8 w-8 items-center justify-center rounded-lg" style={{ backgroundColor: `${ACCENT}15`, color: ACCENT }}><Lightning size={18} weight="fill" /></div><h2 className="text-[16px] font-bold text-foreground">{isEditing ? "Edit Automation Rule" : "New Automation Rule"}</h2></div>
<button type="button" onClick={onClose} className="rounded-lg p-1.5 text-muted-foreground transition hover:bg-muted hover:text-foreground" aria-label="Close"><X size={16} /></button>
</div>
<div className="overflow-y-auto px-6 py-5"><div className="space-y-5">
{error ? <div className="rounded-lg bg-red-100 dark:bg-red-900/50 px-3.5 py-2.5 text-[12px] font-medium text-destructive">{error}</div> : null}
{error ? <div className="rounded-lg bg-destructive-muted px-3.5 py-2.5 text-[12px] font-medium text-destructive">{error}</div> : null}
<div><label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">Rule Name</label><input type="text" value={form.name} onChange={(e) => updateField("name", e.target.value)} placeholder="e.g., Auto-assign blocked tasks" className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 w-full rounded-lg border bg-card px-3.5 py-2.5 text-[14px] text-foreground placeholder:text-muted-foreground" style={{ borderColor: LINE }} /></div>
<div><label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">Description</label><textarea value={form.description} onChange={(e) => updateField("description", e.target.value)} placeholder="What does this rule do?" rows={2} className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 w-full rounded-lg border bg-card px-3.5 py-2.5 text-[14px] text-foreground placeholder:text-muted-foreground" style={{ borderColor: LINE }} /></div>
<div><label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">Trigger</label><Select value={form.trigger} onChange={(v) => updateField("trigger", v as AutomationTrigger)} options={triggerOptions} />{selectedTriggerInfo ? <div className="mt-1.5 text-[11px] text-muted-foreground">{selectedTriggerInfo.description}</div> : null}</div>
@@ -39,7 +41,7 @@ export function RuleFormModal({ open, editingRule, companyId, onClose, onSaved }
<div><label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">Action</label><Select value={form.action} onChange={(v) => updateField("action", v as AutomationAction)} options={actionOptions} />{selectedActionInfo ? <div className="mt-1.5 text-[11px] text-muted-foreground">{selectedActionInfo.description}</div> : null}</div>
<div><label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">Action Configuration (JSON)</label><textarea value={form.actionConfigJson} onChange={(e) => updateField("actionConfigJson", e.target.value)} rows={3} spellCheck={false} className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 w-full rounded-lg border bg-card px-3.5 py-2.5 font-mono text-[12px] text-foreground" style={{ borderColor: LINE }} placeholder='{"targetAgentId": "..."}' /></div>
<div className="grid grid-cols-2 gap-3"><div><label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">Source Department</label><Select value={form.sourceDepartment} onChange={(v) => updateField("sourceDepartment", v)} options={departmentOptions} /></div><div><label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">Target Department</label><Select value={form.targetDepartment} onChange={(v) => updateField("targetDepartment", v)} options={departmentOptions} /></div></div>
<div><div className="mb-1.5 flex items-center justify-between"><label className="text-[12px] font-medium text-muted-foreground">Priority</label><span className="text-[12px] font-semibold" style={{ color: ACCENT }}>{form.priority}</span></div><input type="range" min={0} max={100} value={form.priority} onChange={(e) => updateField("priority", parseInt(e.target.value, 10))} className="w-full accent-[#d97745]" /><div className="mt-1 flex justify-between text-[10px] text-muted-foreground"><span>Low priority</span><span>High priority</span></div></div>
<div><div className="mb-1.5 flex items-center justify-between"><label className="text-[12px] font-medium text-muted-foreground">Priority</label><span className="text-[12px] font-semibold" style={{ color: ACCENT }}>{form.priority}</span></div><input type="range" min={0} max={100} value={form.priority} onChange={(e) => updateField("priority", parseInt(e.target.value, 10))} className="w-full accent-[color:var(--primary)]" /><div className="mt-1 flex justify-between text-[10px] text-muted-foreground"><span>Low priority</span><span>High priority</span></div></div>
</div></div>
<div className="flex shrink-0 justify-end gap-3 border-t px-6 py-4" style={{ borderColor: LINE }}>
<button type="button" onClick={onClose} className="rounded-lg px-4 py-2 text-[12px] font-medium text-muted-foreground transition hover:bg-muted hover:text-foreground">Cancel</button>
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from "react"
import { motion } from "framer-motion"
import { ArrowsClockwise, Gear, X } from "@phosphor-icons/react"
import type { AutomationTrigger, WorkflowPipelineRecord, WorkflowStatus } from "@shared/types"
import { ActionButton, Select } from "../ui"
import { ActionButton, Select, useFocusTrap } from "../ui"
import { ACCENT, LINE, triggerConfig, safeJsonStringify } from "./automation-config"
interface WorkflowFormState { name: string; description: string; triggerType: AutomationTrigger; triggerConfigJson: string; status: WorkflowStatus; stepsJson: string }
@@ -20,15 +20,17 @@ export function WorkflowFormModal({ open, editingWorkflow, companyId, onClose, o
setSaving(true); setError(null)
try { const result = await window.agentCompany.saveWorkflow({ id: editingWorkflow?.id ?? null, companyId, name: form.name.trim(), description: form.description.trim(), triggerType: form.triggerType, triggerConfigJson: form.triggerConfigJson, status: form.status, stepsJson: form.stepsJson }); if (!result.ok) { setError(result.error.message); return }; onSaved(); onClose() } catch (err) { setError(err instanceof Error ? err.message : "Failed to save workflow") } finally { setSaving(false) }
}, [form, editingWorkflow, companyId, onSaved, onClose])
const trapRef = useFocusTrap(open)
useEffect(() => { if (!open) return; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose() }; document.addEventListener("keydown", onKey); return () => document.removeEventListener("keydown", onKey) }, [open, onClose])
if (!open) return null
const triggerOptions = Object.entries(triggerConfig).map(([key, c]) => ({ value: key, label: c.label }))
const statusOptions = [{ value: "draft", label: "Draft" }, { value: "active", label: "Active" }, { value: "paused", label: "Paused" }]
return (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={onClose}>
<motion.div initial={{ scale: 0.95, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} exit={{ scale: 0.95, opacity: 0 }} transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }} className="flex w-full max-w-[540px] flex-col rounded-lg border bg-card shadow-xl" style={{ borderColor: LINE, maxHeight: "85vh" }} onClick={(e) => e.stopPropagation()}>
<motion.div ref={trapRef} role="dialog" aria-modal="true" aria-label={isEditing ? "Edit Workflow" : "New Workflow"} initial={{ scale: 0.95, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} exit={{ scale: 0.95, opacity: 0 }} transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }} className="flex w-full max-w-[min(540px,90vw)] flex-col rounded-lg border bg-card shadow-xl" style={{ borderColor: LINE, maxHeight: "85vh" }} onClick={(e) => e.stopPropagation()}>
<div className="flex shrink-0 items-center justify-between border-b px-6 py-4" style={{ borderColor: LINE }}><div className="flex items-center gap-3"><div className="flex h-8 w-8 items-center justify-center rounded-lg" style={{ backgroundColor: `${ACCENT}15`, color: ACCENT }}><ArrowsClockwise size={18} weight="fill" /></div><h2 className="text-[16px] font-bold text-foreground">{isEditing ? "Edit Workflow" : "New Workflow"}</h2></div><button type="button" onClick={onClose} className="rounded-lg p-1.5 text-muted-foreground transition hover:bg-muted hover:text-foreground" aria-label="Close"><X size={16} /></button></div>
<div className="overflow-y-auto px-6 py-5"><div className="space-y-5">
{error ? <div className="rounded-lg bg-red-100 dark:bg-red-900/50 px-3.5 py-2.5 text-[12px] font-medium text-destructive">{error}</div> : null}
{error ? <div className="rounded-lg bg-destructive-muted px-3.5 py-2.5 text-[12px] font-medium text-destructive">{error}</div> : null}
<div><label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">Workflow Name</label><input type="text" value={form.name} onChange={(e) => updateField("name", e.target.value)} placeholder="e.g., Bug Fix Pipeline" className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 w-full rounded-lg border bg-card px-3.5 py-2.5 text-[14px] text-foreground placeholder:text-muted-foreground" style={{ borderColor: LINE }} /></div>
<div><label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">Description</label><textarea value={form.description} onChange={(e) => updateField("description", e.target.value)} placeholder="Describe this workflow pipeline" rows={2} className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 w-full rounded-lg border bg-card px-3.5 py-2.5 text-[14px] text-foreground placeholder:text-muted-foreground" style={{ borderColor: LINE }} /></div>
<div className="grid grid-cols-2 gap-3"><div><label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">Trigger</label><Select value={form.triggerType} onChange={(v) => updateField("triggerType", v as AutomationTrigger)} options={triggerOptions} /></div><div><label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">Status</label><Select value={form.status} onChange={(v) => updateField("status", v as WorkflowStatus)} options={statusOptions} /></div></div>
@@ -19,7 +19,7 @@ function StepTimeline({ steps }: { steps: WorkflowStep[] }) {
<div className="absolute -left-5 top-1.5 z-10 h-[10px] w-[10px] rounded-full border-2" style={{ backgroundColor: colors.bg, borderColor: colors.border }} />
<div className="flex-1 rounded-lg border px-3 py-2.5" style={{ borderColor: step.status === "running" ? ACCENT : LINE }}>
<div className="flex items-center justify-between"><div className="flex items-center gap-2"><span className="text-[11px] font-bold" style={{ color: colors.text }}>{idx + 1}</span><span className="text-[12px] font-medium text-foreground">{step.name}</span></div><span className="rounded-full px-2 py-0.5 text-[9px] font-bold uppercase tracking-[0.1em]" style={{ backgroundColor: colors.bg, color: colors.text }}>{step.status}</span></div>
<div className="mt-1 flex items-center gap-2 text-[10px] text-muted-foreground"><span>{info?.label ?? step.action}</span>{step.assigneeDepartment ? <><span className="h-0.5 w-0.5 rounded-full bg-[color:var(--muted-foreground)]" /><span>{step.assigneeDepartment}</span></> : null}{step.requiresApproval ? <><span className="h-0.5 w-0.5 rounded-full bg-[color:var(--muted-foreground)]" /><span className="text-yellow-600 dark:text-yellow-400">Requires approval</span></> : null}{step.timeoutSec > 0 ? <><span className="h-0.5 w-0.5 rounded-full bg-[color:var(--muted-foreground)]" /><span>{step.timeoutSec}s timeout</span></> : null}</div>
<div className="mt-1 flex items-center gap-2 text-[10px] text-muted-foreground"><span>{info?.label ?? step.action}</span>{step.assigneeDepartment ? <><span className="h-0.5 w-0.5 rounded-full bg-[color:var(--muted-foreground)]" /><span>{step.assigneeDepartment}</span></> : null}{step.requiresApproval ? <><span className="h-0.5 w-0.5 rounded-full bg-[color:var(--muted-foreground)]" /><span className="text-warning">Requires approval</span></> : null}{step.timeoutSec > 0 ? <><span className="h-0.5 w-0.5 rounded-full bg-[color:var(--muted-foreground)]" /><span>{step.timeoutSec}s timeout</span></> : null}</div>
</div>
</div>
)
@@ -44,9 +44,9 @@ function WorkflowCard({ workflow, isRunning, onDelete, onEdit, onRun }: { workfl
</button>
<div className="flex shrink-0 items-center gap-1">
<motion.div animate={{ rotate: expanded ? 90 : 0 }} transition={{ duration: 0.15 }}><button type="button" onClick={() => setExpanded((p) => !p)} aria-label={expanded ? "Collapse" : "Expand"} className="rounded-lg p-2 text-muted-foreground transition hover:bg-muted hover:text-foreground"><CaretRight size={14} /></button></motion.div>
<button type="button" onClick={onRun} aria-label="Run workflow" disabled={isRunning} className="rounded-lg p-2 text-muted-foreground transition hover:bg-green-100 dark:bg-green-900/50 hover:text-green-600 dark:text-green-400 disabled:pointer-events-none disabled:opacity-50">{isRunning ? <ArrowsClockwise size={15} className="animate-spin" /> : <Play size={15} />}</button>
<button type="button" onClick={onRun} aria-label="Run workflow" disabled={isRunning} className="rounded-lg p-2 text-muted-foreground transition hover:bg-success-muted hover:text-success disabled:pointer-events-none disabled:opacity-50">{isRunning ? <ArrowsClockwise size={15} className="animate-spin" /> : <Play size={15} />}</button>
<button type="button" onClick={onEdit} aria-label="Edit workflow" className="rounded-lg p-2 text-muted-foreground transition hover:bg-muted hover:text-foreground"><Gear size={15} /></button>
<button type="button" onClick={onDelete} aria-label="Delete workflow" className="rounded-lg p-2 text-muted-foreground opacity-0 transition hover:bg-red-100 dark:bg-red-900/50 hover:text-destructive group-hover:opacity-100"><Trash size={15} /></button>
<button type="button" onClick={onDelete} aria-label="Delete workflow" className="rounded-lg p-2 text-muted-foreground opacity-0 transition hover:bg-destructive-muted hover:text-destructive group-hover:opacity-100"><Trash size={15} /></button>
</div>
</div>
<AnimatePresence>
@@ -9,7 +9,7 @@ import { useT } from "../../i18n"
function SendButton({ sending, disabled, onClick, label }: { sending: boolean; disabled: boolean; onClick: () => void; label: string }) {
return (
<button type="button" onClick={onClick} disabled={disabled || sending} className="flex h-[40px] w-[40px] shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground transition hover:brightness-90 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none" aria-label={label}>
<button type="button" onClick={onClick} disabled={disabled || sending} className="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground transition hover:brightness-90 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none" aria-label={label}>
{sending ? <svg className="h-4 w-4 animate-spin" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="2" opacity="0.3" /><path d="M14 8a6 6 0 0 0-6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" /></svg> : <PaperPlaneTilt size={18} weight="fill" />}
</button>
)
@@ -66,10 +66,10 @@ export function MessageComposer({ agents, companyId, channel, channelTargetId, d
<select value={fromAgentId} onChange={(e) => setFromAgentId(e.target.value)} className="min-w-0 rounded-lg border border-border bg-card px-2 py-1 text-[11px] font-medium text-foreground">
{agents.map((a) => <option key={a.id} value={a.id}>{a.name} ({a.role})</option>)}
</select>
{!replyTo ? <input type="text" value={subject} onChange={(e) => setSubject(e.target.value)} placeholder="Subject (optional)" className="min-w-0 flex-1 rounded-lg border border-border bg-card px-2.5 py-1 text-[11px] text-foreground placeholder:text-muted-foreground" /> : null}
{!replyTo ? <input type="text" value={subject} onChange={(e) => setSubject(e.target.value)} placeholder="Subject (optional)" aria-label="Message subject" className="min-w-0 flex-1 rounded-lg border border-border bg-card px-2.5 py-1 text-[11px] text-foreground placeholder:text-muted-foreground" /> : null}
</div>
<div className="flex items-end gap-2">
<textarea ref={textareaRef} value={body} onChange={(e) => setBody(e.target.value)} onKeyDown={handleKeyDown} placeholder="Type a message..." rows={2} className="min-h-[40px] min-w-0 flex-1 resize-none rounded-lg border border-border bg-muted px-3 py-2 text-[13px] text-foreground placeholder:text-muted-foreground focus:border-transparent focus:outline-none focus:ring-2" style={{ focusRingColor: "var(--primary)" } as React.CSSProperties} />
<textarea ref={textareaRef} value={body} onChange={(e) => setBody(e.target.value)} onKeyDown={handleKeyDown} placeholder="Type a message..." aria-label="Message body" rows={2} className="min-h-[40px] min-w-0 flex-1 resize-none rounded-lg border border-border bg-muted px-3 py-2 text-[13px] text-foreground placeholder:text-muted-foreground focus:border-transparent focus:outline-none focus:ring-2" style={{ focusRingColor: "var(--primary)" } as React.CSSProperties} />
<SendButton sending={sending} disabled={!body.trim() || !fromAgentId || (channel === "direct" && !directRecipientId)} onClick={() => void handleSend()} label="Send message" />
</div>
<div className="mt-1 text-[11px] text-muted-foreground">Press {navigator.platform.includes("Mac") ? "Cmd" : "Ctrl"}+Enter to send</div>
@@ -87,7 +87,7 @@ export function TaskDiscussionComposer({ companyId, taskId, onSent }: { companyI
<div className="border-t border-border bg-card px-4 py-3">
{sendError ? <div className="mb-3"><InlineNotice message={sendError} /></div> : null}
<div className="flex items-end gap-2">
<textarea value={body} onChange={(e) => setBody(e.target.value)} placeholder="Reply in task discussion..." rows={2} className="min-h-[40px] min-w-0 flex-1 resize-none rounded-lg border border-border bg-muted px-3 py-2 text-[13px] text-foreground placeholder:text-muted-foreground focus:border-transparent focus:outline-none focus:ring-2" style={{ focusRingColor: "var(--primary)" } as React.CSSProperties} />
<textarea value={body} onChange={(e) => setBody(e.target.value)} placeholder="Reply in task discussion..." aria-label="Task discussion reply" rows={2} className="min-h-[40px] min-w-0 flex-1 resize-none rounded-lg border border-border bg-muted px-3 py-2 text-[13px] text-foreground placeholder:text-muted-foreground focus:border-transparent focus:outline-none focus:ring-2" style={{ focusRingColor: "var(--primary)" } as React.CSSProperties} />
<SendButton sending={sending} disabled={!body.trim()} onClick={() => void handleSend()} label="Send task comment" />
</div>
</div>
@@ -14,7 +14,7 @@ import { DEPARTMENT_COLORS } from "@shared/constants"
const priorityConfig: Record<string, { label: string; className: string }> = {
urgent: { label: "Urgent", className: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400" },
urgent: { label: "Urgent", className: "bg-destructive-muted text-destructive" },
normal: { label: "Normal", className: "bg-muted text-muted-foreground" },
low: { label: "Low", className: "bg-muted text-muted-foreground" },
}
@@ -23,7 +23,7 @@ function highlightMatch(text: string, query: string): React.ReactNode {
const parts = text.split(regex)
return parts.map((part, i) =>
regex.test(part)
? <mark key={i} className="rounded-[2px] bg-yellow-100 dark:bg-yellow-900/50 px-0.5 text-foreground">{part}</mark>
? <mark key={i} className="rounded-[2px] bg-warning-muted px-0.5 text-foreground">{part}</mark>
: part
)
}
+1 -1
View File
@@ -136,7 +136,7 @@ export function ConnectorEditor({ connector, onSaved }: { connector: ConnectorRe
<div className="grid gap-4">
{submitError ? <InlineNotice message={submitError} /> : null}
{needsLogin ? (
<div className="rounded-lg border border-[color:var(--destructive)] bg-yellow-100 dark:bg-yellow-900/50 px-4 py-4 text-[12px] text-yellow-600 dark:text-yellow-400">
<div className="rounded-lg border border-[color:var(--destructive)] bg-warning-muted px-4 py-4 text-[12px] text-warning">
<div className="text-[11px] font-semibold uppercase tracking-[0.08em]">{authActionCopy.title}</div>
<div className="mt-1.5 leading-relaxed">{authActionCopy.message}</div>
<div className="mt-3">
@@ -37,11 +37,13 @@ export function ActivityFeed({ activity, agents, limit = 15 }: { activity: Activ
);
}
const agentMap = useMemo(() => new Map(agents.map((a) => [a.id, a])), [agents]);
return (
<div className="space-y-0.5">
{recentActivity.map((item, index) => {
const Icon = actionIcons[item.action] ?? Lightning;
const agent = agents.find((a) => a.id === item.actor);
const agent = agentMap.get(item.actor);
const actorName = agent?.name ?? (item.actor === "board" ? "Board" : item.actor.slice(0, 8));
return (
@@ -79,8 +79,8 @@ export function BudgetAlertBanner({ agents, onNavigate, companyId }: BudgetAlert
{incidents.map((incident) => {
const isHardStop = incident.thresholdType === "hard_stop";
const borderColor = isHardStop ? "border-[color:var(--destructive)]" : "border-[color:var(--destructive)]";
const bgColor = isHardStop ? "bg-red-100 dark:bg-red-900/50" : "bg-yellow-100 dark:bg-yellow-900/50";
const textColor = isHardStop ? "text-destructive" : "text-yellow-600 dark:text-yellow-400";
const bgColor = isHardStop ? "bg-destructive-muted" : "bg-warning-muted";
const textColor = isHardStop ? "text-destructive" : "text-warning";
const pct = incident.amountLimit > 0 ? Math.round((incident.amountObserved / incident.amountLimit) * 100) : 0;
return (
@@ -132,14 +132,14 @@ export function BudgetAlertBanner({ agents, onNavigate, companyId }: BudgetAlert
<div
role="alert"
aria-live="assertive"
className="rounded-lg border border-[color:var(--destructive)] border-opacity-30 bg-yellow-100 dark:bg-yellow-900/50 px-4 py-3"
className="rounded-lg border border-[color:var(--destructive)] border-opacity-30 bg-warning-muted px-4 py-3"
>
<div className="flex items-start gap-3">
<div className="mt-0.5 shrink-0">
<Warning size={18} weight="bold" className="text-yellow-600 dark:text-yellow-400" />
<Warning size={18} weight="bold" className="text-warning" />
</div>
<div className="min-w-0 flex-1">
<div className="text-[13px] font-semibold text-yellow-600 dark:text-yellow-400">
<div className="text-[13px] font-semibold text-warning">
Budget Alert
</div>
<div className="mt-0.5 text-[13px] text-muted-foreground">
@@ -152,7 +152,7 @@ export function BudgetAlertBanner({ agents, onNavigate, companyId }: BudgetAlert
className={`whitespace-nowrap text-[11px] font-[family-name:var(--font-mono)] ${
agent.pct >= 100
? "text-destructive"
: "text-yellow-600 dark:text-yellow-400"
: "text-warning"
}`}
style={{ fontVariantNumeric: "tabular-nums" }}
>
+24 -18
View File
@@ -84,7 +84,7 @@ export function Dashboard(props: DashboardProps) {
<h2 className="text-[28px] font-bold tracking-[-0.02em] text-foreground">{companyName}</h2>
<div className="mt-2 flex items-center gap-3 text-[13px] tracking-[0.005em] text-muted-foreground">
<span className="flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-green-600 dark:bg-green-400" />
<span className="h-1.5 w-1.5 rounded-full bg-success" />
{activeAgents.length} {activeAgents.length === 1 ? "agent" : "agents"} active
</span>
<span className="h-3 w-px bg-[color:var(--border)]" />
@@ -103,7 +103,7 @@ export function Dashboard(props: DashboardProps) {
<BudgetAlertBanner agents={agents} onNavigate={onNavigate} companyId={companyId} />
{/* Key Metrics — 4 cards */}
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-4">
<MetricCard
label={t("dash.activeTasks")}
value={activeTasks.length}
@@ -142,8 +142,8 @@ export function Dashboard(props: DashboardProps) {
{/* Needs Attention — moved to top, only shows when items exist */}
{hasAttentionItems ? (
<section>
<div className="mb-3 flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-yellow-600 dark:text-yellow-400">
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-yellow-600 dark:bg-yellow-400" />
<div className="mb-3 flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-warning">
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-warning" />
{t("dash.needsAttentionSection")}
</div>
<div className="space-y-1.5">
@@ -157,9 +157,9 @@ export function Dashboard(props: DashboardProps) {
transition={{ duration: 0.22, delay: i * 0.04, ease: [0.16, 1, 0.3, 1] }}
type="button"
onClick={() => onNavigate("approvals", approval.id)}
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 flex w-full items-center gap-3 rounded-lg border border-[color:var(--destructive)] border-opacity-20 bg-yellow-100 dark:bg-yellow-900/50 px-4 py-3 text-left transition hover:border-opacity-40"
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 flex w-full items-center gap-3 rounded-lg border border-[color:var(--destructive)] border-opacity-20 bg-warning-muted px-4 py-3 text-left transition hover:border-opacity-40"
>
<ShieldCheck size={18} className="shrink-0 text-yellow-600 dark:text-yellow-400" />
<ShieldCheck size={18} className="shrink-0 text-warning" />
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-medium text-foreground">{approval.payloadSummary}</div>
<div className="mt-0.5 flex items-center gap-2 text-[11px] text-muted-foreground">
@@ -171,14 +171,17 @@ export function Dashboard(props: DashboardProps) {
</motion.button>
);
})}
{failedRuns.slice(0, 3).map((run) => {
{failedRuns.slice(0, 3).map((run, i) => {
const agent = agentMap.get(run.agentId);
return (
<button
<motion.button
key={run.id}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.22, delay: (pendingApprovals.length + i) * 0.04, ease: [0.16, 1, 0.3, 1] }}
type="button"
onClick={() => onNavigate("runs", run.id)}
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 flex w-full items-center gap-3 rounded-lg border border-[color:var(--destructive)] border-opacity-20 bg-red-100 dark:bg-red-900/50 px-4 py-3 text-left transition hover:border-opacity-40"
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 flex w-full items-center gap-3 rounded-lg border border-[color:var(--destructive)] border-opacity-20 bg-destructive-muted px-4 py-3 text-left transition hover:border-opacity-40"
>
<XCircle size={18} className="shrink-0 text-destructive" />
<div className="min-w-0 flex-1">
@@ -188,19 +191,22 @@ export function Dashboard(props: DashboardProps) {
</div>
</div>
<ArrowRight size={14} className="shrink-0 text-muted-foreground" />
</button>
</motion.button>
);
})}
{urgentMessages.slice(0, 3).map((msg) => {
{urgentMessages.slice(0, 3).map((msg, i) => {
const sender = agentMap.get(msg.fromAgentId);
return (
<button
<motion.button
key={msg.id}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.22, delay: (pendingApprovals.length + failedRuns.length + i) * 0.04, ease: [0.16, 1, 0.3, 1] }}
type="button"
onClick={() => onNavigate("communication", msg.id)}
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 flex w-full items-center gap-3 rounded-lg border border-[color:var(--destructive)] border-opacity-20 bg-yellow-100 dark:bg-yellow-900/50 px-4 py-3 text-left transition hover:border-opacity-40"
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 flex w-full items-center gap-3 rounded-lg border border-[color:var(--destructive)] border-opacity-20 bg-warning-muted px-4 py-3 text-left transition hover:border-opacity-40"
>
<Warning size={18} className="shrink-0 text-yellow-600 dark:text-yellow-400" />
<Warning size={18} className="shrink-0 text-warning" />
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-medium text-foreground">{msg.subject}</div>
<div className="mt-0.5 text-[11px] text-muted-foreground">
@@ -208,7 +214,7 @@ export function Dashboard(props: DashboardProps) {
</div>
</div>
<ArrowRight size={14} className="shrink-0 text-muted-foreground" />
</button>
</motion.button>
);
})}
</div>
@@ -235,11 +241,11 @@ export function Dashboard(props: DashboardProps) {
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 flex w-full items-center gap-3 px-4 py-3 text-left transition hover:bg-muted"
>
<div className="relative shrink-0">
<Robot size={16} className="text-green-600 dark:text-green-400" />
<Robot size={16} className="text-success" />
{run.status === "running" ? (
<span className="absolute -right-0.5 -top-0.5 h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-600 dark:bg-green-400 opacity-40" style={{ willChange: "transform" }} />
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-green-600 dark:bg-green-400" />
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-success opacity-40" style={{ willChange: "transform" }} />
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-success" />
</span>
) : null}
</div>
@@ -11,10 +11,10 @@ interface KanbanProps {
const KANBAN_COLUMNS: Array<{ status: TaskRecord["status"]; label: string; tone: string; dotColor: string }> = [
{ status: "backlog", label: "Backlog", tone: "text-muted-foreground", dotColor: "bg-[color:var(--muted-foreground)]" },
{ status: "todo", label: "Todo", tone: "text-yellow-600 dark:text-yellow-400", dotColor: "bg-yellow-600 dark:bg-yellow-400" },
{ status: "todo", label: "Todo", tone: "text-warning", dotColor: "bg-warning" },
{ status: "in_progress", label: "In Progress", tone: "text-primary", dotColor: "bg-primary" },
{ status: "in_review", label: "In Review", tone: "text-yellow-600 dark:text-yellow-400", dotColor: "bg-yellow-600 dark:bg-yellow-400" },
{ status: "done", label: "Done", tone: "text-green-600 dark:text-green-400", dotColor: "bg-green-600 dark:bg-green-400" },
{ status: "in_review", label: "In Review", tone: "text-warning", dotColor: "bg-warning" },
{ status: "done", label: "Done", tone: "text-success", dotColor: "bg-success" },
{ status: "blocked", label: "Blocked", tone: "text-destructive", dotColor: "bg-destructive" },
];
@@ -57,7 +57,7 @@ export function KanbanBoard({ tasks, agents, runs, onSelectTask }: KanbanProps)
{KANBAN_COLUMNS.map((col) => {
const colTasks = tasksByStatus[col.status] ?? [];
return (
<div key={col.status} className="flex w-[220px] min-w-[180px] shrink-0 flex-col">
<div key={col.status} className="flex w-[220px] min-w-[160px] shrink-0 flex-col lg:w-[240px]">
<div className="mb-2 flex items-center gap-2 px-1">
<span className={`h-2.5 w-2.5 rounded-full ${col.dotColor}`} aria-hidden="true" />
<span className={`text-[11px] font-semibold uppercase tracking-[0.08em] ${col.tone}`}>{col.label}</span>
@@ -88,14 +88,14 @@ export function KanbanBoard({ tasks, agents, runs, onSelectTask }: KanbanProps)
<span className="text-[10px] italic text-muted-foreground">Unassigned</span>
)}
{isRunning ? (
<span className="flex items-center gap-1 whitespace-nowrap text-[10px] text-green-600 dark:text-green-400">
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-green-600 dark:bg-green-400" aria-hidden="true" />
<span className="flex items-center gap-1 whitespace-nowrap text-[10px] text-success">
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-success" aria-hidden="true" />
Running
</span>
) : null}
</div>
{task.priority === "critical" || task.priority === "high" ? (
<div className={`mt-1 text-[9px] font-bold uppercase tracking-[0.12em] ${task.priority === "critical" ? "text-destructive" : "text-yellow-600 dark:text-yellow-400"}`}>
<div className={`mt-1 text-[9px] font-bold uppercase tracking-[0.12em] ${task.priority === "critical" ? "text-destructive" : "text-warning"}`}>
{task.priority}
</div>
) : null}
@@ -17,23 +17,23 @@ export function MetricCard({
}) {
const toneMap = {
default: "text-foreground",
success: "text-green-600 dark:text-green-400",
warn: "text-yellow-600 dark:text-yellow-400",
success: "text-success",
warn: "text-warning",
danger: "text-destructive",
accent: "text-primary",
};
const bgMap = {
default: "bg-muted",
success: "bg-green-100 dark:bg-green-900/50",
warn: "bg-yellow-100 dark:bg-yellow-900/50",
danger: "bg-red-100 dark:bg-red-900/50",
success: "bg-success-muted",
warn: "bg-warning-muted",
danger: "bg-destructive-muted",
accent: "bg-accent",
};
const borderMap = {
default: "border-l-transparent",
success: "border-l-[color:var(--primary)]",
warn: "border-l-[color:var(--destructive)]",
success: "border-l-[color:var(--success)]",
warn: "border-l-[color:var(--warning)]",
danger: "border-l-[color:var(--destructive)]",
accent: "border-l-[color:var(--primary)]",
};
@@ -42,7 +42,7 @@ export function MetricCard({
<button
type="button"
onClick={onClick}
className={`focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 flex items-center gap-4 rounded-lg border border-l-[3px] border-border bg-card px-4 py-4 text-left transition duration-150 hover:border-border hover:shadow-sm ${borderMap[tone]}`}
className={`focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 flex items-center gap-4 rounded-lg border border-l-[3px] border-border bg-card px-4 py-4 text-left transition duration-200 hover:shadow-md hover:-translate-y-0.5 ${borderMap[tone]}`}
>
<div className={`flex h-12 w-12 shrink-0 items-center justify-center rounded-lg ${bgMap[tone]}`}>
<Icon size={24} weight="fill" className={toneMap[tone]} />
@@ -41,9 +41,9 @@ export function RecoveryBanner() {
const handleDismissAll = () => setDismissed(true);
return (
<div className="bg-yellow-100 dark:bg-yellow-900/50 border border-[color:var(--destructive)]/30 rounded-lg px-4 py-4 mb-4">
<div className="bg-warning-muted border border-[color:var(--destructive)]/30 rounded-lg px-4 py-4 mb-4">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2 text-yellow-600 dark:text-yellow-400 font-medium">
<div className="flex items-center gap-2 text-warning font-medium">
<Warning size={18} weight="bold" />
<span>{runs.length} interrupted task(s) detected from previous session</span>
</div>
@@ -61,7 +61,7 @@ export function RecoveryBanner() {
<div className="flex gap-2">
<button
onClick={() => handleRetry(run)}
className="flex items-center gap-1 text-green-600 dark:text-green-400 hover:text-green-600 dark:text-green-400 text-xs transition-colors"
className="flex items-center gap-1 text-success hover:text-success text-xs transition-colors"
>
<ArrowClockwise size={14} /> Retry
</button>
@@ -17,8 +17,8 @@ interface BudgetRow {
function getBarColor(pct: number): string {
if (pct >= 100) return "bg-destructive";
if (pct >= 80) return "bg-yellow-600 dark:bg-yellow-400";
return "bg-green-600 dark:bg-green-400";
if (pct >= 80) return "bg-warning";
return "bg-success";
}
function scopeLabel(scopeType: string): string {
@@ -119,7 +119,7 @@ export function BudgetUtilization({ companyId, agents }: BudgetUtilizationProps)
/>
{/* 80% warning marker */}
<div
className="absolute top-0 h-full w-px bg-yellow-600 dark:bg-yellow-400 opacity-50"
className="absolute top-0 h-full w-px bg-warning opacity-50"
style={{ left: "80%" }}
/>
</div>
@@ -44,7 +44,7 @@ export function ActivityList({ activity }: { activity: ActivityRecord[] }) {
<div className="flex items-center gap-3">
<div className="relative flex-1">
<MagnifyingGlass size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
<input type="text" className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 w-full rounded-lg border border-border bg-card py-2.5 pl-8 pr-3.5 text-[13px] text-foreground placeholder:text-muted-foreground" value={search} onChange={(e) => setSearch(e.target.value)} placeholder={t("exec.searchActivity")} />
<input type="text" aria-label={t("exec.searchActivity")} className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 w-full rounded-lg border border-border bg-card py-2.5 pl-8 pr-3.5 text-[13px] text-foreground placeholder:text-muted-foreground" value={search} onChange={(e) => setSearch(e.target.value)} placeholder={t("exec.searchActivity")} />
</div>
<Select value={entityFilter} onChange={setEntityFilter} options={ENTITY_TYPE_OPTIONS.map((v) => ({ value: v, label: v === "all" ? t("exec.allTypes") : v }))} />
</div>
@@ -12,8 +12,8 @@ import { useAppStore } from "../../state/store"
function getTypeBadgeClass(type: string): string {
if (type === "hire_agent") return "bg-accent text-primary"
if (type === "approve_ceo_strategy") return "bg-yellow-100 dark:bg-yellow-900/50 text-yellow-600 dark:text-yellow-400"
if (type === "secret_access") return "bg-red-100 dark:bg-red-900/50 text-destructive"
if (type === "approve_ceo_strategy") return "bg-warning-muted text-warning"
if (type === "secret_access") return "bg-destructive-muted text-destructive"
if (type === "social_post") return "bg-accent text-primary"
if (type === "deliverable_review") return "bg-accent text-primary"
return "bg-muted text-muted-foreground"
@@ -113,7 +113,7 @@ export function ApprovalInspector({ approval, agents, tasks, documents, companyI
<div><span className="text-muted-foreground">Budget:</span> <span className="font-mono text-foreground">{formatMoney(proposedAgent?.budgetMonthlyUsd ?? (typeof reqSnap?.budgetMonthlyUsd === "number" ? reqSnap.budgetMonthlyUsd : 0))}/mo</span></div>
{mgr ? <div><span className="text-muted-foreground">Reports to:</span> <span className="text-foreground">{mgr.name}</span></div> : null}
</div></div>) })() : null}
{approval.type === "approve_ceo_strategy" ? <div className="rounded-lg border border-[color:var(--destructive)] border-opacity-30 bg-yellow-100 dark:bg-yellow-900/50 px-4 py-3"><div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-yellow-600 dark:text-yellow-400">Organization Strategy</div><div className="whitespace-pre-wrap text-[12px] leading-relaxed text-foreground">{approval.payloadSummary}</div></div> : null}
{approval.type === "approve_ceo_strategy" ? <div className="rounded-lg border border-[color:var(--destructive)] border-opacity-30 bg-warning-muted px-4 py-3"><div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-warning">Organization Strategy</div><div className="whitespace-pre-wrap text-[12px] leading-relaxed text-foreground">{approval.payloadSummary}</div></div> : null}
{approval.type === "deliverable_review" ? <DeliverableReviewPanel approval={approval} documents={documents} agents={agents} /> : null}
{isSocialPost && approval.payloadJson ? (
<SocialDraftPreview
@@ -53,11 +53,11 @@ export function ApprovalList({ approvals, selectedApprovalId, onSelect }: { appr
className={`focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 outline-none ${virtualRow.index === focusedIndex ? "ring-1 ring-[color:var(--primary)] ring-inset" : ""}`}
>
<button
className={`relative w-full border-b border-border px-4 py-3 text-left transition duration-150 last:border-b-0 ${selectedApprovalId === approval.id ? "bg-yellow-100 dark:bg-yellow-900/50" : "bg-transparent hover:bg-muted"}`}
className={`relative w-full border-b border-border px-4 py-3 text-left transition duration-150 last:border-b-0 ${selectedApprovalId === approval.id ? "bg-warning-muted" : "bg-transparent hover:bg-muted"}`}
onClick={() => onSelect(approval.id)}
tabIndex={-1}
>
{selectedApprovalId === approval.id ? <span className="absolute inset-y-0 left-0 w-[3px] bg-yellow-600 dark:bg-yellow-400" /> : null}
{selectedApprovalId === approval.id ? <span className="absolute inset-y-0 left-0 w-[3px] bg-warning" /> : null}
<div className="flex items-center justify-between gap-4">
<div className={`truncate text-[13px] text-foreground ${selectedApprovalId === approval.id ? "font-semibold" : "font-medium"}`}>{approval.payloadSummary}</div>
<StatusPill status={approval.state} />
@@ -45,8 +45,8 @@ export function BudgetSummary({ agents, costs, companyId }: BudgetSummaryProps)
const pct = status.amount > 0 ? (status.spent / status.amount) * 100 : 0
const isWarning = pct > 80 && pct <= 100
const isDanger = pct > 100
const barColor = isDanger ? "bg-destructive" : isWarning ? "bg-yellow-600 dark:bg-yellow-400" : "bg-green-600 dark:bg-green-400"
const nameColor = isDanger ? "text-destructive" : isWarning ? "text-yellow-600 dark:text-yellow-400" : "text-foreground"
const barColor = isDanger ? "bg-destructive" : isWarning ? "bg-warning" : "bg-success"
const nameColor = isDanger ? "text-destructive" : isWarning ? "text-warning" : "text-foreground"
const scopeLabel = status.scopeType === "company"
? "Company"
@@ -62,11 +62,11 @@ export function BudgetSummary({ agents, costs, companyId }: BudgetSummaryProps)
{status.scopeType}
</span>
<div className={`text-[14px] font-medium ${nameColor}`}>{scopeLabel}</div>
{status.hasOpenIncident ? <Warning size={14} weight="fill" className="text-yellow-600 dark:text-yellow-400" /> : null}
{status.hasOpenIncident ? <Warning size={14} weight="fill" className="text-warning" /> : null}
</div>
<div className="flex items-center gap-2">
<div className="font-mono text-[12px] text-foreground">{formatMoney(status.spent / 100)} / {formatMoney(status.amount / 100)}</div>
<span className={`font-mono text-[11px] font-semibold ${isDanger ? "text-destructive" : isWarning ? "text-yellow-600 dark:text-yellow-400" : "text-muted-foreground"}`}>{Math.round(pct)}%</span>
<span className={`font-mono text-[11px] font-semibold ${isDanger ? "text-destructive" : isWarning ? "text-warning" : "text-muted-foreground"}`}>{Math.round(pct)}%</span>
</div>
</div>
<div className="h-2 rounded-full bg-[color:var(--border)]"><div className={`h-2 rounded-full ${barColor}`} style={{ width: `${Math.min(100, pct)}%` }} /></div>
@@ -79,19 +79,19 @@ export function BudgetSummary({ agents, costs, companyId }: BudgetSummaryProps)
{agents.map((agent) => {
const pct = agent.budgetMonthlyUsd > 0 ? (agent.spentMonthlyUsd / agent.budgetMonthlyUsd) * 100 : 0
const isWarning = pct > 80 && pct <= 100; const isDanger = pct > 100
const barColor = isDanger ? "bg-destructive" : isWarning ? "bg-yellow-600 dark:bg-yellow-400" : "bg-green-600 dark:bg-green-400"
const nameColor = isDanger ? "text-destructive" : isWarning ? "text-yellow-600 dark:text-yellow-400" : "text-foreground"
const barColor = isDanger ? "bg-destructive" : isWarning ? "bg-warning" : "bg-success"
const nameColor = isDanger ? "text-destructive" : isWarning ? "text-warning" : "text-foreground"
return (
<ListRow key={agent.id}>
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<div className={`text-[14px] font-medium ${nameColor}`}>{agent.name}</div>
{isDanger ? <Warning size={14} weight="fill" className="text-destructive" /> : null}
{isWarning ? <Warning size={14} weight="fill" className="text-yellow-600 dark:text-yellow-400" /> : null}
{isWarning ? <Warning size={14} weight="fill" className="text-warning" /> : null}
</div>
<div className="flex items-center gap-2">
<div className="font-mono text-[12px] text-foreground">{formatMoney(agent.spentMonthlyUsd)} / {formatMoney(agent.budgetMonthlyUsd)}</div>
<span className={`font-mono text-[11px] font-semibold ${isDanger ? "text-destructive" : isWarning ? "text-yellow-600 dark:text-yellow-400" : "text-muted-foreground"}`}>{Math.round(pct)}%</span>
<span className={`font-mono text-[11px] font-semibold ${isDanger ? "text-destructive" : isWarning ? "text-warning" : "text-muted-foreground"}`}>{Math.round(pct)}%</span>
</div>
</div>
<div className="h-2 rounded-full bg-[color:var(--border)]"><div className={`h-2 rounded-full ${barColor}`} style={{ width: `${Math.min(100, pct)}%` }} /></div>
@@ -100,7 +100,7 @@ export function CostList({ costs, agents, runs }: { costs: ProfileSnapshot["cost
</div>
<div className="shrink-0 text-right">
<div className="font-mono tabular-nums text-[13px] text-foreground">{formatMoney(entry.amountUsd)}</div>
{entry.unattributed ? <div className="text-[11px] text-yellow-600 dark:text-yellow-400">unattributed</div> : null}
{entry.unattributed ? <div className="text-[11px] text-warning">unattributed</div> : null}
</div>
</div>
</div>
@@ -11,8 +11,8 @@ interface IncidentListProps {
}
const STATUS_STYLES: Record<string, string> = {
open: "text-destructive bg-red-100 dark:bg-red-900/50",
resolved: "text-green-600 dark:text-green-400 bg-green-100 dark:bg-green-900/50",
open: "text-destructive bg-destructive-muted",
resolved: "text-success bg-success-muted",
auto_resolved_limit_increase: "text-primary bg-accent",
auto_closed: "text-muted-foreground bg-muted",
};
@@ -93,8 +93,8 @@ export function IncidentList({ companyId, agents, projects }: IncidentListProps)
<span
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.1em] ${
isWarn
? "text-yellow-600 dark:text-yellow-400 bg-yellow-100 dark:bg-yellow-900/50"
: "text-destructive bg-red-100 dark:bg-red-900/50"
? "text-warning bg-warning-muted"
: "text-destructive bg-destructive-muted"
}`}
>
{isWarn ? <Warning size={10} weight="bold" /> : null}
@@ -1,9 +1,52 @@
import { useCallback, useEffect, useState } from "react";
import { FloppyDisk, Plus, X as XIcon } from "@phosphor-icons/react";
import {
CaretDown,
CaretRight,
FloppyDisk,
Plus,
SignIn,
Trash,
ArrowSquareOut,
X as XIcon,
} from "@phosphor-icons/react";
import type { ConnectorRecord, ProfileSnapshot } from "@shared/types";
import type { AppLocale } from "@shared/locale";
import { unwrap } from "../../lib/desktop";
import { ActionButton } from "../ui";
/* ------------------------------------------------------------------ */
/* Compatible-API provider presets (mirrors ProvidersStep) */
/* ------------------------------------------------------------------ */
const COMPATIBLE_PROVIDERS = [
{ id: "minimax", label: "MiniMax", baseUrl: "https://api.minimax.io/anthropic", docUrl: "https://platform.minimax.io/docs/token-plan/claude-code" },
{ id: "minimax_cn", label: "MiniMax (CN)", baseUrl: "https://api.minimaxi.com/anthropic", docUrl: "https://platform.minimax.io/docs/token-plan/claude-code" },
{ id: "kimi", label: "Kimi", baseUrl: "https://api.kimi.com/coding/", docUrl: "https://www.kimi.com/code/docs/en/more/third-party-agents.html" },
{ id: "kimi_cn", label: "Kimi CN", baseUrl: "https://api.moonshot.cn/anthropic", docUrl: "https://platform.moonshot.cn/docs/guide/agent-support" },
{ id: "glm", label: "GLM (ZhiPu)", baseUrl: "https://api.z.ai/api/anthropic", docUrl: "https://docs.z.ai/devpack/tool/claude" },
{ id: "openrouter", label: "OpenRouter", baseUrl: "https://openrouter.ai/api", docUrl: "https://openrouter.ai/docs/guides/coding-agents/claude-code-integration" },
{ id: "tencent", label: "Tencent Coding Plan", baseUrl: "https://api.lkeap.cloud.tencent.com/coding/anthropic", docUrl: "https://cloud.tencent.com/document/product/1772/128951" },
{ id: "poe", label: "POE", baseUrl: "https://api.poe.com", docUrl: "https://creator.poe.com/docs/external-applications/claude-code" },
{ id: "deepseek", label: "DeepSeek", baseUrl: "https://api.deepseek.com/anthropic", docUrl: "https://api-docs.deepseek.com/guides/anthropic_api" },
{ id: "alibaba", label: "Alibaba Coding Plan", baseUrl: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic", docUrl: "https://www.alibabacloud.com/help/en/model-studio/claude-code-coding-plan" },
{ id: "alibaba_cn", label: "Alibaba Coding Plan (CN)", baseUrl: "https://coding.dashscope.aliyuncs.com/apps/anthropic", docUrl: "https://help.aliyun.com/zh/model-studio/claude-code-coding-plan" },
{ id: "bailing", label: "BaiLing", baseUrl: "https://api.tbox.cn/api/anthropic", docUrl: "https://alipaytbox.yuque.com/sxs0ba/ling/best_practices_claudecode" },
{ id: "nanogpt", label: "NanoGPT", baseUrl: "https://nano-gpt.com/api/v1", docUrl: "https://docs.nano-gpt.com/integrations/claude-code#manual-configuration-all-platforms" },
{ id: "zhipu_cn", label: "ZhiPu (CN)", baseUrl: "https://open.bigmodel.cn/api/anthropic", docUrl: "https://docs.bigmodel.cn/cn/coding-plan/tool/claude" },
{ id: "ollama", label: "Ollama (Local)", baseUrl: "http://localhost:11434", docUrl: "https://docs.ollama.com/integrations/claude-code" },
{ id: "custom", label: "Custom", baseUrl: "", docUrl: "" },
] as const;
const COMPAT_SLOTS = [
"claude_compatible", "claude_compat_2", "claude_compat_3", "claude_compat_4", "claude_compat_5",
"claude_compat_6", "claude_compat_7", "claude_compat_8", "claude_compat_9", "claude_compat_10",
] as const;
/* ------------------------------------------------------------------ */
/* Shared */
/* ------------------------------------------------------------------ */
const INPUT_CLS =
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring w-full rounded-lg border border-border bg-card px-3 py-2.5 text-[13px] text-foreground placeholder:text-muted-foreground";
interface ModelManagementProps {
connectors: ProfileSnapshot["connectors"];
@@ -11,194 +54,738 @@ interface ModelManagementProps {
onSaved: () => Promise<void>;
}
/** Check if a claude_compat connector has been configured (has env bindings). */
function isConfigured(c: ConnectorRecord): boolean {
return c.envBindingText.includes("ANTHROPIC_BASE_URL") || c.envBindingText.includes("ANTHROPIC_API_KEY");
interface ModelEntry {
id: string;
label: string;
enabled: boolean;
}
function getDisplayName(connector: ConnectorRecord): string {
if (connector.id.startsWith("claude_compat") && connector.notes) return connector.notes;
return connector.label;
type ConnectorStatus = ProfileSnapshot["connectors"][number]["status"];
function parseEnvValue(text: string, key: string): string {
const line = text.split(/\r?\n/).find((l) => l.startsWith(`${key}=`));
return line ? line.slice(key.length + 1) : "";
}
function CompatModelEditor({
/* ------------------------------------------------------------------ */
/* StatusBadge */
/* ------------------------------------------------------------------ */
function StatusBadge({ status, zh }: { status: ConnectorStatus; zh: boolean }) {
if (status === "ready") {
return (
<span className="inline-flex items-center gap-1.5 rounded-full bg-success-muted px-2 py-0.5 text-[11px] font-medium text-success">
<span className="h-1.5 w-1.5 rounded-full bg-success" />
{zh ? "就绪" : "Ready"}
</span>
);
}
if (status === "not_installed") {
return (
<span className="inline-flex items-center gap-1.5 rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
<span className="h-1.5 w-1.5 rounded-full bg-muted-foreground" />
{zh ? "未安装" : "Not installed"}
</span>
);
}
if (status === "auth_required") {
return (
<span className="inline-flex items-center gap-1.5 rounded-full bg-warning-muted px-2 py-0.5 text-[11px] font-medium text-warning">
<span className="h-1.5 w-1.5 rounded-full bg-warning" />
{zh ? "需登录" : "Auth required"}
</span>
);
}
if (status === "detected" || (status as string) === "checking") {
return (
<span className="inline-flex items-center gap-1.5 rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
<span className="h-3 w-3 animate-spin rounded-full border-[1.5px] border-muted-foreground border-t-primary" />
{zh ? "检测中" : "Checking"}
</span>
);
}
return (
<span className="inline-flex items-center gap-1.5 rounded-full bg-caution-muted px-2 py-0.5 text-[11px] font-medium text-caution">
<span className="h-1.5 w-1.5 rounded-full bg-caution" />
{status.replaceAll("_", " ")}
</span>
);
}
/* ------------------------------------------------------------------ */
/* ProviderCard */
/* ------------------------------------------------------------------ */
function ProviderCard({
title,
badge,
expanded,
onToggle,
children,
}: {
title: string;
badge: React.ReactNode;
expanded: boolean;
onToggle: () => void;
children: React.ReactNode;
}) {
return (
<div className="overflow-hidden rounded-lg border border-border bg-card">
<button
type="button"
onClick={onToggle}
className="flex w-full cursor-pointer items-center justify-between px-4 py-3 transition hover:bg-muted/50"
>
<div className="flex items-center gap-2.5">
{expanded ? (
<CaretDown size={14} className="text-muted-foreground" />
) : (
<CaretRight size={14} className="text-muted-foreground" />
)}
<span className="text-[13px] font-semibold text-foreground">{title}</span>
</div>
{badge}
</button>
{expanded && <div className="space-y-3 px-4 pb-4">{children}</div>}
</div>
);
}
/* ------------------------------------------------------------------ */
/* Toggle switch */
/* ------------------------------------------------------------------ */
function ToggleSwitch({ checked, onChange, label }: { checked: boolean; onChange: () => void; label: string }) {
return (
<button
type="button"
onClick={onChange}
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors ${checked ? "bg-success" : "bg-muted-foreground/30"}`}
role="switch"
aria-checked={checked}
aria-label={label}
>
<span
className={`inline-block h-3.5 w-3.5 rounded-full bg-white shadow-sm transition-transform ${checked ? "translate-x-[18px]" : "translate-x-[3px]"}`}
/>
</button>
);
}
/* ------------------------------------------------------------------ */
/* Model toggle list */
/* ------------------------------------------------------------------ */
function ModelToggleList({
models,
onToggle,
onRemove,
canRemove,
zh,
}: {
models: ModelEntry[];
onToggle: (id: string) => void;
onRemove?: (id: string) => void;
canRemove: boolean;
zh: boolean;
}) {
if (models.length === 0) {
return (
<div className="text-[12px] text-muted-foreground">
{zh ? "暂无模型" : "No models available"}
</div>
);
}
const enabledCount = models.filter((m) => m.enabled).length;
return (
<div>
<div className="mb-1.5 text-[12px] font-medium text-muted-foreground">
{zh ? "模型" : "Models"} ({enabledCount}/{models.length} {zh ? "已启用" : "enabled"})
</div>
<div className="space-y-1">
{models.map((m) => (
<div
key={m.id}
className="flex items-center justify-between rounded-md border border-border px-3 py-2"
>
<div className="flex items-center gap-2.5">
<ToggleSwitch
checked={m.enabled}
onChange={() => onToggle(m.id)}
label={`${m.enabled ? "Disable" : "Enable"} ${m.label}`}
/>
<span
className={`text-[12px] ${m.enabled ? "text-foreground" : "text-muted-foreground line-through"}`}
>
{m.label}
</span>
</div>
{canRemove && onRemove ? (
<button
type="button"
onClick={() => onRemove(m.id)}
className="text-muted-foreground transition hover:text-destructive"
aria-label={`Remove ${m.label}`}
>
<XIcon size={12} />
</button>
) : null}
</div>
))}
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Native provider card (Claude Code / Codex / Gemini) */
/* ------------------------------------------------------------------ */
function NativeProviderCard({
connector,
locale,
zh,
onSaved,
expanded,
onToggle,
}: {
connector: ConnectorRecord;
locale: AppLocale;
zh: boolean;
onSaved: () => Promise<void>;
expanded: boolean;
onToggle: () => void;
}) {
const [models, setModels] = useState<string[]>(() => {
const raw = connector.model ?? "";
return raw.split(",").map((m) => m.trim()).filter(Boolean);
});
const [newModel, setNewModel] = useState("");
const [models, setModels] = useState<ModelEntry[]>([]);
const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false);
const envKeyName = connector.id === "codex_local" ? "OPENAI_API_KEY" : "ANTHROPIC_API_KEY";
const [apiKey, setApiKey] = useState(() => parseEnvValue(connector.envBindingText, envKeyName));
useEffect(() => {
const raw = connector.model ?? "";
setModels(raw.split(",").map((m) => m.trim()).filter(Boolean));
setDirty(false);
}, [connector.model]);
setApiKey(parseEnvValue(connector.envBindingText, envKeyName));
}, [connector.envBindingText, envKeyName]);
function addModel() {
const trimmed = newModel.trim();
if (!trimmed || models.includes(trimmed)) return;
setModels((prev) => [...prev, trimmed]);
setNewModel("");
setDirty(true);
}
const loadModels = useCallback(async () => {
try {
const [enabledResult, disabledResult] = await Promise.all([
window.agentCompany.listConnectorModels(connector.id),
window.agentCompany.getConnectorDisabledModels(connector.id),
]);
const enabled = enabledResult.ok ? enabledResult.data : [];
const disabled = disabledResult.ok ? disabledResult.data : [];
setModels([
...enabled.map((m) => ({ ...m, enabled: true })),
...disabled.map((m) => ({ ...m, enabled: false })),
]);
} catch {
/* non-fatal */
}
}, [connector.id]);
function removeModel(m: string) {
setModels((prev) => prev.filter((x) => x !== m));
setDirty(true);
useEffect(() => {
void loadModels();
}, [loadModels]);
async function handleToggleModel(modelId: string) {
const prev = models;
const updated = models.map((m) =>
m.id === modelId ? { ...m, enabled: !m.enabled } : m,
);
setModels(updated);
const newDisabled = updated
.filter((m) => !m.enabled)
.map((m) => ({ id: m.id, label: m.label }));
try {
unwrap(
await window.agentCompany.setConnectorDisabledModels({
connectorId: connector.id,
disabledModels: newDisabled,
}),
);
await onSaved();
} catch {
setModels(prev);
}
}
async function handleSave() {
setSaving(true);
try {
unwrap(await window.agentCompany.saveConnector({
id: connector.id,
command: connector.command,
model: models.join(",") || null,
envBindingText: connector.envBindingText,
notes: connector.notes,
}));
setDirty(false);
const lines: string[] = [];
if (apiKey.trim()) lines.push(`${envKeyName}=${apiKey.trim()}`);
unwrap(
await window.agentCompany.saveConnector({
id: connector.id,
command: connector.command,
model: connector.model,
envBindingText: lines.join("\n"),
notes: connector.notes,
}),
);
await window.agentCompany.testConnector(connector.id);
await onSaved();
void loadModels();
} finally {
setSaving(false);
}
}
async function handleLogin() {
await window.agentCompany.openConnectorAuthTerminal(connector.id);
}
const showApiKey = connector.id === "claude_local" || connector.id === "codex_local";
return (
<ProviderCard
title={connector.label}
badge={<StatusBadge status={connector.status} zh={zh} />}
expanded={expanded}
onToggle={onToggle}
>
<button
type="button"
onClick={() => void handleLogin()}
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-[12px] font-medium text-foreground transition hover:bg-muted"
>
<SignIn size={14} />
{zh ? "登录" : "Login"}
</button>
{showApiKey ? (
<div>
<label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
{envKeyName}{" "}
<span className="font-normal">({zh ? "可选" : "optional"})</span>
</label>
<input
type="password"
className={INPUT_CLS}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={connector.id === "codex_local" ? "sk-..." : "sk-ant-..."}
/>
</div>
) : null}
<ModelToggleList
models={models}
onToggle={(id) => void handleToggleModel(id)}
canRemove={false}
zh={zh}
/>
<button
type="button"
onClick={() => void handleSave()}
disabled={saving}
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-[12px] font-medium text-foreground transition hover:bg-muted disabled:opacity-40"
>
<FloppyDisk size={14} />
{saving
? zh
? "保存中..."
: "Saving..."
: zh
? "保存并检查"
: "Save & Check"}
</button>
</ProviderCard>
);
}
/* ------------------------------------------------------------------ */
/* Compatible provider card */
/* ------------------------------------------------------------------ */
function CompatProviderCard({
slotId,
connector,
zh,
onSaved,
expanded,
onToggle,
onCleared,
}: {
slotId: string;
connector: ConnectorRecord | null;
zh: boolean;
onSaved: () => Promise<void>;
expanded: boolean;
onToggle: () => void;
onCleared?: () => void;
}) {
const [selectedProvider, setSelectedProvider] = useState<string>(() => {
const saved = connector?.notes ?? "";
const match = COMPATIBLE_PROVIDERS.find((p) => p.label === saved);
return match?.id ?? COMPATIBLE_PROVIDERS[0].id;
});
const [apiKey, setApiKey] = useState(() =>
parseEnvValue(connector?.envBindingText ?? "", "ANTHROPIC_API_KEY"),
);
const [baseUrl, setBaseUrl] = useState<string>(
() =>
parseEnvValue(connector?.envBindingText ?? "", "ANTHROPIC_BASE_URL") ||
COMPATIBLE_PROVIDERS[0].baseUrl,
);
const [models, setModels] = useState<ModelEntry[]>([]);
const [newModel, setNewModel] = useState("");
const [saving, setSaving] = useState(false);
const status = connector?.status ?? "not_installed";
const providerLabel =
COMPATIBLE_PROVIDERS.find((p) => p.id === selectedProvider)?.label ??
"Compatible API";
const displayTitle = connector?.notes || providerLabel;
// Sync form state when connector changes externally (e.g. after save + refresh)
useEffect(() => {
setApiKey(parseEnvValue(connector?.envBindingText ?? "", "ANTHROPIC_API_KEY"));
const savedBase = parseEnvValue(connector?.envBindingText ?? "", "ANTHROPIC_BASE_URL");
if (savedBase) setBaseUrl(savedBase);
const saved = connector?.notes ?? "";
const match = COMPATIBLE_PROVIDERS.find((p) => p.label === saved);
if (match) setSelectedProvider(match.id);
}, [connector?.envBindingText, connector?.notes]);
const loadModels = useCallback(async () => {
if (!connector) {
setModels([]);
return;
}
try {
const raw = connector.model ?? "";
const allModelIds = raw
.split(",")
.map((m) => m.trim())
.filter(Boolean);
const disabledResult = await window.agentCompany.getConnectorDisabledModels(
connector.id,
);
const disabledSet = new Set(
(disabledResult.ok ? disabledResult.data : []).map((m) => m.id),
);
setModels(
allModelIds.map((id) => ({
id,
label: id,
enabled: !disabledSet.has(id),
})),
);
} catch {
/* non-fatal */
}
}, [connector?.id, connector?.model]);
useEffect(() => {
void loadModels();
}, [loadModels]);
function handleProviderChange(providerId: string) {
setSelectedProvider(providerId);
const provider = COMPATIBLE_PROVIDERS.find((p) => p.id === providerId);
if (provider) setBaseUrl(provider.baseUrl);
}
function addModel() {
const trimmed = newModel.trim();
if (!trimmed || models.some((m) => m.id === trimmed)) return;
setModels((prev) => [...prev, { id: trimmed, label: trimmed, enabled: true }]);
setNewModel("");
}
function removeModel(modelId: string) {
setModels((prev) => prev.filter((m) => m.id !== modelId));
}
async function handleToggleModel(modelId: string) {
if (!connector) return;
const prev = models;
const updated = models.map((m) =>
m.id === modelId ? { ...m, enabled: !m.enabled } : m,
);
setModels(updated);
const newDisabled = updated
.filter((m) => !m.enabled)
.map((m) => ({ id: m.id, label: m.label }));
try {
unwrap(
await window.agentCompany.setConnectorDisabledModels({
connectorId: connector.id,
disabledModels: newDisabled,
}),
);
await onSaved();
} catch {
setModels(prev);
}
}
async function handleSave() {
setSaving(true);
try {
const lines: string[] = [];
if (apiKey.trim()) lines.push(`ANTHROPIC_API_KEY=${apiKey.trim()}`);
if (baseUrl.trim()) lines.push(`ANTHROPIC_BASE_URL=${baseUrl.trim()}`);
const label =
COMPATIBLE_PROVIDERS.find((p) => p.id === selectedProvider)?.label ?? "";
const modelsCsv =
models.map((m) => m.id).join(",") || null;
unwrap(
await window.agentCompany.saveConnector({
id: slotId as ConnectorRecord["id"],
command: connector?.command ?? "claude",
model: modelsCsv,
envBindingText: lines.join("\n"),
notes: label,
}),
);
await window.agentCompany.testConnector(slotId);
await onSaved();
} finally {
setSaving(false);
}
}
return (
<div>
<div className="mb-2 text-[13px] font-semibold text-foreground">{getDisplayName(connector)}</div>
{models.length > 0 ? (
<div className="mb-2 flex flex-wrap gap-1.5">
{models.map((m) => (
<span
key={m}
className="inline-flex items-center gap-1 rounded-md border border-border bg-muted px-2.5 py-1 text-[12px] text-foreground"
>
{m}
<button
type="button"
onClick={() => removeModel(m)}
className="ml-0.5 text-muted-foreground transition hover:text-destructive"
aria-label={`Remove ${m}`}
>
<XIcon size={10} />
</button>
</span>
))}
</div>
) : (
<div className="mb-2 text-[12px] text-muted-foreground">
{locale === "zh" ? "未配置模型" : "No models configured"}
</div>
)}
<div className="flex items-center gap-2">
<input
className="flex-1 rounded-lg border border-border bg-card px-3 py-2 text-[13px] text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
value={newModel}
onChange={(e) => setNewModel(e.target.value)}
placeholder={locale === "zh" ? "输入模型名称" : "model-name"}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addModel(); } }}
/>
<button
type="button"
onClick={addModel}
disabled={!newModel.trim()}
className="shrink-0 rounded-lg border border-border bg-card p-2 text-foreground transition hover:bg-muted disabled:opacity-40"
aria-label="Add model"
>
<Plus size={14} />
</button>
{dirty ? (
<ActionButton
label={locale === "zh" ? "保存" : "Save"}
tone="accent"
icon={FloppyDisk}
onClick={() => void handleSave()}
loading={saving}
/>
) : null}
</div>
</div>
);
}
function NativeModelList({
connector,
locale,
}: {
connector: ConnectorRecord;
locale: AppLocale;
}) {
const [models, setModels] = useState<Array<{ id: string; label: string }>>([]);
const loadModels = useCallback(async () => {
async function handleClear() {
if (!connector) return;
try {
const result = await window.agentCompany.listConnectorModels(connector.id);
if (result.ok && result.data.length > 0) {
setModels(result.data);
}
} catch { /* non-fatal */ }
}, [connector.id]);
useEffect(() => { void loadModels(); }, [loadModels]);
return (
<div>
<div className="mb-2 text-[13px] font-semibold text-foreground">{getDisplayName(connector)}</div>
{models.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{models.map((m) => (
<span
key={m.id}
className="inline-block rounded-md border border-border bg-muted px-2.5 py-1 text-[12px] text-foreground"
>
{m.label}
</span>
))}
</div>
) : (
<div className="text-[12px] text-muted-foreground">
{locale === "zh" ? "使用默认模型" : "Using default model"}
</div>
)}
</div>
);
}
export function ModelManagement({ connectors, locale, onSaved }: ModelManagementProps) {
// Show native connectors that are ready + claude_compat connectors that are configured
const visibleConnectors = connectors.filter((c) => {
if (c.id.startsWith("claude_compat")) return isConfigured(c);
return c.status === "ready";
});
if (visibleConnectors.length === 0) {
return (
<div className="py-6 text-center text-[13px] text-muted-foreground">
{locale === "zh" ? "暂无就绪的提供商" : "No ready providers"}
</div>
);
unwrap(
await window.agentCompany.saveConnector({
id: connector.id,
command: connector.command,
model: null,
envBindingText: "",
notes: "",
}),
);
unwrap(
await window.agentCompany.setConnectorDisabledModels({
connectorId: connector.id,
disabledModels: [],
}),
);
await onSaved();
onCleared?.();
} catch {
/* non-fatal */
}
}
const selectedProviderData = COMPATIBLE_PROVIDERS.find(
(p) => p.id === selectedProvider,
);
const isConfigured =
connector &&
(connector.envBindingText.includes("ANTHROPIC_BASE_URL") ||
connector.envBindingText.includes("ANTHROPIC_API_KEY"));
return (
<div className="space-y-5">
{visibleConnectors.map((connector) =>
connector.id.startsWith("claude_compat") ? (
<CompatModelEditor key={connector.id} connector={connector} locale={locale} onSaved={onSaved} />
) : (
<NativeModelList key={connector.id} connector={connector} locale={locale} />
),
)}
<ProviderCard
title={displayTitle}
badge={<StatusBadge status={status} zh={zh} />}
expanded={expanded}
onToggle={onToggle}
>
<div>
<label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
{zh ? "选择提供商" : "Select provider"}
</label>
<select
className={INPUT_CLS}
value={selectedProvider}
onChange={(e) => handleProviderChange(e.target.value)}
>
{COMPATIBLE_PROVIDERS.map((p) => (
<option key={p.id} value={p.id} className="bg-card text-foreground">
{p.label}
</option>
))}
</select>
</div>
{selectedProviderData?.docUrl ? (
<button
type="button"
onClick={() => window.open(selectedProviderData.docUrl, "_blank")}
className="inline-flex items-center gap-1 text-[12px] text-primary transition hover:underline"
>
<ArrowSquareOut size={12} />
{zh ? "查看文档" : "View docs"}
</button>
) : null}
<div>
<label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
ANTHROPIC_API_KEY
</label>
<input
type="password"
className={INPUT_CLS}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="sk-..."
/>
</div>
<div>
<label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
ANTHROPIC_BASE_URL
</label>
<input
className={INPUT_CLS}
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
placeholder="https://api.example.com/v1"
/>
</div>
<ModelToggleList
models={models}
onToggle={(id) => void handleToggleModel(id)}
onRemove={removeModel}
canRemove
zh={zh}
/>
<div>
<label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
{zh ? "添加模型" : "Add model"}
</label>
<div className="flex gap-2">
<input
className={INPUT_CLS}
value={newModel}
onChange={(e) => setNewModel(e.target.value)}
placeholder={zh ? "输入模型名称" : "model-name"}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addModel();
}
}}
/>
<button
type="button"
onClick={addModel}
disabled={!newModel.trim()}
className="shrink-0 rounded-lg border border-border bg-card px-3 py-2 text-[12px] font-medium text-foreground transition hover:bg-muted disabled:opacity-40"
aria-label={zh ? "添加模型" : "Add model"}
>
<Plus size={14} />
</button>
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => void handleSave()}
disabled={saving}
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-[12px] font-medium text-foreground transition hover:bg-muted disabled:opacity-40"
>
<FloppyDisk size={14} />
{saving
? zh
? "保存中..."
: "Saving..."
: zh
? "保存并检查"
: "Save & Check"}
</button>
{isConfigured ? (
<button
type="button"
onClick={() => void handleClear()}
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-[12px] font-medium text-destructive transition hover:bg-muted"
>
<Trash size={14} />
{zh ? "清除配置" : "Clear"}
</button>
) : null}
</div>
</ProviderCard>
);
}
/* ------------------------------------------------------------------ */
/* Main component */
/* ------------------------------------------------------------------ */
export function ModelManagement({
connectors,
locale,
onSaved,
}: ModelManagementProps) {
const zh = locale === "zh";
const [expandedCard, setExpandedCard] = useState<string | null>(null);
const claudeConnector = connectors.find((c) => c.id === "claude_local");
const codexConnector = connectors.find((c) => c.id === "codex_local");
const geminiConnector = connectors.find((c) => c.id === "gemini_local");
const activeCompatSlots = COMPAT_SLOTS.filter((id) => {
const c = connectors.find((conn) => conn.id === id);
return (
c &&
(c.envBindingText.includes("ANTHROPIC_BASE_URL") ||
c.envBindingText.includes("ANTHROPIC_API_KEY"))
);
});
const nextAvailableSlot =
COMPAT_SLOTS.find((id) => !activeCompatSlots.includes(id)) ?? null;
const visibleCompatSlots = [...activeCompatSlots];
if (nextAvailableSlot && !visibleCompatSlots.includes(nextAvailableSlot)) {
visibleCompatSlots.push(nextAvailableSlot);
}
const toggle = (card: string) =>
setExpandedCard((prev) => (prev === card ? null : card));
return (
<div className="space-y-3">
{claudeConnector ? (
<NativeProviderCard
connector={claudeConnector}
zh={zh}
onSaved={onSaved}
expanded={expandedCard === "claude_local"}
onToggle={() => toggle("claude_local")}
/>
) : null}
{codexConnector ? (
<NativeProviderCard
connector={codexConnector}
zh={zh}
onSaved={onSaved}
expanded={expandedCard === "codex_local"}
onToggle={() => toggle("codex_local")}
/>
) : null}
{geminiConnector ? (
<NativeProviderCard
connector={geminiConnector}
zh={zh}
onSaved={onSaved}
expanded={expandedCard === "gemini_local"}
onToggle={() => toggle("gemini_local")}
/>
) : null}
{visibleCompatSlots.map((slotId) => (
<CompatProviderCard
key={slotId}
slotId={slotId}
connector={connectors.find((c) => c.id === slotId) ?? null}
zh={zh}
onSaved={onSaved}
expanded={expandedCard === slotId}
onToggle={() => toggle(slotId)}
onCleared={() => setExpandedCard(null)}
/>
))}
</div>
);
}
@@ -99,7 +99,7 @@ export function PolicyList({ companyId, agents, projects }: PolicyListProps) {
{policies.map((policy) => {
const util = getUtilization(policy.id);
const pct = util?.pct ?? 0;
const barColor = pct > 100 ? "bg-destructive" : pct > 80 ? "bg-yellow-600 dark:bg-yellow-400" : "bg-green-600 dark:bg-green-400";
const barColor = pct > 100 ? "bg-destructive" : pct > 80 ? "bg-warning" : "bg-success";
return (
<ListRow key={policy.id} onClick={() => handleEdit(policy)}>
<div className="flex items-center justify-between gap-3">
@@ -110,7 +110,7 @@ export function PolicyList({ companyId, agents, projects }: PolicyListProps) {
</span>
<span className="truncate text-[13px] font-medium text-foreground">{getScopeName(policy)}</span>
{policy.hardStopEnabled ? <ShieldCheck size={14} className="shrink-0 text-primary" /> : null}
{util?.hasIncident ? <Warning size={14} weight="fill" className="shrink-0 text-yellow-600 dark:text-yellow-400" /> : null}
{util?.hasIncident ? <Warning size={14} weight="fill" className="shrink-0 text-warning" /> : null}
</div>
<div className="mt-1 flex items-center gap-3 text-[11px] text-muted-foreground">
<span className="font-[family-name:var(--font-mono)]" style={{ fontVariantNumeric: "tabular-nums" }}>
@@ -41,6 +41,23 @@ export function SettingsPane({ snapshot, secrets, companyId, onSaved }: { snapsh
<div className="rounded-lg border border-border px-4 py-3"><label className="flex items-center gap-3"><input type="checkbox" checked={company.autoApproveHires} onChange={async (e) => { setActionError(null); try { unwrap(await window.agentCompany.saveCompany({ id: company.id, name: company.name, description: company.description, status: company.status, autoApproveHires: e.target.checked, reviewDeliverables: company.reviewDeliverables })); await onSaved() } catch (err) { setActionError(describeActionError(err)) } }} className="h-4 w-4 rounded accent-[color:var(--primary)]" /><div><div className="text-[13px] font-medium text-foreground">{t("exec.autoApproveLabel")}</div><div className="mt-0.5 text-[11px] text-muted-foreground">{t("exec.autoApproveDesc")}</div></div></label></div>
<div className="rounded-lg border border-border px-4 py-3"><label className="flex items-center gap-3"><input type="checkbox" checked={!company.reviewDeliverables} onChange={async (e) => { setActionError(null); try { unwrap(await window.agentCompany.saveCompany({ id: company.id, name: company.name, description: company.description, status: company.status, autoApproveHires: company.autoApproveHires, reviewDeliverables: !e.target.checked })); await onSaved() } catch (err) { setActionError(describeActionError(err)) } }} className="h-4 w-4 rounded accent-[color:var(--primary)]" /><div><div className="text-[13px] font-medium text-foreground">{t("exec.reviewDeliverablesLabel")}</div><div className="mt-0.5 text-[11px] text-muted-foreground">{t("exec.reviewDeliverablesDesc")}</div></div></label></div>
</div></div>) })()}
<div><div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">Keyboard Shortcuts</div>
<div className="space-y-1 border-y border-border py-3">
{([
[navigator.platform.includes("Mac") ? "⌘K" : "Ctrl+K", "Open command palette"],
[navigator.platform.includes("Mac") ? "⌘Enter" : "Ctrl+Enter", "Send message"],
["Escape", "Close modal / palette"],
["↑ / ↓", "Navigate palette results"],
["Enter", "Select palette result"],
["Tab", "Move focus between elements"],
] as const).map(([key, desc]) => (
<div key={key} className="flex items-center justify-between px-3 py-1.5 text-[12px]">
<span className="text-muted-foreground">{desc}</span>
<kbd className="rounded border border-border bg-muted px-2 py-0.5 font-mono text-[11px] text-foreground">{key}</kbd>
</div>
))}
</div>
</div>
<div><div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("exec.actions")}</div><div className="grid gap-3 md:grid-cols-2"><ActionButton label={t("exec.saveSettings")} tone="accent" onClick={() => void handleSaveSettings()} icon={CheckCircle} loading={loadingAction === "save"} /><ActionButton label={t("exec.checkForUpdates")} onClick={() => void handleCheckForUpdates()} icon={Download} loading={loadingAction === "check"} /><ActionButton label={t("exec.backupProfile")} onClick={() => void handleBackupProfile()} icon={ClockCounterClockwise} loading={loadingAction === "backup"} /><ActionButton label={t("exec.restoreProfile")} onClick={() => void handleRestoreProfile()} icon={ArrowClockwise} loading={loadingAction === "restore"} /></div></div>
<div><div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("exec.secretBackend")}</div><div className="border-y border-border py-3 text-[13px] text-muted-foreground">{snapshot.backend.secretBackend}</div></div>
<div><div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("exec.secrets")} ({secrets.length})</div>{secrets.length === 0 ? <EmptyState title={t("exec.noSecretsStored")} /> : <div className="border-y border-border overflow-x-auto"><table className="w-full text-left text-[13px]"><thead><tr className="border-b border-border"><th className="px-4 py-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("exec.secretName")}</th><th className="px-4 py-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("exec.secretDescription")}</th><th className="px-4 py-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("exec.secretBackendCol")}</th><th className="px-4 py-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("exec.secretUpdated")}</th><th className="px-4 py-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground" /></tr></thead><tbody>{secrets.map((s) => <tr key={s.id} className="border-b border-border last:border-b-0"><td className="px-4 py-3 font-medium text-foreground">{s.name}</td><td className="px-4 py-3 text-muted-foreground">{s.description || "\u2014"}</td><td className="px-4 py-3 font-mono text-muted-foreground">{s.backend}</td><td className="px-4 py-3 font-mono text-muted-foreground">{formatTime(s.updatedAt)}</td><td className="px-4 py-3"><ActionButton label="Delete" onClick={() => setConfirmDeleteSecretId(s.id)} icon={Trash} tone="danger" /></td></tr>)}</tbody></table></div>}</div>
@@ -121,7 +121,7 @@ export function SocialDraftPreview({ payloadJson, platform, isPending: _isPendin
) : null}
{draft.scheduledAt ? (
<div className="flex items-center gap-1.5 text-[13px] font-[family-name:var(--font-mono)] text-yellow-600 dark:text-yellow-400">
<div className="flex items-center gap-1.5 text-[13px] font-[family-name:var(--font-mono)] text-warning">
<Clock size={14} />
Scheduled: {formatScheduledDate(draft.scheduledAt)}
</div>
@@ -52,7 +52,7 @@ export function TaskComments({ taskId, companyId, agents, comments, onRefresh }:
)}
{submitError ? <InlineNotice message={submitError} /> : null}
<div className="flex gap-2">
<input className="flex-1 rounded-md border border-border bg-card px-3 py-2 text-[13px] text-foreground placeholder:text-muted-foreground focus:border-[color:var(--primary)] focus:outline-none" placeholder={t("exec.sendMessage")} value={newComment} onChange={(e) => setNewComment(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); void handleSubmit() } }} />
<input aria-label={t("exec.sendMessage")} className="flex-1 rounded-md border border-border bg-card px-3 py-2 text-[13px] text-foreground placeholder:text-muted-foreground focus:border-[color:var(--primary)] focus:outline-none" placeholder={t("exec.sendMessage")} value={newComment} onChange={(e) => setNewComment(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); void handleSubmit() } }} />
<ActionButton label={t("exec.send")} tone="accent" onClick={() => void handleSubmit()} icon={ArrowRight} loading={submitting} />
</div>
</div>
@@ -1,4 +1,5 @@
import { useState } from "react"
import { AnimatePresence, motion } from "framer-motion"
import { ListChecks } from "@phosphor-icons/react"
import type { GoalRecord, ProfileSnapshot, ProjectRecord, WorkspaceRecord, TaskRecord } from "@shared/types"
import { useT } from "../../i18n"
@@ -20,15 +21,29 @@ export function TaskCreator({ companyId, goals, projects, agents, workspaces, on
try { unwrap(await window.agentCompany.saveTask({ companyId, projectId: projectId || null, goalId: goalId || null, title, description, assigneeAgentId: assigneeAgentId || null, workspaceId: workspaceId || null, priority: priority as TaskRecord["priority"], status: "todo" })); setTitle(""); setDescription(""); await onSaved() } catch (e) { setSubmitError(describeActionError(e)) } finally { setSubmitting(false) }
}
const [showAdvanced, setShowAdvanced] = useState(false)
return (
<div className="grid min-w-0 gap-4 md:grid-cols-2">
<Input label={t("exec.title")} value={title} onChange={setTitle} placeholder={t("exec.taskTitle")} />
<Select label={t("exec.priority")} value={priority} onChange={setPriority} options={["critical","high","medium","low"].map((v) => ({ value: v, label: v }))} />
<div className="md:col-span-2"><TextArea label="Description" value={description} onChange={setDescription} placeholder="Describe the task" rows={5} /></div>
<Select label={t("exec.goal")} value={goalId} onChange={setGoalId} options={[{ value: "", label: t("exec.noGoal") }, ...goals.map((g) => ({ value: g.id, label: g.title }))]} />
<Select label={t("exec.project")} value={projectId} onChange={setProjectId} options={[{ value: "", label: t("exec.noProject") }, ...projects.map((p) => ({ value: p.id, label: p.name }))]} />
<Select label={t("exec.assignee")} value={assigneeAgentId} onChange={setAssigneeAgentId} options={[{ value: "", label: t("exec.unassigned") }, ...agents.map((a) => ({ value: a.id, label: a.name }))]} />
<Select label={t("exec.workspace")} value={workspaceId} onChange={setWorkspaceId} options={[{ value: "", label: t("exec.noWorkspace") }, ...workspaces.map((w) => ({ value: w.id, label: w.name }))]} />
<div className="md:col-span-2">
<button type="button" onClick={() => setShowAdvanced(!showAdvanced)} className="flex items-center gap-1.5 text-[12px] font-medium text-muted-foreground transition hover:text-foreground">
<svg width="10" height="10" viewBox="0 0 10 10" className={`transition-transform ${showAdvanced ? "rotate-90" : ""}`}><path d="M3 1l4 4-4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none" /></svg>
{t("exec.goal")}, {t("exec.project")}, {t("exec.assignee")}, {t("exec.workspace")}
</button>
</div>
<AnimatePresence>
{showAdvanced ? (
<motion.div initial={{ height: 0, opacity: 0 }} animate={{ height: "auto", opacity: 1 }} exit={{ height: 0, opacity: 0 }} transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1] }} className="grid gap-4 overflow-hidden md:col-span-2 md:grid-cols-2">
<Select label={t("exec.goal")} value={goalId} onChange={setGoalId} options={[{ value: "", label: t("exec.noGoal") }, ...goals.map((g) => ({ value: g.id, label: g.title }))]} />
<Select label={t("exec.project")} value={projectId} onChange={setProjectId} options={[{ value: "", label: t("exec.noProject") }, ...projects.map((p) => ({ value: p.id, label: p.name }))]} />
<Select label={t("exec.assignee")} value={assigneeAgentId} onChange={setAssigneeAgentId} options={[{ value: "", label: t("exec.unassigned") }, ...agents.map((a) => ({ value: a.id, label: a.name }))]} />
<Select label={t("exec.workspace")} value={workspaceId} onChange={setWorkspaceId} options={[{ value: "", label: t("exec.noWorkspace") }, ...workspaces.map((w) => ({ value: w.id, label: w.name }))]} />
</motion.div>
) : null}
</AnimatePresence>
{submitError ? <div className="md:col-span-2"><InlineNotice message={submitError} /></div> : null}
<div className="md:col-span-2"><ActionButton label={t("exec.createTask")} tone="accent" onClick={() => void handleCreateTask()} icon={ListChecks} loading={submitting} /></div>
</div>
@@ -38,7 +38,7 @@ export function TaskInspector({ task, tasks, runs, agents, goals, projects, work
return (
<div className="space-y-6">
<ConfirmDialog open={confirmDelete} title={t("exec.deleteTask")} message={`${t("exec.deleteTaskConfirm").replace("{0}", task.title)}`} errorMessage={deleteError} onConfirm={() => void handleDelete()} onCancel={() => { setDeleteError(null); setConfirmDelete(false) }} />
{budgetWarning > 0.8 && assignee ? <div className={`flex items-center gap-2 rounded-lg border px-4 py-3 text-[13px] font-medium ${budgetWarning > 1 ? "border-[color:var(--destructive)] bg-red-100 dark:bg-red-900/50 text-destructive" : "border-[color:var(--destructive)] bg-yellow-100 dark:bg-yellow-900/50 text-yellow-600 dark:text-yellow-400"}`}><Warning size={16} weight="fill" />{assignee.name} has used {Math.round(budgetWarning * 100)}% of their monthly budget ({formatMoney(assignee.spentMonthlyUsd)} / {formatMoney(assignee.budgetMonthlyUsd)})</div> : null}
{budgetWarning > 0.8 && assignee ? <div className={`flex items-center gap-2 rounded-lg border px-4 py-3 text-[13px] font-medium ${budgetWarning > 1 ? "border-[color:var(--destructive)] bg-destructive-muted text-destructive" : "border-[color:var(--destructive)] bg-warning-muted text-warning"}`}><Warning size={16} weight="fill" />{assignee.name} has used {Math.round(budgetWarning * 100)}% of their monthly budget ({formatMoney(assignee.spentMonthlyUsd)} / {formatMoney(assignee.budgetMonthlyUsd)})</div> : null}
{actionError ? <InlineNotice message={actionError} /> : null}
{!actionError && actionNotice ? <InlineNotice message={actionNotice} tone="success" /> : null}
{editing ? (
@@ -65,7 +65,7 @@ export function TaskInspector({ task, tasks, runs, agents, goals, projects, work
</div>
{task.description ? <div className="text-[13px] leading-relaxed text-muted-foreground whitespace-pre-wrap">{task.description}</div> : null}
{task.status === "in_review" && reviewer ? <div className="rounded-lg border border-[color:var(--primary)] bg-accent px-4 py-3"><div className="flex items-center gap-2 text-[13px] font-semibold text-primary"><Robot size={16} weight="fill" />Being reviewed by {reviewer.name}{reviewer.title ? ` (${reviewer.title})` : ""}</div><div className="mt-1 text-[13px] leading-relaxed text-primary">{assignee?.name} submitted this task for review. {reviewer.name} will approve, request revisions, or escalate.</div></div>
: needsGovernance ? <div className="rounded-lg border border-[color:var(--destructive)] bg-yellow-100 dark:bg-yellow-900/50 px-4 py-3"><div className="flex items-center gap-2 text-[13px] font-semibold text-yellow-600 dark:text-yellow-400"><ShieldCheck size={16} weight="fill" />{task.status === "blocked" ? t("exec.governanceRequired") : t("exec.waitingOnReview")}</div><div className="mt-1 text-[13px] leading-relaxed text-muted-foreground">{task.status === "blocked" ? t("exec.blockedGovernance") : t("exec.reviewGovernance")}</div><div className="mt-3"><ActionButton label={task.status === "blocked" ? t("exec.createUnblockApproval") : t("exec.createReviewApproval")} onClick={() => void handleCreateApproval()} icon={ShieldCheck} loading={creatingApproval} /></div></div> : null}
: needsGovernance ? <div className="rounded-lg border border-[color:var(--destructive)] bg-warning-muted px-4 py-3"><div className="flex items-center gap-2 text-[13px] font-semibold text-warning"><ShieldCheck size={16} weight="fill" />{task.status === "blocked" ? t("exec.governanceRequired") : t("exec.waitingOnReview")}</div><div className="mt-1 text-[13px] leading-relaxed text-muted-foreground">{task.status === "blocked" ? t("exec.blockedGovernance") : t("exec.reviewGovernance")}</div><div className="mt-3"><ActionButton label={task.status === "blocked" ? t("exec.createUnblockApproval") : t("exec.createReviewApproval")} onClick={() => void handleCreateApproval()} icon={ShieldCheck} loading={creatingApproval} /></div></div> : null}
<div className="flex items-center gap-3">
{activeRun ? <ActionButton label={t("exec.cancelRun")} onClick={() => void onCancelRun(activeRun.id)} icon={XCircle} tone="danger" /> : <>
{["todo","in_progress","blocked"].includes(task.status) && <ActionButton label={t("exec.run")} onClick={() => void onRun(task.id)} icon={Play} tone="accent" />}
@@ -80,19 +80,19 @@ export function TaskInspector({ task, tasks, runs, agents, goals, projects, work
)}
{(() => { const subtasks = tasks.filter((t) => t.parentId === task.id); if (subtasks.length === 0) return null; return (
<div className="space-y-2">
<div className="flex items-center gap-2"><ListChecks size={14} className="text-muted-foreground" /><span className="text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("exec.subtasks")} ({subtasks.length})</span><span className="text-[10px] text-green-600 dark:text-green-400">{subtasks.filter((t) => t.status === "done").length}/{subtasks.length} done</span></div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted"><div className="h-full rounded-full bg-green-600 dark:bg-green-400 transition-all duration-300" style={{ width: `${subtasks.length > 0 ? (subtasks.filter((t) => t.status === "done").length / subtasks.length) * 100 : 0}%` }} /></div>
<div className="flex items-center gap-2"><ListChecks size={14} className="text-muted-foreground" /><span className="text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("exec.subtasks")} ({subtasks.length})</span><span className="text-[10px] text-success">{subtasks.filter((t) => t.status === "done").length}/{subtasks.length} done</span></div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted"><div className="h-full rounded-full bg-success transition-all duration-300" style={{ width: `${subtasks.length > 0 ? (subtasks.filter((t) => t.status === "done").length / subtasks.length) * 100 : 0}%` }} /></div>
<div className="space-y-1">{subtasks.map((sub) => { const subAssignee = agentMap.get(sub.assigneeAgentId ?? ""); return (
<div key={sub.id} className="flex items-center gap-2 rounded-lg px-3 py-2 text-[12px] bg-muted">
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${sub.status === "done" ? "bg-green-600 dark:bg-green-400" : sub.status === "in_progress" ? "bg-primary" : sub.status === "blocked" ? "bg-destructive" : sub.status === "in_review" ? "bg-yellow-600 dark:bg-yellow-400" : "bg-[color:var(--muted-foreground)]"}`} />
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${sub.status === "done" ? "bg-success" : sub.status === "in_progress" ? "bg-primary" : sub.status === "blocked" ? "bg-destructive" : sub.status === "in_review" ? "bg-warning" : "bg-[color:var(--muted-foreground)]"}`} />
<span className={`flex-1 truncate ${sub.status === "done" ? "line-through text-muted-foreground" : "text-foreground"}`}>{sub.title}</span>
{subAssignee ? <span className="shrink-0 text-[10px] text-muted-foreground">{subAssignee.name}</span> : null}<StatusPill status={sub.status} />
</div>) })}</div></div>) })()}
<TaskComments taskId={task.id} companyId={task.companyId} agents={agents} comments={comments} onRefresh={onRefresh} />
{runs.length > 0 && <div className="space-y-2"><div className="text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("exec.runHistory")}</div><div className="space-y-2">{runs.slice(0, 8).map((run) => { const runAgent = agentMap.get(run.agentId); const isActive = run.status === "running" || run.status === "queued"; return (
<div key={run.id} className={`rounded-lg border px-3 py-2.5 ${isActive ? "border-[color:var(--primary)] bg-green-100 dark:bg-green-900/50" : run.status === "failed" || run.status === "timed_out" ? "border-[color:var(--destructive)] border-opacity-30 bg-red-100 dark:bg-red-900/50" : "border-border bg-muted"}`}>
<div className="flex items-center justify-between gap-2"><div className="min-w-0 flex-1"><div className="flex items-center gap-2">{isActive ? <span className="h-2 w-2 animate-pulse rounded-full bg-green-600 dark:bg-green-400" /> : null}<span className="truncate text-[13px] font-medium text-foreground">{run.summary || (isActive ? "Running..." : "Completed run")}</span></div><div className="mt-0.5 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-[10px] text-muted-foreground">{runAgent ? <span>{runAgent.name}</span> : null}<span>{run.connectorId.replace("_local", "")}</span>{run.model ? <span className="font-mono">{run.model}</span> : null}<span>{formatTime(run.startedAt)}</span>{run.costUsd != null && run.costUsd > 0 ? <span className="font-mono tabular-nums">{formatMoney(run.costUsd)}</span> : null}</div></div><StatusPill status={run.status} /></div>
{run.errorMessage ? <div className="mt-1.5 rounded bg-red-100 dark:bg-red-900/50 px-2 py-1 text-[11px] text-destructive break-words">{run.errorMessage}</div> : null}
<div key={run.id} className={`rounded-lg border px-3 py-2.5 ${isActive ? "border-[color:var(--primary)] bg-success-muted" : run.status === "failed" || run.status === "timed_out" ? "border-[color:var(--destructive)] border-opacity-30 bg-destructive-muted" : "border-border bg-muted"}`}>
<div className="flex items-center justify-between gap-2"><div className="min-w-0 flex-1"><div className="flex items-center gap-2">{isActive ? <span className="h-2 w-2 animate-pulse rounded-full bg-success" /> : null}<span className="truncate text-[13px] font-medium text-foreground">{run.summary || (isActive ? "Running..." : "Completed run")}</span></div><div className="mt-0.5 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-[10px] text-muted-foreground">{runAgent ? <span>{runAgent.name}</span> : null}<span>{run.connectorId.replace("_local", "")}</span>{run.model ? <span className="font-mono">{run.model}</span> : null}<span>{formatTime(run.startedAt)}</span>{run.costUsd != null && run.costUsd > 0 ? <span className="font-mono tabular-nums">{formatMoney(run.costUsd)}</span> : null}</div></div><StatusPill status={run.status} /></div>
{run.errorMessage ? <div className="mt-1.5 rounded bg-destructive-muted px-2 py-1 text-[11px] text-destructive break-words">{run.errorMessage}</div> : null}
</div>) })}{runs.length > 8 && <div className="text-[11px] text-muted-foreground text-center">+{runs.length - 8} more runs</div>}</div></div>}
</div>
)
@@ -27,11 +27,11 @@ export const ENTITY_TYPE_OPTIONS = [
export function WorkspaceStatePill({ state }: { state: string }) {
const colorMap: Record<string, string> = {
ready: "text-green-600 dark:text-green-400 bg-green-100 dark:bg-green-900/50",
busy: "text-yellow-600 dark:text-yellow-400 bg-yellow-100 dark:bg-yellow-900/50",
blocked: "text-destructive bg-red-100 dark:bg-red-900/50",
ready: "text-success bg-success-muted",
busy: "text-warning bg-warning-muted",
blocked: "text-destructive bg-destructive-muted",
missing: "text-muted-foreground bg-muted",
error: "text-destructive bg-red-100 dark:bg-red-900/50",
error: "text-destructive bg-destructive-muted",
}
return (
<span className={`inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.1em] ${colorMap[state] ?? colorMap.missing}`}>
@@ -41,6 +41,6 @@ export function WorkspaceStatePill({ state }: { state: string }) {
}
export function PriorityDot({ priority }: { priority: string }) {
const color = priority === "critical" ? "bg-destructive" : priority === "high" ? "bg-yellow-600 dark:bg-yellow-400" : priority === "low" ? "bg-[color:var(--muted-foreground)]" : "bg-[color:var(--muted-foreground)]"
const color = priority === "critical" ? "bg-destructive" : priority === "high" ? "bg-warning" : priority === "low" ? "bg-[color:var(--muted-foreground)]" : "bg-[color:var(--muted-foreground)]"
return <span className={`inline-block h-2 w-2 shrink-0 rounded-full ${color}`} aria-label={`${priority} priority`} />
}
@@ -126,8 +126,8 @@ export function HiringPipeline({ agents, approvals, onRefresh, onNavigateToAgent
const ag = candidate.agent;
return (
<div key={a?.id ?? ag?.id} className="px-4 py-3 flex items-start gap-3">
<div className="flex-shrink-0 mt-0.5 h-8 w-8 rounded-full bg-yellow-100 dark:bg-yellow-900/50 flex items-center justify-center">
<UserPlus size={16} className="text-yellow-600 dark:text-yellow-400" />
<div className="flex-shrink-0 mt-0.5 h-8 w-8 rounded-full bg-warning-muted flex items-center justify-center">
<UserPlus size={16} className="text-warning" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
@@ -247,7 +247,7 @@ export function HiringPipeline({ agents, approvals, onRefresh, onNavigateToAgent
</div>
<div className="rounded-lg px-4 py-3 border border-border bg-card">
<div className="text-[10px] uppercase text-muted-foreground tracking-wider">{t("hiring.pendingHires")}</div>
<div className="text-2xl font-bold text-yellow-600 dark:text-yellow-400 mt-1">{agents.filter(a => a.status === "pending_approval").length}</div>
<div className="text-2xl font-bold text-warning mt-1">{agents.filter(a => a.status === "pending_approval").length}</div>
</div>
<div className="rounded-lg px-4 py-3 border border-border bg-card">
<div className="text-[10px] uppercase text-muted-foreground tracking-wider">{t("hiring.departments")}</div>
@@ -278,8 +278,8 @@ export function HiringPipeline({ agents, approvals, onRefresh, onNavigateToAgent
<span className="text-[10px] text-muted-foreground">{t("hiring.active")}</span>
{pending > 0 && (
<>
<span className="text-sm font-bold text-yellow-600 dark:text-yellow-400">+{pending}</span>
<span className="text-[10px] text-yellow-600 dark:text-yellow-400">{t("hiring.pendingApproval")}</span>
<span className="text-sm font-bold text-warning">+{pending}</span>
<span className="text-[10px] text-warning">{t("hiring.pendingApproval")}</span>
</>
)}
</div>
@@ -24,12 +24,12 @@ export function getHiringStage(agent: AgentRecord, approval: ApprovalRecord | nu
export function stageColor(stage: HiringStage): string {
switch (stage) {
case "requisition": return "bg-yellow-100 dark:bg-yellow-900/50 text-yellow-600 dark:text-yellow-400";
case "pending_approval": return "bg-yellow-100 dark:bg-yellow-900/50 text-yellow-600 dark:text-yellow-400";
case "approved": return "bg-green-100 dark:bg-green-900/50 text-green-600 dark:text-green-400";
case "requisition": return "bg-warning-muted text-warning";
case "pending_approval": return "bg-warning-muted text-warning";
case "approved": return "bg-success-muted text-success";
case "onboarding": return "bg-accent text-primary";
case "active": return "bg-green-100 dark:bg-green-900/50 text-green-600 dark:text-green-400";
case "rejected": return "bg-red-100 dark:bg-red-900/50 text-destructive";
case "active": return "bg-success-muted text-success";
case "rejected": return "bg-destructive-muted text-destructive";
}
}
@@ -60,7 +60,7 @@ export function PerformanceView({ metrics, onNavigate }: PerformanceProps) {
</div>
<div className="flex gap-4 text-[12px]">
<span className="flex items-center gap-1.5">
<span className="h-2.5 w-2.5 rounded-full bg-green-600 dark:bg-green-400" />
<span className="h-2.5 w-2.5 rounded-full bg-success" />
<span className="text-muted-foreground">{metrics.successfulRuns} succeeded</span>
</span>
<span className="flex items-center gap-1.5">
@@ -102,11 +102,11 @@ export function PerformanceView({ metrics, onNavigate }: PerformanceProps) {
<span className="text-[12px] font-medium text-foreground">{am.agentName}</span>
</div>
</td>
<td className="px-3 py-2.5 text-right text-[12px] font-semibold text-green-600 dark:text-green-400">{am.tasksCompleted}</td>
<td className="px-3 py-2.5 text-right text-[12px] font-semibold text-success">{am.tasksCompleted}</td>
<td className="px-3 py-2.5 text-right">
<span className={`text-[12px] font-semibold ${
am.successRate >= 80 ? "text-green-600 dark:text-green-400"
: am.successRate >= 50 ? "text-yellow-600 dark:text-yellow-400"
am.successRate >= 80 ? "text-success"
: am.successRate >= 50 ? "text-warning"
: "text-destructive"
}`}>
{am.successRate}%
@@ -43,7 +43,7 @@ export function StandupView({ report, onNavigate }: StandupProps) {
<div className="text-[14px] font-medium text-foreground">{report.summary}</div>
<div className="mt-3 flex gap-4">
<div className="text-center">
<div className="text-[18px] font-bold tabular-nums text-green-600 dark:text-green-400">{report.agentReports.filter(r => r.tasksCompleted24h > 0).length}</div>
<div className="text-[18px] font-bold tabular-nums text-success">{report.agentReports.filter(r => r.tasksCompleted24h > 0).length}</div>
<div className="text-[10px] text-muted-foreground">{t("standup.productive")}</div>
</div>
<div className="h-10 w-px bg-[color:var(--border)]" />
@@ -53,7 +53,7 @@ export function StandupView({ report, onNavigate }: StandupProps) {
</div>
<div className="h-10 w-px bg-[color:var(--border)]" />
<div className="text-center">
<div className="text-[18px] font-bold tabular-nums text-yellow-600 dark:text-yellow-400">{report.pendingApprovals}</div>
<div className="text-[18px] font-bold tabular-nums text-warning">{report.pendingApprovals}</div>
<div className="text-[10px] text-muted-foreground">{t("standup.pendingApprovals")}</div>
</div>
<div className="h-10 w-px bg-[color:var(--border)]" />
@@ -71,7 +71,7 @@ export function StandupView({ report, onNavigate }: StandupProps) {
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("standup.highlights")}</div>
<div className="space-y-1.5">
{report.companyHighlights.map((h, i) => (
<div key={i} className="flex items-center gap-2 rounded-lg bg-green-100 dark:bg-green-900/50 px-3 py-2 text-[12px] text-green-600 dark:text-green-400">
<div key={i} className="flex items-center gap-2 rounded-lg bg-success-muted px-3 py-2 text-[12px] text-success">
<CheckCircle size={14} weight="fill" />
{h}
</div>
@@ -85,7 +85,7 @@ export function StandupView({ report, onNavigate }: StandupProps) {
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-destructive">Blockers</div>
<div className="space-y-1.5">
{report.blockers.map((b, i) => (
<div key={i} className="flex items-center gap-2 rounded-lg bg-red-100 dark:bg-red-900/50 px-3 py-2 text-[12px] text-destructive">
<div key={i} className="flex items-center gap-2 rounded-lg bg-destructive-muted px-3 py-2 text-[12px] text-destructive">
<Warning size={14} weight="fill" />
{b}
</div>
@@ -106,16 +106,16 @@ export function StandupView({ report, onNavigate }: StandupProps) {
>
<div className="relative">
<div className={`flex h-10 w-10 items-center justify-center rounded-full text-[12px] font-bold ${
ar.status === "running" ? "bg-green-100 dark:bg-green-900/50 text-green-600 dark:text-green-400"
: ar.status === "error" ? "bg-red-100 dark:bg-red-900/50 text-destructive"
ar.status === "running" ? "bg-success-muted text-success"
: ar.status === "error" ? "bg-destructive-muted text-destructive"
: "bg-muted text-muted-foreground"
}`}>
{getAgentInitials(ar.agentName)}
</div>
{ar.status === "running" ? (
<span className="absolute -bottom-0.5 -right-0.5 flex h-3 w-3">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-600 dark:bg-green-400 opacity-40" style={{ willChange: "transform" }} />
<span className="relative inline-flex h-2 w-2 rounded-full bg-green-600 dark:bg-green-400" />
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-success opacity-40" style={{ willChange: "transform" }} />
<span className="relative inline-flex h-2 w-2 rounded-full bg-success" />
</span>
) : null}
</div>
@@ -126,7 +126,7 @@ export function StandupView({ report, onNavigate }: StandupProps) {
</div>
<div className="mt-1 flex flex-wrap gap-3 text-[11px] text-muted-foreground">
{ar.tasksInProgress > 0 ? <span className="text-primary">{ar.tasksInProgress} in progress</span> : null}
{ar.tasksCompleted24h > 0 ? <span className="text-green-600 dark:text-green-400">{ar.tasksCompleted24h} done</span> : null}
{ar.tasksCompleted24h > 0 ? <span className="text-success">{ar.tasksCompleted24h} done</span> : null}
{ar.tasksBlocked > 0 ? <span className="text-destructive">{ar.tasksBlocked} blocked</span> : null}
{ar.costLast24h > 0 ? <span className="tabular-nums">${ar.costLast24h.toFixed(2)}</span> : null}
</div>
@@ -33,8 +33,8 @@ export function CompactMetric({ label, value, tone = "default", subtitle }: {
}) {
const toneMap = {
default: "text-foreground",
success: "text-green-600 dark:text-green-400",
warn: "text-yellow-600 dark:text-yellow-400",
success: "text-success",
warn: "text-warning",
danger: "text-destructive",
accent: "text-primary",
};
@@ -56,7 +56,7 @@ export function RunProgressBar({ succeeded, failed, total }: { succeeded: number
return (
<div className="flex h-3 overflow-hidden rounded-full bg-muted">
<motion.div
className="h-full bg-green-600 dark:bg-green-400"
className="h-full bg-success"
initial={{ width: 0 }}
animate={{ width: `${successPct}%` }}
transition={{ duration: 0.8, ease: "easeOut" }}
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { Robot } from "@phosphor-icons/react";
import type { AgentRecord, ConnectorRecord, WorkspaceRecord } from "@shared/types";
import { isConnectorExecutionReady } from "@shared/connector-policy";
@@ -65,11 +66,13 @@ export function AgentCreator({
}
}, [executionReadyConnectors, connectorId]);
const [showAdvanced, setShowAdvanced] = useState(false);
return (
<div className="grid min-w-0 gap-4">
<Input label="Agent name" value={name} onChange={setName} placeholder="Agent name" />
<Input label="Role" value={role} onChange={setRole} placeholder="e.g. Engineer, PM, CEO" />
<Input label="Title" value={title} onChange={setTitle} placeholder="Title" />
<Input label="Agent name" value={name} onChange={setName} placeholder="Agent name" hint="Display name shown in org chart and messages" />
<Input label="Role" value={role} onChange={setRole} placeholder="e.g. Engineer, PM, CEO" hint="Determines which tasks the agent can handle" />
<Input label="Title" value={title} onChange={setTitle} placeholder="Title" hint="Job title shown under the name" />
<Select
label="Connector"
value={connectorId}
@@ -77,7 +80,7 @@ export function AgentCreator({
options={executionReadyConnectors.map((connector) => ({ value: connector.id, label: connector.id.startsWith("claude_compat") && connector.notes ? connector.notes : connector.label }))}
/>
{executionReadyConnectors.length === 0 ? (
<p className="text-[12px] text-yellow-600 dark:text-yellow-400">
<p className="text-[12px] text-warning">
No execution-ready connector is available. Fix connector health before requesting hires.
</p>
) : null}
@@ -102,31 +105,42 @@ export function AgentCreator({
]}
/>
) : null}
<Select
label="Workspace"
value={workspaceId}
onChange={setWorkspaceId}
options={[
{ value: "", label: "No workspace" },
...workspaces.map((workspace) => ({ value: workspace.id, label: workspace.name })),
]}
/>
<Select
label="Reports to"
value={reportsTo}
onChange={setReportsTo}
options={[
{ value: "", label: "No manager" },
...agents.map((a) => ({ value: a.id, label: a.name })),
]}
/>
<Input label="Monthly budget (USD)" value={budget} onChange={setBudget} placeholder="120" type="number" />
<TextArea
label="Capabilities"
value={capabilities}
onChange={setCapabilities}
placeholder="What this agent does"
/>
<button type="button" onClick={() => setShowAdvanced(!showAdvanced)} className="flex items-center gap-1.5 text-[12px] font-medium text-muted-foreground transition hover:text-foreground">
<svg width="10" height="10" viewBox="0 0 10 10" className={`transition-transform ${showAdvanced ? "rotate-90" : ""}`}><path d="M3 1l4 4-4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none" /></svg>
Advanced settings
</button>
<AnimatePresence>
{showAdvanced ? (
<motion.div initial={{ height: 0, opacity: 0 }} animate={{ height: "auto", opacity: 1 }} exit={{ height: 0, opacity: 0 }} transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1] }} className="grid gap-4 overflow-hidden">
<Select
label="Workspace"
value={workspaceId}
onChange={setWorkspaceId}
options={[
{ value: "", label: "No workspace" },
...workspaces.map((workspace) => ({ value: workspace.id, label: workspace.name })),
]}
/>
<Select
label="Reports to"
value={reportsTo}
onChange={setReportsTo}
options={[
{ value: "", label: "No manager" },
...agents.map((a) => ({ value: a.id, label: a.name })),
]}
/>
<Input label="Monthly budget (USD)" value={budget} onChange={setBudget} placeholder="120" type="number" hint="Spending cap per month; agent pauses when exceeded" />
<TextArea
label="Capabilities"
value={capabilities}
onChange={setCapabilities}
placeholder="What this agent does"
hint="Skills and domain knowledge injected into the agent's system prompt"
/>
</motion.div>
) : null}
</AnimatePresence>
<p className="text-[12px] text-muted-foreground">
New agents enter the hiring pipeline first. Approval activates the agent and keeps onboarding, budgets, and org history linked to the same record.
</p>
@@ -155,7 +155,7 @@ export function AgentEditor({
options={connectorOptions}
/>
{selectedConnectorIssue ? (
<p className="text-[12px] text-yellow-600 dark:text-yellow-400">
<p className="text-[12px] text-warning">
{selectedConnectorIssue}
</p>
) : null}
@@ -76,7 +76,7 @@ export function OrgChart({ agents, connectors, onSelectAgent }: { agents: AgentR
<div ref={containerRef} className="relative min-h-[400px] h-[clamp(400px,60vh,720px)] w-full overflow-hidden rounded-lg border border-border bg-background" style={{ cursor: dragging ? "grabbing" : "grab" }} onMouseDown={handleMouseDown} onMouseMove={handleMouseMove} onMouseUp={handleMouseUp} onMouseLeave={handleMouseUp} onWheel={handleWheel}>
<div className="absolute right-3 top-3 z-10 flex flex-col gap-1">
{[{ label: "+", ariaLabel: "Zoom in", action: zoomIn }, { label: "\u2212", ariaLabel: "Zoom out", action: zoomOut }, { label: "Fit", ariaLabel: "Fit chart to screen", action: fitToScreen }].map((btn) => (
<button key={btn.ariaLabel} className="flex h-7 w-7 items-center justify-center rounded-lg border border-border bg-card text-[12px] text-foreground transition hover:bg-muted" onClick={btn.action} aria-label={btn.ariaLabel}>{btn.label}</button>
<button key={btn.ariaLabel} className="flex h-9 w-9 items-center justify-center rounded-lg border border-border bg-card text-[12px] text-foreground transition hover:bg-muted" onClick={btn.action} aria-label={btn.ariaLabel}>{btn.label}</button>
))}
</div>
<svg className="pointer-events-none absolute inset-0" style={{ width: "100%", height: "100%" }}>
@@ -103,11 +103,11 @@ export function OrgChart({ agents, connectors, onSelectAgent }: { agents: AgentR
const isPending = node.agent.status === "pending_approval";
const isCEO = node.children.length > 0 && !node.agent.reportsTo;
return (
<div key={node.agent.id} data-org-card className={`absolute cursor-pointer select-none rounded-lg border bg-card shadow-sm transition-all duration-200 hover:shadow-lg hover:-translate-y-0.5 ${isRunning ? "border-[color:var(--primary)] shadow-[0_0_12px_rgba(74,222,128,0.15)]" : isError ? "border-[color:var(--destructive)] border-opacity-50" : isPending ? "border-[color:var(--destructive)] border-dashed" : "border-border hover:border-border"}`} style={{ left: node.x, top: node.y, width: CARD_W, minHeight: CARD_H }} onClick={() => onSelectAgent?.(node.agent.id)}>
<div className={`rounded-t-[7px] ${isCEO ? "h-[4px] bg-primary" : node.children.length > 0 ? "h-[3px] bg-yellow-600 dark:bg-yellow-400" : "h-[2px] bg-[color:var(--muted-foreground)] opacity-30"}`} />
<div key={node.agent.id} data-org-card role="button" tabIndex={0} aria-label={`${node.agent.name}, ${node.agent.title || node.agent.role}, ${node.agent.status}`} className={`absolute cursor-pointer select-none rounded-lg border bg-card shadow-sm transition-all duration-200 hover:shadow-lg hover:-translate-y-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${isRunning ? "border-[color:var(--primary)] shadow-[0_0_12px_rgba(74,222,128,0.15)]" : isError ? "border-[color:var(--destructive)] border-opacity-50" : isPending ? "border-[color:var(--destructive)] border-dashed" : "border-border hover:border-border"}`} style={{ left: node.x, top: node.y, width: CARD_W, minHeight: CARD_H }} onClick={() => onSelectAgent?.(node.agent.id)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onSelectAgent?.(node.agent.id); } }}>
<div className={`rounded-t-[7px] ${isCEO ? "h-[4px] bg-primary" : node.children.length > 0 ? "h-[3px] bg-warning" : "h-[2px] bg-[color:var(--muted-foreground)] opacity-30"}`} />
<div className="flex items-center gap-3 px-4 py-3">
<div className="relative shrink-0">
<div className={`flex h-11 w-11 items-center justify-center rounded-full text-[13px] font-bold ${isActive ? "bg-green-100 dark:bg-green-900/50 text-green-600 dark:text-green-400" : isError ? "bg-red-100 dark:bg-red-900/50 text-destructive" : "bg-muted text-muted-foreground"}`}>{initials}</div>
<div className={`flex h-11 w-11 items-center justify-center rounded-full text-[13px] font-bold ${isActive ? "bg-success-muted text-success" : isError ? "bg-destructive-muted text-destructive" : "bg-muted text-muted-foreground"}`}>{initials}</div>
{isRunning ? (
<span className="absolute -bottom-0.5 -right-0.5 flex h-3.5 w-3.5" aria-hidden="true">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full opacity-40" style={{ backgroundColor: dotColor, willChange: "transform" }} />
@@ -123,7 +123,7 @@ export function OrgChart({ agents, connectors, onSelectAgent }: { agents: AgentR
<span className="mt-0.5 text-[11px] leading-tight text-muted-foreground">{node.agent.title || node.agent.role}</span>
<div className="mt-1.5 flex items-center gap-2">
<span className="rounded bg-muted px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{connectorLabel}</span>
{node.agent.heartbeatEnabled ? <Heartbeat size={10} className="text-green-600 dark:text-green-400" /> : null}
{node.agent.heartbeatEnabled ? <Heartbeat size={10} className="text-success" /> : null}
</div>
</div>
</div>
@@ -10,7 +10,7 @@ export function BudgetBar({ spent, budget }: { spent: number; budget: number })
<div className="flex items-center gap-2">
<div className="h-1.5 w-20 overflow-hidden rounded-full bg-[color:var(--border)]">
<div
className={`h-full rounded-full transition-all ${overBudget ? "bg-destructive" : "bg-green-600 dark:bg-green-400"}`}
className={`h-full rounded-full transition-all ${overBudget ? "bg-destructive" : "bg-success"}`}
style={{ width: `${pct}%` }}
/>
</div>
@@ -31,7 +31,7 @@ export function ConfirmDelete({
onCancel: () => void;
}) {
return (
<div className="flex items-center gap-3 rounded-lg border border-[color:var(--destructive)] bg-red-100 dark:bg-red-900/50 px-4 py-3">
<div className="flex items-center gap-3 rounded-lg border border-[color:var(--destructive)] bg-destructive-muted px-4 py-3">
<span className="text-[13px] text-destructive">Delete {entityLabel}?</span>
<ActionButton label="Yes, delete" tone="danger" onClick={onConfirm} icon={Trash} />
<ActionButton label="Cancel" onClick={onCancel} icon={XCircle} />
@@ -118,7 +118,7 @@ export function HeartbeatSection({
}
/>
{persistedConnectorIssue ? (
<p className="mt-2 text-[12px] text-yellow-600 dark:text-yellow-400">
<p className="mt-2 text-[12px] text-warning">
{persistedConnectorIssue} Save a ready connector before waking this agent again.
</p>
) : null}
+2 -2
View File
@@ -82,7 +82,7 @@ export function RunDetail({
<DetailRow label="Cost">{formatMoney(selectedRun.costUsd)}</DetailRow>
</div>
{selectedRun.errorMessage ? (
<div className="mt-4 rounded-lg border border-[color:var(--destructive)] bg-red-100 dark:bg-red-900/50 px-4 py-3 text-[13px] text-destructive">
<div className="mt-4 rounded-lg border border-[color:var(--destructive)] bg-destructive-muted px-4 py-3 text-[13px] text-destructive">
{selectedRun.errorMessage}
</div>
) : null}
@@ -118,7 +118,7 @@ export function RunDetail({
)}
>
{logState.error ? (
<div className="rounded-lg border border-[color:var(--destructive)] bg-red-100 dark:bg-red-900/50 px-4 py-3 text-[13px] text-destructive">
<div className="rounded-lg border border-[color:var(--destructive)] bg-destructive-muted px-4 py-3 text-[13px] text-destructive">
{logState.error}
</div>
) : null}
@@ -28,7 +28,7 @@ export function AccountCard({ account, onLogin, onDelete, onClick }: AccountCard
<div className="mt-0.5 flex items-center gap-2 text-[11px] text-muted-foreground">
<span className="truncate">@{account.accountName}</span>
{account.requireApproval ? (
<span className="flex items-center gap-1 text-yellow-600 dark:text-yellow-400">
<span className="flex items-center gap-1 text-warning">
<ShieldCheck size={10} />
Approval required
</span>
@@ -58,16 +58,16 @@ export function AccountCard({ account, onLogin, onDelete, onClick }: AccountCard
<div className="flex items-center gap-2">
<span className={`flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[10px] font-semibold ${
isLoggedIn
? "bg-green-100 dark:bg-green-900/50 text-green-600 dark:text-green-400"
? "bg-success-muted text-success"
: account.status === "login_required"
? "bg-yellow-100 dark:bg-yellow-900/50 text-yellow-600 dark:text-yellow-400"
? "bg-warning-muted text-warning"
: account.status === "suspended"
? "bg-red-100 dark:bg-red-900/50 text-destructive"
? "bg-destructive-muted text-destructive"
: "bg-muted text-muted-foreground"
}`}>
<span className={`h-1.5 w-1.5 rounded-full ${
isLoggedIn ? "bg-green-600 dark:bg-green-400"
: account.status === "login_required" ? "bg-yellow-600 dark:bg-yellow-400"
isLoggedIn ? "bg-success"
: account.status === "login_required" ? "bg-warning"
: account.status === "suspended" ? "bg-destructive"
: "bg-[color:var(--muted-foreground)]"
}`} />
@@ -89,7 +89,7 @@ export function AccountCard({ account, onLogin, onDelete, onClick }: AccountCard
type="button"
onClick={onDelete}
aria-label="Delete account"
className="rounded-lg p-1.5 text-muted-foreground opacity-0 transition hover:bg-red-100 dark:bg-red-900/50 hover:text-destructive group-hover:opacity-100 group-focus-within:opacity-100"
className="rounded-lg p-1.5 text-muted-foreground opacity-0 transition hover:bg-destructive-muted hover:text-destructive group-hover:opacity-100 group-focus-within:opacity-100"
>
<Trash size={14} />
</button>
@@ -40,7 +40,7 @@ export function ActionHistory({ actions, agents, onOpenPath, onCancelAction, onN
className="flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-2.5 text-[12px]"
>
<Icon size={16} className={
isSuccess ? "text-green-600 dark:text-green-400"
isSuccess ? "text-success"
: isFailed ? "text-destructive"
: isRunning ? "text-primary"
: "text-muted-foreground"
@@ -94,7 +94,7 @@ export function ActionHistory({ actions, agents, onOpenPath, onCancelAction, onN
<button
type="button"
onClick={() => void onCancelAction(action.id)}
className="inline-flex items-center gap-1 rounded-[5px] bg-red-100 dark:bg-red-900/50 px-2 py-1 text-[10px] font-medium text-destructive transition hover:brightness-95"
className="inline-flex items-center gap-1 rounded-[5px] bg-destructive-muted px-2 py-1 text-[10px] font-medium text-destructive transition hover:brightness-95"
>
<ClockCounterClockwise size={10} />
Cancel
@@ -106,10 +106,10 @@ export function ActionHistory({ actions, agents, onOpenPath, onCancelAction, onN
{timeAgo(action.createdAt)}
</div>
<span className={`shrink-0 rounded-full px-2 py-0.5 text-[9px] font-bold ${
isSuccess ? "bg-green-100 dark:bg-green-900/50 text-green-600 dark:text-green-400"
: isFailed ? "bg-red-100 dark:bg-red-900/50 text-destructive"
isSuccess ? "bg-success-muted text-success"
: isFailed ? "bg-destructive-muted text-destructive"
: isRunning ? "bg-accent text-primary"
: action.status === "approval_required" ? "bg-yellow-100 dark:bg-yellow-900/50 text-yellow-600 dark:text-yellow-400"
: action.status === "approval_required" ? "bg-warning-muted text-warning"
: "bg-muted text-muted-foreground"
}`}>
{action.status.replace("_", " ")}
+98 -59
View File
@@ -1,8 +1,11 @@
import { Component, useEffect, useRef, type ElementType, type ErrorInfo, type ReactNode } from "react";
import { AnimatePresence, motion } from "framer-motion";
const EASE = [0.16, 1, 0.3, 1] as const;
export function Badge({ count, tone = "default" }: { count: number; tone?: "default" | "warn" | "danger" }) {
if (count <= 0) return null;
const toneClass = tone === "danger" ? "bg-destructive text-primary-foreground" : tone === "warn" ? "bg-yellow-600 dark:bg-yellow-400 text-primary-foreground" : "bg-[color:var(--muted-foreground)] text-primary-foreground";
const toneClass = tone === "danger" ? "bg-destructive text-primary-foreground" : tone === "warn" ? "bg-warning text-primary-foreground" : "bg-[color:var(--muted-foreground)] text-primary-foreground";
return (
<span className={`ml-auto inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-full px-1.5 text-[10px] font-bold ${toneClass}`}>
{count > 99 ? "99+" : count}
@@ -43,7 +46,7 @@ export function SectionButton({
<Icon size={18} weight={active ? "fill" : "regular"} className={active ? "text-primary" : ""} />
{badge ? (
<span className={`absolute right-1 top-1 h-[6px] w-[6px] rounded-full ${
badgeTone === "danger" ? "bg-destructive" : badgeTone === "warn" ? "bg-yellow-600 dark:bg-yellow-400" : "bg-[color:var(--muted-foreground)]"
badgeTone === "danger" ? "bg-destructive" : badgeTone === "warn" ? "bg-warning" : "bg-[color:var(--muted-foreground)]"
}`} />
) : null}
</button>
@@ -59,7 +62,7 @@ export function SectionButton({
}`}
onClick={onClick}
>
{active ? <span className="absolute left-0 top-1/2 h-4 w-[3px] -translate-y-1/2 rounded-r-full bg-primary" aria-hidden="true" /> : null}
<span className={`absolute left-0 top-1/2 h-4 w-[3px] -translate-y-1/2 rounded-r-full bg-primary transition-all duration-200 ${active ? "opacity-100 scale-y-100" : "opacity-0 scale-y-0"}`} aria-hidden="true" />
<Icon size={17} weight={active ? "fill" : "regular"} className={active ? "text-primary" : ""} />
<span className={`text-[13px] tracking-[0.005em] ${active ? "font-bold" : "font-medium"}`}>{label}</span>
{badge ? <Badge count={badge} tone={badgeTone} /> : null}
@@ -120,32 +123,46 @@ export function Modal({
return () => document.removeEventListener("keydown", onKey);
}, [open, onClose]);
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[color:var(--foreground)]/45" onClick={onClose}>
<div
ref={trapRef}
role="dialog"
aria-modal="true"
aria-label={title}
className="flex w-full flex-col rounded-lg border border-border bg-card shadow-xl"
style={{ maxWidth: width, maxHeight: "85vh" }}
onClick={(e) => e.stopPropagation()}
>
<div className="flex shrink-0 items-center justify-between border-b border-border px-5 py-3.5">
<h2 className="min-w-0 truncate text-[16px] font-bold tracking-[-0.015em] text-foreground">{title}</h2>
<button
type="button"
className="ml-3 shrink-0 cursor-pointer rounded-lg p-2 text-muted-foreground transition hover:bg-muted hover:text-foreground"
onClick={(e) => { e.stopPropagation(); onClose(); }}
aria-label="Close"
<AnimatePresence>
{open ? (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.18, ease: EASE }}
className="fixed inset-0 z-50 flex items-center justify-center bg-[color:var(--foreground)]/45"
onClick={onClose}
>
<motion.div
ref={trapRef}
role="dialog"
aria-modal="true"
aria-label={title}
initial={{ scale: 0.96, opacity: 0, y: 8 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
exit={{ scale: 0.96, opacity: 0, y: 8 }}
transition={{ duration: 0.22, ease: EASE }}
className="flex w-full flex-col rounded-lg border border-border bg-card shadow-xl"
style={{ maxWidth: width, maxHeight: "85vh" }}
onClick={(e) => e.stopPropagation()}
>
<svg width="16" height="16" viewBox="0 0 14 14" fill="none"><path d="M1 1l12 12M13 1L1 13" stroke="currentColor" strokeWidth="2" strokeLinecap="round" /></svg>
</button>
</div>
<div className="min-h-0 min-w-0 overflow-y-auto overflow-x-auto px-5 py-4" style={{ overflowWrap: "break-word", wordBreak: "break-word" }}>{children}</div>
</div>
</div>
<div className="flex shrink-0 items-center justify-between border-b border-border px-5 py-3.5">
<h2 className="min-w-0 truncate text-[16px] font-bold tracking-[-0.015em] text-foreground">{title}</h2>
<button
type="button"
className="ml-3 flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-lg text-muted-foreground transition hover:bg-muted hover:text-foreground"
onClick={(e) => { e.stopPropagation(); onClose(); }}
aria-label="Close"
>
<svg width="16" height="16" viewBox="0 0 14 14" fill="none"><path d="M1 1l12 12M13 1L1 13" stroke="currentColor" strokeWidth="2" strokeLinecap="round" /></svg>
</button>
</div>
<div className="min-h-0 min-w-0 overflow-y-auto overflow-x-auto px-5 py-4" style={{ overflowWrap: "break-word", wordBreak: "break-word" }}>{children}</div>
</motion.div>
</motion.div>
) : null}
</AnimatePresence>
);
}
@@ -166,6 +183,7 @@ export function Input({
readOnly = false,
disabled = false,
label,
hint,
id,
ariaLabel,
}: {
@@ -176,6 +194,7 @@ export function Input({
readOnly?: boolean;
disabled?: boolean;
label?: string;
hint?: string;
id?: string;
ariaLabel?: string;
}) {
@@ -195,7 +214,10 @@ export function Input({
if (label) {
return (
<label className="block">
<div className="mb-1.5 text-[12px] font-medium text-muted-foreground">{label}</div>
<div className="mb-1.5 flex items-center gap-1.5 text-[12px] font-medium text-muted-foreground">
{label}
{hint ? <span title={hint} className="cursor-help text-[10px] text-muted-foreground/60 hover:text-muted-foreground">(?)</span> : null}
</div>
{input}
</label>
);
@@ -209,6 +231,7 @@ export function TextArea({
placeholder,
rows = 4,
label,
hint,
readOnly = false,
disabled = false,
}: {
@@ -217,6 +240,7 @@ export function TextArea({
placeholder?: string;
rows?: number;
label?: string;
hint?: string;
readOnly?: boolean;
disabled?: boolean;
}) {
@@ -235,7 +259,10 @@ export function TextArea({
if (label) {
return (
<label className="block">
<div className="mb-1.5 text-[12px] font-medium text-muted-foreground">{label}</div>
<div className="mb-1.5 flex items-center gap-1.5 text-[12px] font-medium text-muted-foreground">
{label}
{hint ? <span title={hint} className="cursor-help text-[10px] text-muted-foreground/60 hover:text-muted-foreground">(?)</span> : null}
</div>
{textarea}
</label>
);
@@ -249,6 +276,7 @@ export function Select({
options,
compact = false,
label,
hint,
disabled = false,
}: {
value: string;
@@ -256,6 +284,7 @@ export function Select({
options: Array<{ value: string; label: string }>;
compact?: boolean;
label?: string;
hint?: string;
disabled?: boolean;
}) {
const select = (
@@ -279,7 +308,10 @@ export function Select({
if (label) {
return (
<label className="block">
<div className="mb-1.5 text-[12px] font-medium text-muted-foreground">{label}</div>
<div className="mb-1.5 flex items-center gap-1.5 text-[12px] font-medium text-muted-foreground">
{label}
{hint ? <span title={hint} className="cursor-help text-[10px] text-muted-foreground/60 hover:text-muted-foreground">(?)</span> : null}
</div>
{select}
</label>
);
@@ -312,7 +344,7 @@ export function ActionButton({
return (
<button
className={`focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 inline-flex items-center gap-2 rounded-lg border px-4 py-2.5 text-[12px] font-bold transition duration-150 active:translate-y-px ${toneClass} ${isDisabled ? "pointer-events-none opacity-60" : ""} ${loading ? "scale-[0.98]" : ""}`}
className={`focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 inline-flex items-center gap-2 rounded-lg border px-4 py-2.5 text-[12px] font-bold transition-all duration-150 active:scale-[0.97] active:translate-y-px ${toneClass} ${isDisabled ? "pointer-events-none opacity-60" : ""} ${loading ? "scale-[0.98]" : ""}`}
onClick={onClick}
disabled={isDisabled}
aria-busy={loading}
@@ -336,18 +368,18 @@ export function StatusPill({ status }: { status: string }) {
const warning = ["auth_required", "degraded", "pending", "in_review", "todo", "queued", "not_installed"].includes(status);
const checking = status === "detected";
const toneClass = positive
? "text-green-600 dark:text-green-400"
? "text-success"
: negative
? "text-destructive"
: warning
? "text-yellow-600 dark:text-yellow-400"
? "text-warning"
: "text-muted-foreground";
const dotClass = positive
? "bg-green-600 dark:bg-green-400"
? "bg-success"
: negative
? "bg-destructive"
: warning
? "bg-yellow-600 dark:bg-yellow-400"
? "bg-warning"
: "bg-[color:var(--muted-foreground)]";
const displayLabel = checking ? "checking" : status.replaceAll("_", " ");
@@ -374,10 +406,10 @@ export function InlineNotice({
action?: ReactNode;
}) {
const toneClass = tone === "success"
? "border-[color:var(--primary)] bg-green-100 dark:bg-green-900/50 text-green-600 dark:text-green-400"
? "border-[color:var(--primary)] bg-success-muted text-success"
: tone === "warn"
? "border-[color:var(--destructive)] bg-yellow-100 dark:bg-yellow-900/50 text-yellow-600 dark:text-yellow-400"
: "border-[color:var(--destructive)] bg-red-100 dark:bg-red-900/50 text-destructive";
? "border-[color:var(--warning)] bg-warning-muted text-warning"
: "border-[color:var(--destructive)] bg-destructive-muted text-destructive";
return (
<div className={`rounded-lg border px-4 py-3 text-[13px] ${toneClass}`} role={tone === "danger" ? "alert" : "status"}>
{action ? (
@@ -409,26 +441,33 @@ export function ConfirmDialog({ open, title, message, errorMessage, onConfirm, o
return () => document.removeEventListener("keydown", onKey);
}, [open, onCancel]);
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[color:var(--foreground)]/45" onClick={onCancel}>
<div
ref={trapRef}
role="dialog"
aria-modal="true"
aria-labelledby="confirm-dialog-title"
className="w-full max-w-[400px] rounded-lg bg-card border border-border p-6 shadow-lg"
onClick={e => e.stopPropagation()}
>
<div id="confirm-dialog-title" className="text-[16px] font-semibold text-foreground mb-2">{title}</div>
<div className="text-[13px] text-muted-foreground mb-6">{message}</div>
{errorMessage ? <div className="mb-4"><InlineNotice message={errorMessage} /></div> : null}
<div className="flex gap-3 justify-end">
<ActionButton label="Cancel" onClick={onCancel} />
<ActionButton label="Confirm" tone="danger" onClick={onConfirm} />
</div>
</div>
</div>
<AnimatePresence>
{open ? (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.15, ease: EASE }} className="fixed inset-0 z-50 flex items-center justify-center bg-[color:var(--foreground)]/45" onClick={onCancel}>
<motion.div
ref={trapRef}
role="dialog"
aria-modal="true"
aria-labelledby="confirm-dialog-title"
initial={{ scale: 0.96, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.96, opacity: 0 }}
transition={{ duration: 0.18, ease: EASE }}
className="w-full max-w-[400px] rounded-lg bg-card border border-border p-6 shadow-lg"
onClick={e => e.stopPropagation()}
>
<div id="confirm-dialog-title" className="text-[16px] font-semibold text-foreground mb-2">{title}</div>
<div className="text-[13px] text-muted-foreground mb-6">{message}</div>
{errorMessage ? <div className="mb-4"><InlineNotice message={errorMessage} /></div> : null}
<div className="flex gap-3 justify-end">
<ActionButton label="Cancel" onClick={onCancel} />
<ActionButton label="Confirm" tone="danger" onClick={onConfirm} />
</div>
</motion.div>
</motion.div>
) : null}
</AnimatePresence>
);
}
@@ -442,11 +481,11 @@ export function EmptyState({
action?: ReactNode;
}) {
return (
<div className="px-1 py-8 text-center">
<motion.div initial={{ opacity: 0, y: 6 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.22, ease: EASE }} className="px-1 py-8 text-center">
<div className="mb-2 text-[15px] font-semibold text-foreground">{title}</div>
{detail ? <div className="mx-auto max-w-[48ch] text-[13px] leading-relaxed text-muted-foreground">{detail}</div> : null}
{action ? <div className="mt-5">{action}</div> : null}
</div>
</motion.div>
);
}