UI: mobile responsive layout, streamline agent budget display, and xs avatar size
Make agents list force list view on mobile with condensed trailing info. Add mobile bottom bar for config save/cancel and live run indicator on agent detail. Make MetricCard, PageTabBar, Dashboard tasks, and ActivityRow responsive for small screens. Add xs avatar size for inline text flow. Remove redundant budget displays from agent overview, properties panel, costs tab, and config form. Add attachment activity verb labels. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,8 @@ const ACTION_VERBS: Record<string, string> = {
|
||||
"issue.checked_out": "checked out",
|
||||
"issue.released": "released",
|
||||
"issue.comment_added": "commented on",
|
||||
"issue.attachment_added": "attached file to",
|
||||
"issue.attachment_removed": "removed attachment from",
|
||||
"issue.commented": "commented on",
|
||||
"issue.deleted": "deleted",
|
||||
"agent.created": "created",
|
||||
@@ -105,23 +107,24 @@ export function ActivityRow({ event, agentMap, entityNameMap, className }: Activ
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"px-4 py-2 flex flex-wrap items-center justify-between gap-x-2 gap-y-0.5 text-sm",
|
||||
"px-4 py-2 text-sm",
|
||||
link && "cursor-pointer hover:bg-accent/50 transition-colors",
|
||||
className,
|
||||
)}
|
||||
onClick={link ? () => navigate(link) : undefined}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<Identity
|
||||
name={actor?.name ?? (event.actorType === "system" ? "System" : event.actorId || "You")}
|
||||
size="sm"
|
||||
/>
|
||||
<span className="text-muted-foreground shrink-0">{verb}</span>
|
||||
{name && <span className="truncate">{name}</span>}
|
||||
<div className="flex gap-3">
|
||||
<p className="flex-1 min-w-0">
|
||||
<Identity
|
||||
name={actor?.name ?? (event.actorType === "system" ? "System" : event.actorId || "You")}
|
||||
size="xs"
|
||||
className="align-baseline"
|
||||
/>
|
||||
<span className="text-muted-foreground ml-1">{verb} </span>
|
||||
{name && <span className="font-medium">{name}</span>}
|
||||
</p>
|
||||
<span className="text-xs text-muted-foreground shrink-0 pt-0.5">{timeAgo(event.createdAt)}</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
{timeAgo(event.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -716,26 +716,6 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ---- Runtime (edit only) ---- */}
|
||||
{!isCreate && (
|
||||
<div className="border-b border-border">
|
||||
<div className="px-4 py-2 text-xs font-medium text-muted-foreground">Runtime</div>
|
||||
<div className="px-4 pb-3 space-y-3">
|
||||
<Field label="Monthly budget (cents)" hint={help.budgetMonthlyCents}>
|
||||
<DraftNumberInput
|
||||
value={eff(
|
||||
"runtime",
|
||||
"budgetMonthlyCents",
|
||||
props.agent.budgetMonthlyCents,
|
||||
)}
|
||||
onCommit={(v) => mark("runtime", "budgetMonthlyCents", v)}
|
||||
immediate
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useCompany } from "../context/CompanyContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { StatusBadge } from "./StatusBadge";
|
||||
import { Identity } from "./Identity";
|
||||
import { formatCents, formatDate } from "../lib/utils";
|
||||
import { formatDate } from "../lib/utils";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
interface AgentPropertiesProps {
|
||||
@@ -62,24 +62,6 @@ export function AgentProperties({ agent, runtimeState }: AgentPropertiesProps) {
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-1">
|
||||
<PropertyRow label="Budget">
|
||||
<span className="text-sm">
|
||||
{formatCents(agent.spentMonthlyCents)} / {formatCents(agent.budgetMonthlyCents)}
|
||||
</span>
|
||||
</PropertyRow>
|
||||
<PropertyRow label="Utilization">
|
||||
<span className="text-sm">
|
||||
{agent.budgetMonthlyCents > 0
|
||||
? Math.round((agent.spentMonthlyCents / agent.budgetMonthlyCents) * 100)
|
||||
: 0}
|
||||
%
|
||||
</span>
|
||||
</PropertyRow>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-1">
|
||||
{(runtimeState?.sessionDisplayId ?? runtimeState?.sessionId) && (
|
||||
<PropertyRow label="Session">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
|
||||
type IdentitySize = "sm" | "default" | "lg";
|
||||
type IdentitySize = "xs" | "sm" | "default" | "lg";
|
||||
|
||||
export interface IdentityProps {
|
||||
name: string;
|
||||
@@ -18,6 +18,7 @@ function deriveInitials(name: string): string {
|
||||
}
|
||||
|
||||
const textSize: Record<IdentitySize, string> = {
|
||||
xs: "text-sm",
|
||||
sm: "text-xs",
|
||||
default: "text-sm",
|
||||
lg: "text-sm",
|
||||
@@ -27,8 +28,8 @@ export function Identity({ name, avatarUrl, initials, size = "default", classNam
|
||||
const displayInitials = initials ?? deriveInitials(name);
|
||||
|
||||
return (
|
||||
<span className={cn("inline-flex items-center gap-1.5", size === "lg" && "gap-2", className)}>
|
||||
<Avatar size={size}>
|
||||
<span className={cn("inline-flex gap-1.5", size === "xs" ? "items-baseline gap-1" : "items-center", size === "lg" && "gap-2", className)}>
|
||||
<Avatar size={size} className={size === "xs" ? "relative top-[2px]" : undefined}>
|
||||
{avatarUrl && <AvatarImage src={avatarUrl} alt={name} />}
|
||||
<AvatarFallback>{displayInitials}</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
@@ -13,27 +13,27 @@ interface MetricCardProps {
|
||||
export function MetricCard({ icon: Icon, value, label, description, onClick }: MetricCardProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex gap-3">
|
||||
<CardContent className="p-3 sm:p-4">
|
||||
<div className="flex gap-2 sm:gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p
|
||||
className={`text-2xl font-bold${onClick ? " cursor-pointer" : ""}`}
|
||||
className={`text-lg sm:text-2xl font-bold${onClick ? " cursor-pointer" : ""}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{value}
|
||||
</p>
|
||||
<p
|
||||
className={`text-sm text-muted-foreground${onClick ? " cursor-pointer" : ""}`}
|
||||
className={`text-xs sm:text-sm text-muted-foreground${onClick ? " cursor-pointer" : ""}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{label}
|
||||
</p>
|
||||
{description && (
|
||||
<div className="text-xs text-muted-foreground mt-1">{description}</div>
|
||||
<div className="text-[11px] sm:text-xs text-muted-foreground mt-1 hidden sm:block">{description}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-muted p-2 rounded-md h-fit shrink-0">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
<div className="bg-muted p-1.5 sm:p-2 rounded-md h-fit shrink-0">
|
||||
<Icon className="h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -21,7 +21,7 @@ export function PageTabBar({ items, value, onValueChange }: PageTabBarProps) {
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onValueChange(e.target.value)}
|
||||
className="h-8 rounded-md border border-border bg-background px-2 py-1 text-sm focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
className="h-9 rounded-md border border-border bg-background px-2 py-1 text-base focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
{items.map((item) => (
|
||||
<option key={item.value} value={item.value}>
|
||||
|
||||
@@ -8,14 +8,14 @@ function Avatar({
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
|
||||
size?: "default" | "sm" | "lg"
|
||||
size?: "default" | "xs" | "sm" | "lg"
|
||||
}) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/avatar relative flex size-8 shrink-0 overflow-hidden rounded-full select-none data-[size=lg]:size-10 data-[size=sm]:size-6",
|
||||
"group/avatar relative flex size-8 shrink-0 overflow-hidden rounded-full select-none data-[size=lg]:size-10 data-[size=sm]:size-6 data-[size=xs]:size-5",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -44,7 +44,7 @@ function AvatarFallback({
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn(
|
||||
"bg-muted text-muted-foreground flex size-full items-center justify-center rounded-full text-sm group-data-[size=sm]/avatar:text-xs",
|
||||
"bg-muted text-muted-foreground flex size-full items-center justify-center rounded-full text-sm group-data-[size=sm]/avatar:text-xs group-data-[size=xs]/avatar:text-[10px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -177,6 +177,7 @@ export function AgentDetail() {
|
||||
const [configSaving, setConfigSaving] = useState(false);
|
||||
const saveConfigActionRef = useRef<(() => void) | null>(null);
|
||||
const cancelConfigActionRef = useRef<(() => void) | null>(null);
|
||||
const { isMobile } = useSidebar();
|
||||
const setSaveConfigAction = useCallback((fn: (() => void) | null) => { saveConfigActionRef.current = fn; }, []);
|
||||
const setCancelConfigAction = useCallback((fn: (() => void) | null) => { cancelConfigActionRef.current = fn; }, []);
|
||||
|
||||
@@ -213,6 +214,10 @@ export function AgentDetail() {
|
||||
const assignedIssues = (allIssues ?? []).filter((i) => i.assigneeAgentId === agentId);
|
||||
const reportsToAgent = (allAgents ?? []).find((a) => a.id === agent?.reportsTo);
|
||||
const directReports = (allAgents ?? []).filter((a) => a.reportsTo === agentId && a.status !== "terminated");
|
||||
const mobileLiveRun = useMemo(
|
||||
() => (heartbeats ?? []).find((r) => r.status === "running" || r.status === "queued") ?? null,
|
||||
[heartbeats],
|
||||
);
|
||||
|
||||
const agentAction = useMutation({
|
||||
mutationFn: async (action: "invoke" | "pause" | "resume" | "terminate") => {
|
||||
@@ -295,9 +300,10 @@ export function AgentDetail() {
|
||||
if (error) return <p className="text-sm text-destructive">{error.message}</p>;
|
||||
if (!agent) return null;
|
||||
const isPendingApproval = agent.status === "pending_approval";
|
||||
const showConfigActionBar = activeTab === "configuration" && configDirty;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className={cn("space-y-6", isMobile && showConfigActionBar && "pb-24")}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
@@ -347,6 +353,18 @@ export function AgentDetail() {
|
||||
</Button>
|
||||
)}
|
||||
<span className="hidden sm:inline"><StatusBadge status={agent.status} /></span>
|
||||
{mobileLiveRun && (
|
||||
<button
|
||||
className="sm:hidden flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-blue-500/10 hover:bg-blue-500/20 transition-colors"
|
||||
onClick={() => navigate(`/agents/${agent.id}/runs/${mobileLiveRun.id}`)}
|
||||
>
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
|
||||
</span>
|
||||
<span className="text-[11px] font-medium text-blue-400">Live</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Overflow menu */}
|
||||
<Popover open={moreOpen} onOpenChange={setMoreOpen}>
|
||||
@@ -398,33 +416,61 @@ export function AgentDetail() {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Floating Save/Cancel — sticky so it's always reachable when scrolled */}
|
||||
<div
|
||||
className={cn(
|
||||
"sticky top-6 z-10 float-right transition-opacity duration-150",
|
||||
activeTab === "configuration" && configDirty
|
||||
? "opacity-100"
|
||||
: "opacity-0 pointer-events-none"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-1.5 shadow-lg">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => cancelConfigActionRef.current?.()}
|
||||
disabled={configSaving}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => saveConfigActionRef.current?.()}
|
||||
disabled={configSaving}
|
||||
>
|
||||
{configSaving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
{/* Floating Save/Cancel (desktop) */}
|
||||
{!isMobile && (
|
||||
<div
|
||||
className={cn(
|
||||
"sticky top-6 z-10 float-right transition-opacity duration-150",
|
||||
showConfigActionBar
|
||||
? "opacity-100"
|
||||
: "opacity-0 pointer-events-none"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-1.5 shadow-lg">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => cancelConfigActionRef.current?.()}
|
||||
disabled={configSaving}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => saveConfigActionRef.current?.()}
|
||||
disabled={configSaving}
|
||||
>
|
||||
{configSaving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile bottom Save/Cancel bar */}
|
||||
{isMobile && showConfigActionBar && (
|
||||
<div className="fixed inset-x-0 bottom-0 z-30 border-t border-border bg-background/95 backdrop-blur-sm">
|
||||
<div
|
||||
className="flex items-center justify-end gap-2 px-3 py-2"
|
||||
style={{ paddingBottom: "max(env(safe-area-inset-bottom), 0.5rem)" }}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => cancelConfigActionRef.current?.()}
|
||||
disabled={configSaving}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => saveConfigActionRef.current?.()}
|
||||
disabled={configSaving}
|
||||
>
|
||||
{configSaving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<PageTabBar
|
||||
@@ -472,27 +518,6 @@ export function AgentDetail() {
|
||||
: <span className="text-muted-foreground">Never</span>
|
||||
}
|
||||
</SummaryRow>
|
||||
<SummaryRow label="Budget">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-16 h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full",
|
||||
(() => {
|
||||
const pct = agent.budgetMonthlyCents > 0
|
||||
? Math.round((agent.spentMonthlyCents / agent.budgetMonthlyCents) * 100)
|
||||
: 0;
|
||||
return pct > 90 ? "bg-red-400" : pct > 70 ? "bg-yellow-400" : "bg-green-400";
|
||||
})(),
|
||||
)}
|
||||
style={{ width: `${Math.min(100, agent.budgetMonthlyCents > 0 ? Math.round((agent.spentMonthlyCents / agent.budgetMonthlyCents) * 100) : 0)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatCents(agent.spentMonthlyCents)} / {formatCents(agent.budgetMonthlyCents)}
|
||||
</span>
|
||||
</div>
|
||||
</SummaryRow>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -591,7 +616,7 @@ export function AgentDetail() {
|
||||
|
||||
{/* COSTS TAB */}
|
||||
<TabsContent value="costs" className="mt-4">
|
||||
<CostsTab agent={agent} runtimeState={runtimeState ?? undefined} runs={heartbeats ?? []} />
|
||||
<CostsTab runtimeState={runtimeState ?? undefined} runs={heartbeats ?? []} />
|
||||
</TabsContent>
|
||||
|
||||
{/* KEYS TAB */}
|
||||
@@ -1633,19 +1658,12 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
||||
/* ---- Costs Tab ---- */
|
||||
|
||||
function CostsTab({
|
||||
agent,
|
||||
runtimeState,
|
||||
runs,
|
||||
}: {
|
||||
agent: Agent;
|
||||
runtimeState?: AgentRuntimeState;
|
||||
runs: HeartbeatRun[];
|
||||
}) {
|
||||
const budgetPct =
|
||||
agent.budgetMonthlyCents > 0
|
||||
? Math.round((agent.spentMonthlyCents / agent.budgetMonthlyCents) * 100)
|
||||
: 0;
|
||||
|
||||
const runsWithCost = runs
|
||||
.filter((r) => {
|
||||
const u = r.usageJson as Record<string, unknown> | null;
|
||||
@@ -1680,27 +1698,6 @@ function CostsTab({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Monthly budget */}
|
||||
<div className="border border-border rounded-lg p-4">
|
||||
<h3 className="text-sm font-medium mb-3">Monthly Budget</h3>
|
||||
<div className="flex items-center justify-between text-sm mb-1">
|
||||
<span className="text-muted-foreground">Utilization</span>
|
||||
<span>
|
||||
{formatCents(agent.spentMonthlyCents)} / {formatCents(agent.budgetMonthlyCents)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-2 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full transition-all",
|
||||
budgetPct > 90 ? "bg-red-400" : budgetPct > 70 ? "bg-yellow-400" : "bg-green-400"
|
||||
)}
|
||||
style={{ width: `${Math.min(100, budgetPct)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">{budgetPct}% utilized</p>
|
||||
</div>
|
||||
|
||||
{/* Per-run cost table */}
|
||||
{runsWithCost.length > 0 && (
|
||||
<div>
|
||||
|
||||
@@ -6,11 +6,12 @@ import { heartbeatsApi } from "../api/heartbeats";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { StatusBadge } from "../components/StatusBadge";
|
||||
import { EntityRow } from "../components/EntityRow";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { formatCents, relativeTime, cn } from "../lib/utils";
|
||||
import { relativeTime, cn } from "../lib/utils";
|
||||
import { PageTabBar } from "../components/PageTabBar";
|
||||
import { Tabs } from "@/components/ui/tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -61,9 +62,12 @@ export function Agents() {
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { isMobile } = useSidebar();
|
||||
const pathSegment = location.pathname.split("/").pop() ?? "all";
|
||||
const tab: FilterTab = (pathSegment === "all" || pathSegment === "active" || pathSegment === "paused" || pathSegment === "error") ? pathSegment : "all";
|
||||
const [view, setView] = useState<"list" | "org">("org");
|
||||
const forceListView = isMobile;
|
||||
const effectiveView: "list" | "org" = forceListView ? "list" : view;
|
||||
const [showTerminated, setShowTerminated] = useState(false);
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
|
||||
@@ -76,7 +80,7 @@ export function Agents() {
|
||||
const { data: orgTree } = useQuery({
|
||||
queryKey: queryKeys.org(selectedCompanyId!),
|
||||
queryFn: () => agentsApi.org(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId && view === "org",
|
||||
enabled: !!selectedCompanyId && effectiveView === "org",
|
||||
});
|
||||
|
||||
const { data: runs } = useQuery({
|
||||
@@ -161,26 +165,28 @@ export function Agents() {
|
||||
)}
|
||||
</div>
|
||||
{/* View toggle */}
|
||||
<div className="flex items-center border border-border">
|
||||
<button
|
||||
className={cn(
|
||||
"p-1.5 transition-colors",
|
||||
view === "list" ? "bg-accent text-foreground" : "text-muted-foreground hover:bg-accent/50"
|
||||
)}
|
||||
onClick={() => setView("list")}
|
||||
>
|
||||
<List className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
className={cn(
|
||||
"p-1.5 transition-colors",
|
||||
view === "org" ? "bg-accent text-foreground" : "text-muted-foreground hover:bg-accent/50"
|
||||
)}
|
||||
onClick={() => setView("org")}
|
||||
>
|
||||
<GitBranch className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
{!forceListView && (
|
||||
<div className="flex items-center border border-border">
|
||||
<button
|
||||
className={cn(
|
||||
"p-1.5 transition-colors",
|
||||
effectiveView === "list" ? "bg-accent text-foreground" : "text-muted-foreground hover:bg-accent/50"
|
||||
)}
|
||||
onClick={() => setView("list")}
|
||||
>
|
||||
<List className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
className={cn(
|
||||
"p-1.5 transition-colors",
|
||||
effectiveView === "org" ? "bg-accent text-foreground" : "text-muted-foreground hover:bg-accent/50"
|
||||
)}
|
||||
onClick={() => setView("org")}
|
||||
>
|
||||
<GitBranch className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<Button size="sm" onClick={openNewAgent}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1.5" />
|
||||
New Agent
|
||||
@@ -205,14 +211,9 @@ export function Agents() {
|
||||
)}
|
||||
|
||||
{/* List view */}
|
||||
{view === "list" && filtered.length > 0 && (
|
||||
{effectiveView === "list" && filtered.length > 0 && (
|
||||
<div className="border border-border">
|
||||
{filtered.map((agent) => {
|
||||
const budgetPct =
|
||||
agent.budgetMonthlyCents > 0
|
||||
? Math.round((agent.spentMonthlyCents / agent.budgetMonthlyCents) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<EntityRow
|
||||
key={agent.id}
|
||||
@@ -240,39 +241,35 @@ export function Agents() {
|
||||
}
|
||||
trailing={
|
||||
<div className="flex items-center gap-3">
|
||||
{liveRunByAgent.has(agent.id) && (
|
||||
<LiveRunIndicator
|
||||
agentId={agent.id}
|
||||
runId={liveRunByAgent.get(agent.id)!.runId}
|
||||
navigate={navigate}
|
||||
/>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground font-mono w-14 text-right">
|
||||
{adapterLabels[agent.adapterType] ?? agent.adapterType}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground w-16 text-right">
|
||||
{agent.lastHeartbeatAt ? relativeTime(agent.lastHeartbeatAt) : "—"}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-16 h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${
|
||||
budgetPct > 90
|
||||
? "bg-red-400"
|
||||
: budgetPct > 70
|
||||
? "bg-yellow-400"
|
||||
: "bg-green-400"
|
||||
}`}
|
||||
style={{ width: `${Math.min(100, budgetPct)}%` }}
|
||||
<span className="sm:hidden">
|
||||
{liveRunByAgent.has(agent.id) ? (
|
||||
<LiveRunIndicator
|
||||
agentId={agent.id}
|
||||
runId={liveRunByAgent.get(agent.id)!.runId}
|
||||
navigate={navigate}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground w-24 text-right">
|
||||
{formatCents(agent.spentMonthlyCents)} / {formatCents(agent.budgetMonthlyCents)}
|
||||
) : (
|
||||
<StatusBadge status={agent.status} />
|
||||
)}
|
||||
</span>
|
||||
<div className="hidden sm:flex items-center gap-3">
|
||||
{liveRunByAgent.has(agent.id) && (
|
||||
<LiveRunIndicator
|
||||
agentId={agent.id}
|
||||
runId={liveRunByAgent.get(agent.id)!.runId}
|
||||
navigate={navigate}
|
||||
/>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground font-mono w-14 text-right">
|
||||
{adapterLabels[agent.adapterType] ?? agent.adapterType}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground w-16 text-right">
|
||||
{agent.lastHeartbeatAt ? relativeTime(agent.lastHeartbeatAt) : "—"}
|
||||
</span>
|
||||
<span className="w-20 flex justify-end">
|
||||
<StatusBadge status={agent.status} />
|
||||
</span>
|
||||
</div>
|
||||
<span className="w-20 flex justify-end">
|
||||
<StatusBadge status={agent.status} />
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
@@ -281,14 +278,14 @@ export function Agents() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view === "list" && agents && agents.length > 0 && filtered.length === 0 && (
|
||||
{effectiveView === "list" && agents && agents.length > 0 && filtered.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">
|
||||
No agents match the selected filter.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Org chart view */}
|
||||
{view === "org" && filteredOrg.length > 0 && (
|
||||
{effectiveView === "org" && filteredOrg.length > 0 && (
|
||||
<div className="border border-border py-1">
|
||||
{filteredOrg.map((node) => (
|
||||
<OrgTreeNode key={node.id} node={node} depth={0} navigate={navigate} agentMap={agentMap} liveRunByAgent={liveRunByAgent} />
|
||||
@@ -296,13 +293,13 @@ export function Agents() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view === "org" && orgTree && orgTree.length > 0 && filteredOrg.length === 0 && (
|
||||
{effectiveView === "org" && orgTree && orgTree.length > 0 && filteredOrg.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">
|
||||
No agents match the selected filter.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{view === "org" && orgTree && orgTree.length === 0 && (
|
||||
{effectiveView === "org" && orgTree && orgTree.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">
|
||||
No organizational hierarchy defined.
|
||||
</p>
|
||||
@@ -339,11 +336,6 @@ function OrgTreeNode({
|
||||
? "bg-red-400"
|
||||
: "bg-neutral-400";
|
||||
|
||||
const budgetPct =
|
||||
agent && agent.budgetMonthlyCents > 0
|
||||
? Math.round((agent.spentMonthlyCents / agent.budgetMonthlyCents) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div style={{ paddingLeft: depth * 24 }}>
|
||||
<button
|
||||
@@ -361,43 +353,39 @@ function OrgTreeNode({
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
{liveRunByAgent.has(node.id) && (
|
||||
<LiveRunIndicator
|
||||
agentId={node.id}
|
||||
runId={liveRunByAgent.get(node.id)!.runId}
|
||||
navigate={navigate}
|
||||
/>
|
||||
)}
|
||||
{agent && (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground font-mono w-14 text-right">
|
||||
{adapterLabels[agent.adapterType] ?? agent.adapterType}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground w-16 text-right">
|
||||
{agent.lastHeartbeatAt ? relativeTime(agent.lastHeartbeatAt) : "—"}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-16 h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${
|
||||
budgetPct > 90
|
||||
? "bg-red-400"
|
||||
: budgetPct > 70
|
||||
? "bg-yellow-400"
|
||||
: "bg-green-400"
|
||||
}`}
|
||||
style={{ width: `${Math.min(100, budgetPct)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground w-24 text-right">
|
||||
{formatCents(agent.spentMonthlyCents)} / {formatCents(agent.budgetMonthlyCents)}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<span className="w-20 flex justify-end">
|
||||
<StatusBadge status={node.status} />
|
||||
<span className="sm:hidden">
|
||||
{liveRunByAgent.has(node.id) ? (
|
||||
<LiveRunIndicator
|
||||
agentId={node.id}
|
||||
runId={liveRunByAgent.get(node.id)!.runId}
|
||||
navigate={navigate}
|
||||
/>
|
||||
) : (
|
||||
<StatusBadge status={node.status} />
|
||||
)}
|
||||
</span>
|
||||
<div className="hidden sm:flex items-center gap-3">
|
||||
{liveRunByAgent.has(node.id) && (
|
||||
<LiveRunIndicator
|
||||
agentId={node.id}
|
||||
runId={liveRunByAgent.get(node.id)!.runId}
|
||||
navigate={navigate}
|
||||
/>
|
||||
)}
|
||||
{agent && (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground font-mono w-14 text-right">
|
||||
{adapterLabels[agent.adapterType] ?? agent.adapterType}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground w-16 text-right">
|
||||
{agent.lastHeartbeatAt ? relativeTime(agent.lastHeartbeatAt) : "—"}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span className="w-20 flex justify-end">
|
||||
<StatusBadge status={node.status} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{node.reports && node.reports.length > 0 && (
|
||||
|
||||
@@ -175,7 +175,7 @@ export function Dashboard() {
|
||||
|
||||
{data && (
|
||||
<>
|
||||
<div className="grid md:grid-cols-2 xl:grid-cols-4 gap-4">
|
||||
<div className="grid grid-cols-2 xl:grid-cols-4 gap-2 sm:gap-4">
|
||||
<MetricCard
|
||||
icon={Bot}
|
||||
value={data.agents.active + data.agents.running + data.agents.paused + data.agents.error}
|
||||
@@ -263,21 +263,27 @@ export function Dashboard() {
|
||||
{recentIssues.slice(0, 10).map((issue) => (
|
||||
<div
|
||||
key={issue.id}
|
||||
className="px-4 py-2 flex items-center gap-2 text-sm cursor-pointer hover:bg-accent/50 transition-colors"
|
||||
className="px-4 py-2 text-sm cursor-pointer hover:bg-accent/50 transition-colors"
|
||||
onClick={() => navigate(`/issues/${issue.id}`)}
|
||||
>
|
||||
<PriorityIcon priority={issue.priority} />
|
||||
<StatusIcon status={issue.status} />
|
||||
<span className="truncate flex-1">{issue.title}</span>
|
||||
{issue.assigneeAgentId && (() => {
|
||||
const name = agentName(issue.assigneeAgentId);
|
||||
return name
|
||||
? <Identity name={name} size="sm" className="shrink-0 hidden sm:flex" />
|
||||
: <span className="text-xs text-muted-foreground font-mono shrink-0 hidden sm:inline">{issue.assigneeAgentId.slice(0, 8)}</span>;
|
||||
})()}
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
{timeAgo(issue.updatedAt)}
|
||||
</span>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex items-center gap-2 shrink-0 mt-0.5">
|
||||
<PriorityIcon priority={issue.priority} />
|
||||
<StatusIcon status={issue.status} />
|
||||
</div>
|
||||
<p className="min-w-0 flex-1">
|
||||
<span>{issue.title}</span>
|
||||
{issue.assigneeAgentId && (() => {
|
||||
const name = agentName(issue.assigneeAgentId);
|
||||
return name
|
||||
? <span className="hidden sm:inline"><Identity name={name} size="sm" className="ml-2 inline-flex" /></span>
|
||||
: null;
|
||||
})()}
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
{timeAgo(issue.updatedAt)}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -31,6 +31,8 @@ const ACTION_LABELS: Record<string, string> = {
|
||||
"issue.checked_out": "checked out the issue",
|
||||
"issue.released": "released the issue",
|
||||
"issue.comment_added": "added a comment",
|
||||
"issue.attachment_added": "added an attachment",
|
||||
"issue.attachment_removed": "removed an attachment",
|
||||
"issue.deleted": "deleted the issue",
|
||||
"agent.created": "created an agent",
|
||||
"agent.updated": "updated the agent",
|
||||
|
||||
Reference in New Issue
Block a user