First Release of Claw3D (#11)

Co-authored-by: iamlukethedev <iamlukethedev@users.noreply.github.com>
This commit is contained in:
Luke The Dev
2026-03-19 23:14:04 -05:00
committed by GitHub
parent 5ea96b2650
commit 4fa4f13558
431 changed files with 105438 additions and 14 deletions
@@ -0,0 +1,43 @@
import Image from "next/image";
import { useMemo } from "react";
import { buildAvatarDataUrl } from "@/lib/avatars/multiavatar";
type AgentAvatarProps = {
seed: string;
name: string;
avatarUrl?: string | null;
size?: number;
isSelected?: boolean;
};
export const AgentAvatar = ({
seed,
name,
avatarUrl,
size = 112,
isSelected = false,
}: AgentAvatarProps) => {
const src = useMemo(() => {
const trimmed = avatarUrl?.trim();
if (trimmed) return trimmed;
return buildAvatarDataUrl(seed);
}, [avatarUrl, seed]);
return (
<div
className={`flex items-center justify-center overflow-hidden rounded-full border border-border/80 bg-card transition-transform duration-300 ${isSelected ? "agent-avatar-selected scale-[1.02]" : ""}`}
style={{ width: size, height: size }}
>
<Image
className="pointer-events-none h-full w-full select-none"
src={src}
alt={`Avatar for ${name}`}
width={size}
height={size}
unoptimized
draggable={false}
/>
</div>
);
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,157 @@
"use client";
import { useState } from "react";
import { Shuffle } from "lucide-react";
import type { AgentCreateModalSubmitPayload } from "@/features/agents/creation/types";
import { AgentAvatar } from "@/features/agents/components/AgentAvatar";
import { randomUUID } from "@/lib/uuid";
type AgentCreateModalProps = {
open: boolean;
suggestedName: string;
busy?: boolean;
submitError?: string | null;
onClose: () => void;
onSubmit: (payload: AgentCreateModalSubmitPayload) => Promise<void> | void;
};
const fieldClassName =
"ui-input w-full rounded-md px-3 py-2 text-xs text-foreground outline-none";
const labelClassName =
"font-mono text-[11px] font-semibold tracking-[0.05em] text-muted-foreground";
const resolveInitialName = (suggestedName: string): string => {
const trimmed = suggestedName.trim();
if (!trimmed) return "New Agent";
return trimmed;
};
const AgentCreateModalContent = ({
suggestedName,
busy,
submitError,
onClose,
onSubmit,
}: Omit<AgentCreateModalProps, "open">) => {
const [name, setName] = useState(() => resolveInitialName(suggestedName));
const [avatarSeed, setAvatarSeed] = useState(() => randomUUID());
const canSubmit = name.trim().length > 0;
const handleSubmit = () => {
if (!canSubmit || busy) return;
const trimmedName = name.trim();
if (!trimmedName) return;
void onSubmit({ name: trimmedName, avatarSeed });
};
return (
<div
className="fixed inset-0 z-[120] flex items-center justify-center bg-background/80 p-4"
role="dialog"
aria-modal="true"
aria-label="Create agent"
onClick={busy ? undefined : onClose}
>
<form
className="ui-panel w-full max-w-2xl shadow-xs"
onSubmit={(event) => {
event.preventDefault();
handleSubmit();
}}
onClick={(event) => event.stopPropagation()}
data-testid="agent-create-modal"
>
<div className="flex items-center justify-between border-b border-border/35 px-6 py-6">
<div>
<div className="font-mono text-[11px] font-semibold tracking-[0.06em] text-muted-foreground">
New agent
</div>
<div className="mt-1 text-base font-semibold text-foreground">Launch agent</div>
<div className="mt-1 text-xs text-muted-foreground">Name it and activate immediately.</div>
</div>
<button
type="button"
className="ui-btn-ghost px-3 py-1.5 font-mono text-[11px] font-semibold tracking-[0.06em] disabled:cursor-not-allowed disabled:opacity-60"
onClick={onClose}
disabled={busy}
>
Close
</button>
</div>
<div className="grid gap-4 px-6 py-5">
<label className={labelClassName}>
Name
<input
aria-label="Agent name"
value={name}
onChange={(event) => setName(event.target.value)}
className={`mt-1 ${fieldClassName}`}
placeholder="My agent"
/>
</label>
<div className="-mt-2 text-[11px] text-muted-foreground">
You can rename this agent from the main chat header.
</div>
<div className="grid justify-items-center gap-2 border-t border-border/40 pt-3">
<div className={labelClassName}>Choose avatar</div>
<AgentAvatar
seed={avatarSeed}
name={name.trim() || "New Agent"}
size={64}
isSelected
/>
<button
type="button"
aria-label="Shuffle avatar selection"
className="ui-btn-secondary inline-flex items-center gap-2 px-3 py-2 text-xs text-muted-foreground"
onClick={() => setAvatarSeed(randomUUID())}
disabled={busy}
>
<Shuffle className="h-3.5 w-3.5" />
Shuffle
</button>
</div>
{submitError ? (
<div className="ui-alert-danger rounded-md px-3 py-2 text-xs">
{submitError}
</div>
) : null}
</div>
<div className="flex items-center justify-between border-t border-border/45 px-6 pb-4 pt-5">
<div className="text-[11px] text-muted-foreground">Authority can be configured after launch.</div>
<button
type="submit"
className="ui-btn-primary px-3 py-1.5 font-mono text-[11px] font-semibold tracking-[0.06em] disabled:cursor-not-allowed disabled:border-border disabled:bg-muted disabled:text-muted-foreground"
disabled={!canSubmit || busy}
>
{busy ? "Launching..." : "Launch agent"}
</button>
</div>
</form>
</div>
);
};
export const AgentCreateModal = ({
open,
suggestedName,
busy = false,
submitError = null,
onClose,
onSubmit,
}: AgentCreateModalProps) => {
if (!open) return null;
return (
<AgentCreateModalContent
suggestedName={suggestedName}
busy={busy}
submitError={submitError}
onClose={onClose}
onSubmit={onSubmit}
/>
);
};
@@ -0,0 +1,7 @@
"use client";
export { AgentBrainPanel, type AgentBrainPanelProps } from "@/features/agents/components/inspect/AgentBrainPanel";
export {
AgentSettingsPanel,
type AgentSettingsPanelProps,
} from "@/features/agents/components/inspect/AgentSettingsPanel";
@@ -0,0 +1,255 @@
"use client";
import { useMemo, useState } from "react";
import type { SkillStatusReport } from "@/lib/skills/types";
import {
buildAgentSkillsAllowlistSet,
buildSkillMissingDetails,
deriveAgentSkillDisplayState,
deriveAgentSkillsAccessMode,
deriveSkillReadinessState,
type AgentSkillDisplayState,
} from "@/lib/skills/presentation";
type SkillRowFilter = "all" | AgentSkillDisplayState;
type AgentSkillsPanelProps = {
skillsReport?: SkillStatusReport | null;
skillsLoading?: boolean;
skillsError?: string | null;
skillsBusy?: boolean;
skillsBusyKey?: string | null;
skillsAllowlist?: string[] | undefined;
onSetSkillEnabled: (skillName: string, enabled: boolean) => Promise<void> | void;
onOpenSystemSetup: (skillKey?: string) => void;
};
const FILTERS: Array<{ id: SkillRowFilter; label: string }> = [
{ id: "all", label: "All" },
{ id: "ready", label: "Ready" },
{ id: "setup-required", label: "Setup required" },
{ id: "not-supported", label: "Not supported" },
];
const DISPLAY_LABELS: Record<AgentSkillDisplayState, string> = {
ready: "Ready",
"setup-required": "Setup required",
"not-supported": "Not supported",
};
const DISPLAY_CLASSES: Record<AgentSkillDisplayState, string> = {
ready: "ui-badge-status-running",
"setup-required": "ui-badge-status-error",
"not-supported": "ui-badge-status-error",
};
const resolveHint = (
skill: SkillStatusReport["skills"][number],
displayState: AgentSkillDisplayState
): string | null => {
if (displayState === "ready") {
return null;
}
if (displayState === "not-supported") {
if (skill.blockedByAllowlist) {
return "Blocked by bundled skills policy.";
}
return buildSkillMissingDetails(skill).find((line) => line.startsWith("Requires OS:")) ?? "Not supported.";
}
const readiness = deriveSkillReadinessState(skill);
if (readiness === "disabled-globally") {
return "Disabled globally. Enable it in System setup.";
}
return buildSkillMissingDetails(skill)[0] ?? "Requires setup in System setup.";
};
export const AgentSkillsPanel = ({
skillsReport = null,
skillsLoading = false,
skillsError = null,
skillsBusy = false,
skillsBusyKey = null,
skillsAllowlist,
onSetSkillEnabled,
onOpenSystemSetup,
}: AgentSkillsPanelProps) => {
const [skillsFilter, setSkillsFilter] = useState("");
const [rowFilter, setRowFilter] = useState<SkillRowFilter>("all");
const skillEntries = useMemo(() => skillsReport?.skills ?? [], [skillsReport]);
const accessMode = deriveAgentSkillsAccessMode(skillsAllowlist);
const allowlistSet = useMemo(() => buildAgentSkillsAllowlistSet(skillsAllowlist), [skillsAllowlist]);
const anySkillBusy = skillsBusy || Boolean(skillsBusyKey);
const rows = useMemo(() => {
return skillEntries.map((skill) => {
const normalizedName = skill.name.trim();
const allowed =
accessMode === "all" ? true : accessMode === "none" ? false : allowlistSet.has(normalizedName);
const readiness = deriveSkillReadinessState(skill);
return {
skill,
allowed,
displayState: deriveAgentSkillDisplayState(readiness),
};
});
}, [accessMode, allowlistSet, skillEntries]);
const searchedRows = useMemo(() => {
const query = skillsFilter.trim().toLowerCase();
if (!query) {
return rows;
}
return rows.filter((entry) =>
[entry.skill.name, entry.skill.description, entry.skill.source, entry.skill.skillKey]
.join(" ")
.toLowerCase()
.includes(query)
);
}, [rows, skillsFilter]);
const filteredRows = useMemo(() => {
if (rowFilter === "all") {
return searchedRows;
}
return searchedRows.filter((entry) => entry.displayState === rowFilter);
}, [rowFilter, searchedRows]);
const filterCounts = useMemo(
() =>
searchedRows.reduce(
(counts, entry) => {
counts.all += 1;
counts[entry.displayState] += 1;
return counts;
},
{
all: 0,
ready: 0,
"setup-required": 0,
"not-supported": 0,
} satisfies Record<SkillRowFilter, number>
),
[searchedRows]
);
const enabledCount = useMemo(
() => rows.reduce((count, entry) => count + (entry.allowed ? 1 : 0), 0),
[rows]
);
return (
<section className="sidebar-section" data-testid="agent-settings-skills">
<div className="flex items-center justify-between gap-3">
<h3 className="sidebar-section-title">Skills</h3>
<div className="font-mono text-[10px] text-muted-foreground">
{enabledCount}/{skillEntries.length}
</div>
</div>
<div className="mt-2 text-[11px] text-muted-foreground">Skill access controls apply to this agent.</div>
{accessMode === "selected" ? (
<div className="mt-2 text-[10px] text-muted-foreground/80">
This agent is using selected skills only.
</div>
) : null}
<div className="mt-3">
<input
value={skillsFilter}
onChange={(event) => setSkillsFilter(event.target.value)}
placeholder="Search skills"
className="w-full rounded-md border border-border/60 bg-surface-1 px-3 py-2 text-[11px] text-foreground outline-none transition focus:border-border"
aria-label="Search skills"
/>
</div>
<div className="mt-2 flex flex-wrap gap-1">
{FILTERS.map((filter) => {
const selected = rowFilter === filter.id;
return (
<button
key={filter.id}
type="button"
className="ui-btn-secondary px-2 py-1 text-[9px] font-semibold disabled:cursor-not-allowed disabled:opacity-65"
data-active={selected ? "true" : "false"}
disabled={skillsLoading}
onClick={() => {
setRowFilter(filter.id);
}}
>
{filter.label} ({filterCounts[filter.id]})
</button>
);
})}
</div>
{skillsLoading ? <div className="mt-3 text-[11px] text-muted-foreground">Loading skills...</div> : null}
{!skillsLoading && skillsError ? (
<div className="ui-alert-danger mt-3 rounded-md px-3 py-2 text-xs">{skillsError}</div>
) : null}
{!skillsLoading && !skillsError && filteredRows.length === 0 ? (
<div className="mt-3 text-[11px] text-muted-foreground">No matching skills.</div>
) : null}
{!skillsLoading && !skillsError && filteredRows.length > 0 ? (
<div className="mt-3 flex flex-col gap-2">
{filteredRows.map((entry) => {
const statusLabel = DISPLAY_LABELS[entry.displayState];
const statusClassName = DISPLAY_CLASSES[entry.displayState];
const canConfigureInSystem = entry.displayState === "setup-required";
const switchDisabled = anySkillBusy || entry.displayState === "not-supported";
return (
<div
key={`${entry.skill.source}:${entry.skill.skillKey}`}
className="ui-settings-row flex min-h-[68px] flex-col gap-3 px-4 py-3 sm:flex-row sm:items-start sm:justify-between"
>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-[11px] font-medium text-foreground/88">{entry.skill.name}</span>
<span className="rounded bg-surface-2 px-1.5 py-0.5 font-mono text-[9px] text-muted-foreground">
{entry.skill.source}
</span>
<span
className={`rounded border px-1.5 py-0.5 font-mono text-[10px] font-semibold ${statusClassName}`}
>
{statusLabel}
</span>
</div>
<div className="mt-1 text-[10px] text-muted-foreground/70">{entry.skill.description}</div>
{entry.displayState !== "ready" ? (
<div className="mt-1 text-[10px] text-muted-foreground/80">
{resolveHint(entry.skill, entry.displayState)}
</div>
) : null}
</div>
<div className="flex w-full items-center justify-between gap-2 sm:w-[240px] sm:justify-end">
<button
type="button"
role="switch"
aria-label={`Skill ${entry.skill.name}`}
aria-checked={entry.allowed}
className={`ui-switch self-start ${entry.allowed ? "ui-switch--on" : ""}`}
disabled={switchDisabled}
onClick={() => {
void onSetSkillEnabled(entry.skill.name, !entry.allowed);
}}
>
<span className="ui-switch-thumb" />
</button>
{canConfigureInSystem ? (
<button
type="button"
className="ui-btn-secondary px-2 py-1 text-[9px] font-semibold"
onClick={() => {
onOpenSystemSetup(entry.skill.skillKey);
}}
>
Open System Setup
</button>
) : null}
</div>
</div>
);
})}
</div>
) : null}
</section>
);
};
@@ -0,0 +1,235 @@
"use client";
import { useEffect } from "react";
import type { SkillStatusEntry } from "@/lib/skills/types";
import {
buildSkillMissingDetails,
canRemoveSkill,
deriveSkillReadinessState,
resolvePreferredInstallOption,
} from "@/lib/skills/presentation";
type SkillSetupMessage = { kind: "success" | "error"; message: string };
type AgentSkillsSetupModalProps = {
skill: SkillStatusEntry | null;
skillsBusy: boolean;
skillsBusyKey: string | null;
skillMessage: SkillSetupMessage | null;
apiKeyDraft: string;
defaultAgentScopeWarning?: string | null;
onClose: () => void;
onInstallSkill: (skillKey: string, name: string, installId: string) => Promise<void> | void;
onSetSkillGlobalEnabled: (skillKey: string, enabled: boolean) => Promise<void> | void;
onRemoveSkill: (
skill: { skillKey: string; source: string; baseDir: string }
) => Promise<void> | void;
onSkillApiKeyChange: (skillKey: string, value: string) => Promise<void> | void;
onSaveSkillApiKey: (skillKey: string) => Promise<void> | void;
};
const READINESS_LABELS = {
ready: "Ready",
"needs-setup": "Needs setup",
unavailable: "Unavailable",
"disabled-globally": "Disabled globally",
} as const;
const READINESS_CLASSES = {
ready: "ui-badge-status-running",
"needs-setup": "ui-badge-status-error",
unavailable: "ui-badge-status-error",
"disabled-globally": "ui-badge-status-error",
} as const;
export const AgentSkillsSetupModal = ({
skill,
skillsBusy,
skillsBusyKey,
skillMessage,
apiKeyDraft,
defaultAgentScopeWarning = null,
onClose,
onInstallSkill,
onSetSkillGlobalEnabled,
onRemoveSkill,
onSkillApiKeyChange,
onSaveSkillApiKey,
}: AgentSkillsSetupModalProps) => {
useEffect(() => {
if (!skill) {
return;
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape") {
return;
}
event.preventDefault();
onClose();
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [onClose, skill]);
if (!skill) {
return null;
}
const readiness = deriveSkillReadinessState(skill);
const readinessLabel = READINESS_LABELS[readiness];
const readinessClassName = READINESS_CLASSES[readiness];
const missingDetails = buildSkillMissingDetails(skill);
const installOption = resolvePreferredInstallOption(skill);
const canDeleteSkill = canRemoveSkill(skill);
const busyForSkill = skillsBusyKey === skill.skillKey;
const anySkillBusy = skillsBusy || Boolean(skillsBusyKey);
const trimmedApiKey = apiKeyDraft.trim();
return (
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/80 p-4"
role="dialog"
aria-modal="true"
aria-label={`Setup ${skill.name}`}
onClick={onClose}
>
<div
className="ui-panel w-full max-w-2xl bg-card shadow-xs"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-start justify-between gap-3 px-6 py-5">
<div className="min-w-0">
<div className="text-[11px] font-medium tracking-[0.01em] text-muted-foreground/80">
System setup
</div>
<div className="mt-1 flex flex-wrap items-center gap-2">
<span className="text-base font-semibold text-foreground">{skill.name}</span>
<span
className={`rounded border px-1.5 py-0.5 font-mono text-[10px] font-semibold ${readinessClassName}`}
>
{readinessLabel}
</span>
</div>
<div className="mt-2 text-[10px] text-muted-foreground/80">
Changes affect all agents on this gateway.
</div>
</div>
<button
type="button"
className="sidebar-btn-ghost px-3 font-mono text-[10px] font-semibold tracking-[0.06em]"
onClick={onClose}
>
Close
</button>
</div>
<div className="space-y-3 px-6 pb-3 text-[11px] text-muted-foreground">
{defaultAgentScopeWarning ? (
<div className="rounded-md border border-border/60 bg-surface-1/65 px-3 py-2 text-[10px] text-muted-foreground/80">
{defaultAgentScopeWarning}
</div>
) : null}
<div>{skill.description}</div>
{skill.blockedByAllowlist ? (
<div className="text-[10px] text-muted-foreground/80">
Blocked by bundled skills policy (`skills.allowBundled`).
</div>
) : null}
{missingDetails.map((line) => (
<div key={`${skill.skillKey}:${line}`} className="text-[10px] text-muted-foreground/80">
{line}
</div>
))}
{skillMessage ? (
<div
className={`text-[10px] ${skillMessage.kind === "error" ? "ui-text-danger" : "ui-text-success"}`}
>
{skillMessage.message}
</div>
) : null}
<div className="space-y-2 rounded-md border border-border/60 bg-surface-1/65 px-3 py-3">
{installOption ? (
<button
type="button"
className="ui-btn-secondary w-full px-3 py-2 text-[10px] font-medium disabled:cursor-not-allowed disabled:opacity-65"
disabled={anySkillBusy}
onClick={() => {
void onInstallSkill(skill.skillKey, skill.name, installOption.id);
}}
>
{busyForSkill ? "Working..." : installOption.label}
</button>
) : null}
<button
type="button"
className="ui-btn-secondary w-full px-3 py-2 text-[10px] font-medium disabled:cursor-not-allowed disabled:opacity-65"
disabled={anySkillBusy}
onClick={() => {
void onSetSkillGlobalEnabled(skill.skillKey, skill.disabled);
}}
>
{busyForSkill
? "Working..."
: skill.disabled
? "Enable globally"
: "Disable globally"}
</button>
{skill.primaryEnv ? (
<>
<input
type="password"
value={apiKeyDraft}
onChange={(event) => {
void onSkillApiKeyChange(skill.skillKey, event.target.value);
}}
disabled={anySkillBusy}
className="w-full rounded-md border border-border/60 bg-surface-1 px-3 py-2 text-[10px] text-foreground outline-none transition focus:border-border"
placeholder={`Set ${skill.primaryEnv}`}
aria-label={`API key for ${skill.name}`}
/>
<button
type="button"
className="ui-btn-secondary w-full px-3 py-2 text-[10px] font-medium disabled:cursor-not-allowed disabled:opacity-65"
disabled={anySkillBusy || trimmedApiKey.length === 0}
onClick={() => {
if (trimmedApiKey.length === 0) {
return;
}
void onSaveSkillApiKey(skill.skillKey);
}}
>
{busyForSkill ? "Working..." : `Save ${skill.primaryEnv}`}
</button>
</>
) : null}
{canDeleteSkill ? (
<button
type="button"
className="ui-btn-secondary ui-btn-danger w-full px-3 py-2 text-[10px] font-medium disabled:cursor-not-allowed disabled:opacity-65"
disabled={anySkillBusy}
onClick={() => {
const approved = window.confirm(
`Remove ${skill.name} from the gateway? This affects all agents.`
);
if (!approved) {
return;
}
void onRemoveSkill({
skillKey: skill.skillKey,
source: skill.source,
baseDir: skill.baseDir,
});
onClose();
}}
>
Remove skill from gateway
</button>
) : null}
</div>
</div>
</div>
</div>
);
};
@@ -0,0 +1,94 @@
import type { GatewayStatus } from "@/lib/gateway/GatewayClient";
import { X } from "lucide-react";
import { resolveGatewayStatusBadgeClass, resolveGatewayStatusLabel } from "./colorSemantics";
type ConnectionPanelProps = {
gatewayUrl: string;
token: string;
status: GatewayStatus;
error: string | null;
onGatewayUrlChange: (value: string) => void;
onTokenChange: (value: string) => void;
onConnect: () => void;
onDisconnect: () => void;
onClose?: () => void;
};
export const ConnectionPanel = ({
gatewayUrl,
token,
status,
error,
onGatewayUrlChange,
onTokenChange,
onConnect,
onDisconnect,
onClose,
}: ConnectionPanelProps) => {
const isConnected = status === "connected";
const isConnecting = status === "connecting";
return (
<div className="fade-up-delay flex flex-col gap-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-3">
<span
className={`ui-chip inline-flex items-center px-3 py-1 font-mono text-[10px] font-semibold tracking-[0.08em] ${resolveGatewayStatusBadgeClass(status)}`}
data-status={status}
>
{resolveGatewayStatusLabel(status)}
</span>
<button
className="ui-btn-secondary px-4 py-2 text-xs font-semibold tracking-[0.05em] text-foreground disabled:cursor-not-allowed disabled:opacity-60"
type="button"
onClick={isConnected ? onDisconnect : onConnect}
disabled={isConnecting || !gatewayUrl.trim()}
>
{isConnected ? "Disconnect" : "Connect"}
</button>
</div>
{onClose ? (
<button
className="ui-btn-ghost inline-flex items-center gap-1 px-3 py-2 text-xs font-semibold tracking-[0.05em] text-foreground"
type="button"
onClick={onClose}
data-testid="gateway-connection-close"
aria-label="Close gateway connection panel"
>
<X className="h-3.5 w-3.5" />
Close
</button>
) : null}
</div>
<div className="grid gap-3 lg:grid-cols-[1.4fr_1fr]">
<label className="flex flex-col gap-1 font-mono text-[10px] font-semibold tracking-[0.06em] text-muted-foreground">
Upstream URL
<input
className="ui-input h-10 rounded-md px-4 font-sans text-sm text-foreground outline-none"
type="text"
value={gatewayUrl}
onChange={(event) => onGatewayUrlChange(event.target.value)}
placeholder="ws://localhost:18789"
spellCheck={false}
/>
</label>
<label className="flex flex-col gap-1 font-mono text-[10px] font-semibold tracking-[0.06em] text-muted-foreground">
Upstream token
<input
className="ui-input h-10 rounded-md px-4 font-sans text-sm text-foreground outline-none"
type="password"
value={token}
onChange={(event) => onTokenChange(event.target.value)}
placeholder="gateway token"
spellCheck={false}
/>
</label>
</div>
{error ? (
<p className="ui-alert-danger rounded-md px-4 py-2 text-sm">
{error}
</p>
) : null}
</div>
);
};
@@ -0,0 +1,58 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs));
type EmptyStatePanelProps = {
title: string;
label?: string;
description?: string;
detail?: string;
fillHeight?: boolean;
compact?: boolean;
className?: string;
};
export const EmptyStatePanel = ({
title,
label,
description,
detail,
fillHeight = false,
compact = false,
className,
}: EmptyStatePanelProps) => {
return (
<div
className={cn(
"ui-card text-muted-foreground",
fillHeight ? "flex h-full w-full flex-col justify-center" : "",
className
)}
>
{label ? (
<p className="font-mono text-[10px] font-semibold tracking-[0.06em] text-muted-foreground">
{label}
</p>
) : null}
<p
className={cn(
"console-title mt-2 text-2xl leading-none text-foreground sm:text-3xl",
compact ? "mt-0 text-xs font-medium tracking-normal text-muted-foreground sm:text-xs" : ""
)}
>
{title}
</p>
{description ? (
<p className={cn("mt-3 text-sm text-muted-foreground", compact ? "mt-1 text-xs" : "")}>
{description}
</p>
) : null}
{detail ? (
<p className="ui-input mt-3 rounded-md px-4 py-2 font-mono text-[11px] text-muted-foreground/90">
{detail}
</p>
) : null}
</div>
);
};
@@ -0,0 +1,173 @@
import type { AgentState, FocusFilter } from "@/features/agents/state/store";
import { useLayoutEffect, useMemo, useRef } from "react";
import { AgentAvatar } from "./AgentAvatar";
import {
NEEDS_APPROVAL_BADGE_CLASS,
resolveAgentStatusBadgeClass,
resolveAgentStatusLabel,
} from "./colorSemantics";
import { EmptyStatePanel } from "./EmptyStatePanel";
type FleetSidebarProps = {
agents: AgentState[];
selectedAgentId: string | null;
filter: FocusFilter;
onFilterChange: (next: FocusFilter) => void;
onSelectAgent: (agentId: string) => void;
onCreateAgent: () => void;
createDisabled?: boolean;
createBusy?: boolean;
};
const FILTER_OPTIONS: Array<{ value: FocusFilter; label: string; testId: string }> = [
{ value: "all", label: "All", testId: "fleet-filter-all" },
{ value: "running", label: "Running", testId: "fleet-filter-running" },
{ value: "approvals", label: "Approvals", testId: "fleet-filter-approvals" },
];
export const FleetSidebar = ({
agents,
selectedAgentId,
filter,
onFilterChange,
onSelectAgent,
onCreateAgent,
createDisabled = false,
createBusy = false,
}: FleetSidebarProps) => {
const rowRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
const previousTopByAgentIdRef = useRef<Map<string, number>>(new Map());
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
const agentOrderKey = useMemo(() => agents.map((agent) => agent.agentId).join("|"), [agents]);
useLayoutEffect(() => {
const scroller = scrollContainerRef.current;
if (!scroller) return;
const scrollerRect = scroller.getBoundingClientRect();
const getTopInScrollContent = (node: HTMLElement) =>
node.getBoundingClientRect().top - scrollerRect.top + scroller.scrollTop;
const nextTopByAgentId = new Map<string, number>();
const agentIds = agentOrderKey.length === 0 ? [] : agentOrderKey.split("|");
for (const agentId of agentIds) {
const node = rowRefs.current.get(agentId);
if (!node) continue;
const nextTop = getTopInScrollContent(node);
nextTopByAgentId.set(agentId, nextTop);
const previousTop = previousTopByAgentIdRef.current.get(agentId);
if (typeof previousTop !== "number") continue;
const deltaY = previousTop - nextTop;
if (Math.abs(deltaY) < 0.5) continue;
if (typeof node.animate !== "function") continue;
node.animate(
[{ transform: `translateY(${deltaY}px)` }, { transform: "translateY(0px)" }],
{ duration: 300, easing: "cubic-bezier(0.22, 1, 0.36, 1)" }
);
}
previousTopByAgentIdRef.current = nextTopByAgentId;
}, [agentOrderKey]);
return (
<aside
className="glass-panel fade-up-delay ui-panel ui-depth-sidepanel relative flex h-full w-full min-w-72 flex-col gap-3 bg-sidebar p-3 xl:max-w-[320px] xl:border-r xl:border-sidebar-border"
data-testid="fleet-sidebar"
>
<div className="flex items-center justify-between gap-2 px-1">
<p className="console-title type-page-title text-foreground">Agents ({agents.length})</p>
<button
type="button"
data-testid="fleet-new-agent-button"
className="ui-btn-primary px-3 py-2 font-mono text-[12px] font-medium tracking-[0.02em] disabled:cursor-not-allowed disabled:border-border disabled:bg-muted disabled:text-muted-foreground"
onClick={onCreateAgent}
disabled={createDisabled || createBusy}
>
{createBusy ? "Creating..." : "New agent"}
</button>
</div>
<div className="ui-segment ui-segment-fleet-filter grid-cols-3">
{FILTER_OPTIONS.map((option) => {
const active = filter === option.value;
return (
<button
key={option.value}
type="button"
data-testid={option.testId}
aria-pressed={active}
className="ui-segment-item px-2 py-1 font-mono text-[12px] font-medium tracking-[0.02em]"
data-active={active ? "true" : "false"}
onClick={() => onFilterChange(option.value)}
>
{option.label}
</button>
);
})}
</div>
<div ref={scrollContainerRef} className="ui-scroll min-h-0 flex-1 overflow-auto">
{agents.length === 0 ? (
<EmptyStatePanel title="No agents available." compact className="p-3 text-xs" />
) : (
<div className="flex flex-col gap-2.5">
{agents.map((agent) => {
const selected = selectedAgentId === agent.agentId;
const avatarSeed = agent.avatarSeed ?? agent.agentId;
return (
<button
key={agent.agentId}
ref={(node) => {
if (node) {
rowRefs.current.set(agent.agentId, node);
return;
}
rowRefs.current.delete(agent.agentId);
}}
type="button"
data-testid={`fleet-agent-row-${agent.agentId}`}
className={`group relative ui-card flex w-full items-center gap-3 overflow-hidden border px-3 py-3 text-left transition-colors ${
selected
? "ui-card-selected"
: "hover:bg-surface-2/45"
}`}
onClick={() => onSelectAgent(agent.agentId)}
>
<span
aria-hidden="true"
className={`ui-card-select-indicator ${selected ? "opacity-100" : "opacity-0 group-hover:opacity-35"}`}
/>
<AgentAvatar
seed={avatarSeed}
name={agent.name}
avatarUrl={agent.avatarUrl ?? null}
size={42}
isSelected={selected}
/>
<div className="min-w-0 flex-1">
<p className="type-secondary-heading truncate text-foreground">
{agent.name}
</p>
<div className="mt-1.5 flex flex-wrap items-center gap-2">
<span
className={`ui-badge ${resolveAgentStatusBadgeClass(agent.status)}`}
data-status={agent.status}
>
{resolveAgentStatusLabel(agent.status)}
</span>
{agent.awaitingUserInput ? (
<span className={`ui-badge ${NEEDS_APPROVAL_BADGE_CLASS}`} data-status="approval">
Needs approval
</span>
) : null}
</div>
</div>
</button>
);
})}
</div>
)}
</div>
</aside>
);
};
@@ -0,0 +1,249 @@
import { useMemo, useState } from "react";
import { Check, Copy, Eye, EyeOff, Loader2 } from "lucide-react";
import type { GatewayStatus } from "@/lib/gateway/GatewayClient";
import { isLocalGatewayUrl } from "@/lib/gateway/local-gateway";
import type { StudioGatewaySettings } from "@/lib/studio/settings";
type GatewayConnectScreenProps = {
gatewayUrl: string;
token: string;
localGatewayDefaults: StudioGatewaySettings | null;
status: GatewayStatus;
error: string | null;
showApprovalHint: boolean;
onGatewayUrlChange: (value: string) => void;
onTokenChange: (value: string) => void;
onUseLocalDefaults: () => void;
onConnect: () => void;
};
const resolveLocalGatewayPort = (gatewayUrl: string): number => {
try {
const parsed = new URL(gatewayUrl);
const port = Number(parsed.port);
if (Number.isFinite(port) && port > 0) return port;
} catch {}
return 18789;
};
export const GatewayConnectScreen = ({
gatewayUrl,
token,
localGatewayDefaults,
status,
error,
showApprovalHint,
onGatewayUrlChange,
onTokenChange,
onUseLocalDefaults,
onConnect,
}: GatewayConnectScreenProps) => {
const [copyStatus, setCopyStatus] = useState<"idle" | "copied" | "failed">("idle");
const [showToken, setShowToken] = useState(false);
const isLocal = useMemo(() => isLocalGatewayUrl(gatewayUrl), [gatewayUrl]);
const localPort = useMemo(() => resolveLocalGatewayPort(gatewayUrl), [gatewayUrl]);
const localGatewayCommand = useMemo(
() => `npx openclaw gateway run --bind loopback --port ${localPort} --verbose`,
[localPort]
);
const localGatewayCommandPnpm = useMemo(
() => `pnpm openclaw gateway run --bind loopback --port ${localPort} --verbose`,
[localPort]
);
const statusCopy = useMemo(() => {
if (status === "connecting" && isLocal) {
return `Local gateway detected on port ${localPort}. Connecting…`;
}
if (status === "connecting") {
return "Connecting to remote gateway…";
}
if (isLocal) {
return "No local gateway found.";
}
return "Not connected to a gateway.";
}, [isLocal, localPort, status]);
const connectDisabled = status === "connecting";
const connectLabel = connectDisabled ? "Connecting…" : "Connect";
const statusDotClass =
status === "connected"
? "ui-dot-status-connected"
: status === "connecting"
? "ui-dot-status-connecting"
: "ui-dot-status-disconnected";
const copyLocalCommand = async () => {
try {
await navigator.clipboard.writeText(localGatewayCommand);
setCopyStatus("copied");
window.setTimeout(() => setCopyStatus("idle"), 1200);
} catch {
setCopyStatus("failed");
window.setTimeout(() => setCopyStatus("idle"), 1800);
}
};
const commandField = (
<div className="space-y-1.5">
<div className="ui-command-surface flex items-center gap-2 rounded-md px-3 py-2">
<code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap font-mono text-[12px] text-white">
{localGatewayCommand}
</code>
<button
type="button"
className="ui-btn-icon ui-command-copy h-7 w-7 shrink-0"
onClick={copyLocalCommand}
aria-label="Copy local gateway command"
title="Copy command"
>
{copyStatus === "copied" ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
</button>
</div>
{copyStatus === "copied" ? (
<p className="text-xs text-white/80">Copied</p>
) : copyStatus === "failed" ? (
<p className="ui-text-danger text-xs">Could not copy command.</p>
) : (
<p className="text-xs leading-snug text-white/80">
In a source checkout, use <span className="font-mono text-white">{localGatewayCommandPnpm}</span>.
</p>
)}
</div>
);
const remoteForm = (
<div className="mt-2.5 flex flex-col gap-3">
<label className="flex flex-col gap-1 text-[11px] font-medium text-white/90">
Upstream URL
<input
className="ui-input h-10 rounded-md px-4 font-sans text-sm text-foreground outline-none"
type="text"
value={gatewayUrl}
onChange={(event) => onGatewayUrlChange(event.target.value)}
placeholder="wss://your-gateway.example.com"
spellCheck={false}
/>
</label>
<div className="space-y-0.5 text-xs text-white/80">
<p className="font-medium text-white">Using Tailscale?</p>
<p>
URL: <span className="font-mono">wss://&lt;your-tailnet-host&gt;</span>
</p>
</div>
<label className="flex flex-col gap-1 text-[11px] font-medium text-white/90">
Upstream token
<div className="relative">
<input
className="ui-input h-10 w-full rounded-md px-4 pr-10 font-sans text-sm text-foreground outline-none"
type={showToken ? "text" : "password"}
value={token}
onChange={(event) => onTokenChange(event.target.value)}
placeholder="gateway token"
spellCheck={false}
/>
<button
type="button"
className="ui-btn-icon absolute inset-y-0 right-1 my-auto h-8 w-8 border-transparent bg-transparent text-white/70 hover:bg-transparent hover:text-white"
aria-label={showToken ? "Hide token" : "Show token"}
onClick={() => setShowToken((prev) => !prev)}
>
{showToken ? (
<EyeOff className="h-4 w-4 transition-transform duration-150" />
) : (
<Eye className="h-4 w-4 transition-transform duration-150" />
)}
</button>
</div>
</label>
<button
type="button"
className="ui-btn-primary mt-1 h-11 w-full px-4 text-xs font-semibold tracking-[0.05em] disabled:cursor-not-allowed disabled:opacity-60"
onClick={onConnect}
disabled={connectDisabled || !gatewayUrl.trim()}
>
{connectLabel}
</button>
{status === "connecting" ? (
<p className="inline-flex items-center gap-1.5 text-xs text-white/80">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Connecting
</p>
) : null}
{error ? <p className="ui-text-danger text-xs leading-snug">{error}</p> : null}
{showApprovalHint ? (
<div className="rounded-md border border-white/10 bg-white/5 px-3 py-3 text-xs text-white/85">
<p className="leading-snug">
If the first connection attempt did not work, go to your OpenClaw computer and approve this
device:
</p>
<code className="mt-2 block overflow-x-auto whitespace-nowrap rounded-md bg-black/30 px-2.5 py-2 font-mono text-[11px] text-white">
openclaw devices approve --latest
</code>
</div>
) : null}
</div>
);
return (
<div className="mx-auto flex min-h-0 w-full max-w-[820px] flex-1 flex-col gap-5">
<div className="ui-card px-4 py-2">
<div className="flex items-center gap-2">
{status === "connecting" ? (
<Loader2 className="h-4 w-4 animate-spin text-[color:var(--status-connecting-fg)]" />
) : (
<span
className={`h-2.5 w-2.5 ${statusDotClass}`}
/>
)}
<p className="text-sm font-semibold text-white">{statusCopy}</p>
</div>
</div>
<div className="ui-card px-4 py-5 sm:px-6">
<div>
<p className="font-mono text-[10px] font-medium tracking-[0.06em] text-white/80">
Remote gateway (recommended)
</p>
<p className="mt-2 text-sm text-white/90">Default: enter your URL and token to connect.</p>
</div>
{remoteForm}
</div>
<div className="ui-card px-4 py-4 sm:px-6 sm:py-5">
<div className="space-y-1.5">
<p className="font-mono text-[10px] font-semibold tracking-[0.06em] text-white/80">
Run locally (optional)
</p>
<p className="text-sm text-white/90">
Start a local gateway process on this machine, then connect.
</p>
</div>
<div className="mt-3 space-y-3">
{commandField}
{localGatewayDefaults ? (
<div className="ui-input rounded-md px-3 py-3">
<div className="space-y-2">
<p className="text-xs text-white/80">
Use token from <span className="font-mono">~/.openclaw/openclaw.json</span>.
</p>
<p className="font-mono text-[11px] text-white">
{localGatewayDefaults.url}
</p>
<button
type="button"
className="ui-btn-secondary h-9 w-full px-3 text-xs font-semibold tracking-[0.05em] text-white"
onClick={onUseLocalDefaults}
>
Use local defaults
</button>
</div>
</div>
) : null}
</div>
</div>
</div>
);
};
@@ -0,0 +1,89 @@
import { useEffect, useRef, useState } from "react";
import { ThemeToggle } from "@/components/theme-toggle";
import type { GatewayStatus } from "@/lib/gateway/GatewayClient";
import { Plug } from "lucide-react";
import { resolveGatewayStatusBadgeClass } from "./colorSemantics";
type HeaderBarProps = {
status: GatewayStatus;
onConnectionSettings: () => void;
showConnectionSettings?: boolean;
};
export const HeaderBar = ({
status,
onConnectionSettings,
showConnectionSettings = true,
}: HeaderBarProps) => {
const [menuOpen, setMenuOpen] = useState(false);
const menuRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!menuOpen) return;
const onPointerDown = (event: MouseEvent) => {
if (!menuRef.current) return;
if (menuRef.current.contains(event.target as Node)) return;
setMenuOpen(false);
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setMenuOpen(false);
};
document.addEventListener("mousedown", onPointerDown);
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("mousedown", onPointerDown);
document.removeEventListener("keydown", onKeyDown);
};
}, [menuOpen]);
return (
<div className="ui-topbar relative z-[180]">
<div className="grid h-10 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center px-3 sm:px-4 md:px-5">
<div aria-hidden="true" />
<p className="truncate text-sm font-semibold tracking-[0.01em] text-foreground">Claw3D</p>
<div className="flex items-center justify-end gap-1">
{status === "connecting" ? (
<span
className={`ui-chip px-2 py-0.5 font-mono text-[9px] font-semibold tracking-[0.08em] ${resolveGatewayStatusBadgeClass("connecting")}`}
data-testid="gateway-connecting-indicator"
data-status="connecting"
>
Connecting
</span>
) : null}
<ThemeToggle />
{showConnectionSettings ? (
<div className="relative z-[210]" ref={menuRef}>
<button
type="button"
className="ui-btn-icon ui-btn-icon-xs"
data-testid="studio-menu-toggle"
aria-haspopup="menu"
aria-expanded={menuOpen}
onClick={() => setMenuOpen((prev) => !prev)}
>
<Plug className="h-3.5 w-3.5" />
<span className="sr-only">Open studio menu</span>
</button>
{menuOpen ? (
<div className="ui-card ui-menu-popover absolute right-0 top-9 z-[260] min-w-44 p-1">
<button
className="ui-btn-ghost w-full justify-start border-transparent px-3 py-2 text-left text-xs font-medium tracking-normal text-foreground"
type="button"
onClick={() => {
onConnectionSettings();
setMenuOpen(false);
}}
data-testid="gateway-settings-toggle"
>
Gateway connection
</button>
</div>
) : null}
</div>
) : null}
</div>
</div>
</div>
);
};
@@ -0,0 +1,319 @@
"use client";
import { useMemo, useState } from "react";
import { AgentSkillsSetupModal } from "@/features/agents/components/AgentSkillsSetupModal";
import {
buildSkillMissingDetails,
deriveSkillReadinessState,
type SkillReadinessState,
} from "@/lib/skills/presentation";
import type { SkillStatusReport } from "@/lib/skills/types";
type SkillSetupMessage = { kind: "success" | "error"; message: string };
type ReadinessFilter = "all" | SkillReadinessState;
type SystemSkillsPanelProps = {
skillsReport?: SkillStatusReport | null;
skillsLoading?: boolean;
skillsError?: string | null;
skillsBusy?: boolean;
skillsBusyKey?: string | null;
skillMessages?: Record<string, SkillSetupMessage>;
skillApiKeyDrafts?: Record<string, string>;
defaultAgentScopeWarning?: string | null;
initialSkillKey?: string | null;
onInitialSkillKeyHandled?: () => void;
onSetSkillGlobalEnabled: (skillKey: string, enabled: boolean) => Promise<void> | void;
onInstallSkill: (skillKey: string, name: string, installId: string) => Promise<void> | void;
onRemoveSkill: (
skill: { skillKey: string; source: string; baseDir: string }
) => Promise<void> | void;
onSkillApiKeyChange: (skillKey: string, value: string) => Promise<void> | void;
onSaveSkillApiKey: (skillKey: string) => Promise<void> | void;
};
const READINESS_FILTERS: Array<{ id: ReadinessFilter; label: string }> = [
{ id: "all", label: "All" },
{ id: "ready", label: "Ready" },
{ id: "needs-setup", label: "Needs setup" },
{ id: "unavailable", label: "Unavailable" },
{ id: "disabled-globally", label: "Disabled globally" },
];
const READINESS_LABELS = {
ready: "Ready",
"needs-setup": "Needs setup",
unavailable: "Unavailable",
"disabled-globally": "Disabled globally",
} as const;
const READINESS_CLASSES = {
ready: "ui-badge-status-running",
"needs-setup": "ui-badge-status-error",
unavailable: "ui-badge-status-error",
"disabled-globally": "ui-badge-status-error",
} as const;
const resolveReadinessHint = (
skill: SkillStatusReport["skills"][number],
readiness: SkillReadinessState
): string | null => {
if (readiness === "ready") {
return null;
}
if (readiness === "disabled-globally") {
return "Disabled globally for all agents.";
}
if (readiness === "unavailable") {
if (skill.blockedByAllowlist) {
return "Blocked by bundled skills policy.";
}
return buildSkillMissingDetails(skill)[0] ?? "Unavailable on this system.";
}
return buildSkillMissingDetails(skill)[0] ?? "Requires setup.";
};
export const SystemSkillsPanel = ({
skillsReport = null,
skillsLoading = false,
skillsError = null,
skillsBusy = false,
skillsBusyKey = null,
skillMessages = {},
skillApiKeyDrafts = {},
defaultAgentScopeWarning = null,
initialSkillKey = null,
onInitialSkillKeyHandled,
onSetSkillGlobalEnabled,
onInstallSkill,
onRemoveSkill,
onSkillApiKeyChange,
onSaveSkillApiKey,
}: SystemSkillsPanelProps) => {
const [skillsFilter, setSkillsFilter] = useState("");
const [readinessFilter, setReadinessFilter] = useState<ReadinessFilter>("all");
const [setupSkillKey, setSetupSkillKey] = useState<string | null>(null);
const skillEntries = useMemo(() => skillsReport?.skills ?? [], [skillsReport]);
const anySkillBusy = skillsBusy || Boolean(skillsBusyKey);
const requestedInitialSkillKey = useMemo(() => {
const candidate = initialSkillKey?.trim() ?? "";
if (!candidate) {
return null;
}
return skillEntries.some((entry) => entry.skillKey === candidate) ? candidate : null;
}, [initialSkillKey, skillEntries]);
const rows = useMemo(
() =>
skillEntries.map((skill) => ({
skill,
readiness: deriveSkillReadinessState(skill),
})),
[skillEntries]
);
const searchedRows = useMemo(() => {
const query = skillsFilter.trim().toLowerCase();
if (!query) {
return rows;
}
return rows.filter((entry) =>
[entry.skill.name, entry.skill.description, entry.skill.source, entry.skill.skillKey]
.join(" ")
.toLowerCase()
.includes(query)
);
}, [rows, skillsFilter]);
const filteredRows = useMemo(() => {
if (readinessFilter === "all") {
return searchedRows;
}
return searchedRows.filter((entry) => entry.readiness === readinessFilter);
}, [readinessFilter, searchedRows]);
const readinessCounts = useMemo(
() =>
searchedRows.reduce(
(counts, entry) => {
counts.all += 1;
counts[entry.readiness] += 1;
return counts;
},
{
all: 0,
ready: 0,
"needs-setup": 0,
unavailable: 0,
"disabled-globally": 0,
} satisfies Record<ReadinessFilter, number>
),
[searchedRows]
);
const setupQueue = useMemo(
() =>
rows.filter(
(entry) => entry.readiness === "needs-setup" || entry.readiness === "disabled-globally"
),
[rows]
);
const selectedSkillKey = setupSkillKey ?? requestedInitialSkillKey;
const selectedSetupSkill = selectedSkillKey
? skillEntries.find((entry) => entry.skillKey === selectedSkillKey) ?? null
: null;
return (
<section className="sidebar-section" data-testid="agent-settings-system-skills">
<div className="flex items-center justify-between gap-3">
<h3 className="sidebar-section-title">System skill setup</h3>
<div className="font-mono text-[10px] text-muted-foreground">{skillEntries.length}</div>
</div>
<div className="mt-2 text-[11px] text-muted-foreground">
Changes here affect all agents on this gateway.
</div>
{defaultAgentScopeWarning ? (
<div className="mt-3 rounded-md border border-border/60 bg-surface-1/65 px-3 py-2 text-[10px] text-muted-foreground/82">
{defaultAgentScopeWarning}
</div>
) : null}
{setupQueue.length > 0 ? (
<div className="mt-3 rounded-md border border-border/60 bg-surface-1/65 px-3 py-3">
<div className="text-[10px] font-semibold text-foreground/85">Needs setup ({setupQueue.length})</div>
<div className="mt-2 flex flex-col gap-2">
{setupQueue.slice(0, 5).map((entry) => (
<div
key={`setup-queue:${entry.skill.skillKey}`}
className="flex items-center justify-between gap-2 text-[10px] text-muted-foreground/85"
>
<span className="truncate">{entry.skill.name}</span>
<button
type="button"
className="ui-btn-secondary px-2 py-1 text-[9px] font-semibold disabled:cursor-not-allowed disabled:opacity-65"
disabled={anySkillBusy}
onClick={() => {
onInitialSkillKeyHandled?.();
setSetupSkillKey(entry.skill.skillKey);
}}
>
Set up
</button>
</div>
))}
</div>
</div>
) : null}
<div className="mt-3">
<input
value={skillsFilter}
onChange={(event) => setSkillsFilter(event.target.value)}
placeholder="Search skills"
className="w-full rounded-md border border-border/60 bg-surface-1 px-3 py-2 text-[11px] text-foreground outline-none transition focus:border-border"
aria-label="Search skills"
/>
</div>
<div className="mt-2 flex flex-wrap gap-1">
{READINESS_FILTERS.map((filter) => {
const selected = readinessFilter === filter.id;
return (
<button
key={filter.id}
type="button"
className="ui-btn-secondary px-2 py-1 text-[9px] font-semibold disabled:cursor-not-allowed disabled:opacity-65"
data-active={selected ? "true" : "false"}
disabled={skillsLoading}
onClick={() => {
setReadinessFilter(filter.id);
}}
>
{filter.label} ({readinessCounts[filter.id]})
</button>
);
})}
</div>
{skillsLoading ? <div className="mt-3 text-[11px] text-muted-foreground">Loading skills...</div> : null}
{!skillsLoading && skillsError ? (
<div className="ui-alert-danger mt-3 rounded-md px-3 py-2 text-xs">{skillsError}</div>
) : null}
{!skillsLoading && !skillsError && filteredRows.length === 0 ? (
<div className="mt-3 text-[11px] text-muted-foreground">No matching skills.</div>
) : null}
{!skillsLoading && !skillsError && filteredRows.length > 0 ? (
<div className="mt-3 flex flex-col gap-2">
{filteredRows.map((entry) => {
const readinessLabel = READINESS_LABELS[entry.readiness];
const readinessClassName = READINESS_CLASSES[entry.readiness];
const message = skillMessages[entry.skill.skillKey] ?? null;
return (
<div
key={`${entry.skill.source}:${entry.skill.skillKey}`}
className="ui-settings-row flex min-h-[68px] flex-col gap-3 px-4 py-3 sm:flex-row sm:items-start sm:justify-between"
>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-[11px] font-medium text-foreground/88">{entry.skill.name}</span>
<span className="rounded bg-surface-2 px-1.5 py-0.5 font-mono text-[9px] text-muted-foreground">
{entry.skill.source}
</span>
<span
className={`rounded border px-1.5 py-0.5 font-mono text-[10px] font-semibold ${readinessClassName}`}
>
{readinessLabel}
</span>
</div>
<div className="mt-1 text-[10px] text-muted-foreground/70">{entry.skill.description}</div>
{entry.readiness !== "ready" ? (
<div className="mt-1 text-[10px] text-muted-foreground/80">
{resolveReadinessHint(entry.skill, entry.readiness)}
</div>
) : null}
{message ? (
<div
className={`mt-1 text-[10px] ${message.kind === "error" ? "ui-text-danger" : "ui-text-success"}`}
>
{message.message}
</div>
) : null}
</div>
<div className="flex w-full items-center justify-end gap-2 sm:w-[210px]">
<button
type="button"
className="ui-btn-secondary px-2 py-1 text-[9px] font-semibold disabled:cursor-not-allowed disabled:opacity-65"
disabled={anySkillBusy}
onClick={() => {
onInitialSkillKeyHandled?.();
setSetupSkillKey(entry.skill.skillKey);
}}
>
Configure
</button>
</div>
</div>
);
})}
</div>
) : null}
<AgentSkillsSetupModal
skill={selectedSetupSkill}
skillsBusy={skillsBusy}
skillsBusyKey={skillsBusyKey}
skillMessage={selectedSetupSkill ? skillMessages[selectedSetupSkill.skillKey] ?? null : null}
apiKeyDraft={selectedSetupSkill ? skillApiKeyDrafts[selectedSetupSkill.skillKey] ?? "" : ""}
defaultAgentScopeWarning={defaultAgentScopeWarning}
onClose={() => {
onInitialSkillKeyHandled?.();
setSetupSkillKey(null);
}}
onInstallSkill={onInstallSkill}
onSetSkillGlobalEnabled={onSetSkillGlobalEnabled}
onRemoveSkill={onRemoveSkill}
onSkillApiKeyChange={onSkillApiKeyChange}
onSaveSkillApiKey={onSaveSkillApiKey}
/>
</section>
);
};
+493
View File
@@ -0,0 +1,493 @@
import {
formatThinkingMarkdown,
isToolMarkdown,
isMetaMarkdown,
isTraceMarkdown,
parseToolMarkdown,
parseMetaMarkdown,
stripTraceMarkdown,
} from "@/lib/text/message-extract";
import { normalizeAssistantDisplayText } from "@/lib/text/assistantText";
type ItemMeta = {
role: "user" | "assistant";
timestampMs: number;
thinkingDurationMs?: number;
};
export type AgentChatItem =
| { kind: "user"; text: string; timestampMs?: number }
| { kind: "assistant"; text: string; live?: boolean; timestampMs?: number; thinkingDurationMs?: number }
| { kind: "tool"; text: string; timestampMs?: number }
| { kind: "thinking"; text: string; live?: boolean; timestampMs?: number; thinkingDurationMs?: number };
export type AssistantTraceEvent =
| { kind: "thinking"; text: string }
| { kind: "tool"; text: string };
export type AgentChatRenderBlock =
| { kind: "user"; text: string; timestampMs?: number }
| {
kind: "assistant";
text: string | null;
timestampMs?: number;
thinkingDurationMs?: number;
traceEvents: AssistantTraceEvent[];
};
export type BuildAgentChatItemsInput = {
outputLines: string[];
streamText: string | null;
liveThinkingTrace: string;
showThinkingTraces: boolean;
toolCallingEnabled: boolean;
};
const normalizeUserDisplayText = (value: string): string => {
return value.replace(/\s+/g, " ").trim();
};
const normalizeThinkingDisplayText = (value: string): string => {
const markdown = formatThinkingMarkdown(value);
const normalized = stripTraceMarkdown(markdown).trim();
return normalized;
};
export const buildFinalAgentChatItems = ({
outputLines,
showThinkingTraces,
toolCallingEnabled,
}: Pick<
BuildAgentChatItemsInput,
"outputLines" | "showThinkingTraces" | "toolCallingEnabled"
>): AgentChatItem[] => {
const items: AgentChatItem[] = [];
let currentMeta: ItemMeta | null = null;
const appendThinking = (text: string) => {
const normalized = text.trim();
if (!normalized) return;
const previous = items[items.length - 1];
if (!previous || previous.kind !== "thinking") {
items.push({
kind: "thinking",
text: normalized,
...(currentMeta ? { timestampMs: currentMeta.timestampMs, thinkingDurationMs: currentMeta.thinkingDurationMs } : {}),
});
return;
}
if (previous.text === normalized) {
return;
}
if (normalized.startsWith(previous.text)) {
previous.text = normalized;
return;
}
if (previous.text.startsWith(normalized)) {
return;
}
previous.text = `${previous.text}\n\n${normalized}`;
};
for (const line of outputLines) {
if (!line) continue;
if (isMetaMarkdown(line)) {
const parsed = parseMetaMarkdown(line);
if (parsed) {
currentMeta = {
role: parsed.role,
timestampMs: parsed.timestamp,
...(typeof parsed.thinkingDurationMs === "number" ? { thinkingDurationMs: parsed.thinkingDurationMs } : {}),
};
}
continue;
}
if (isTraceMarkdown(line)) {
if (!showThinkingTraces) continue;
const text = stripTraceMarkdown(line).trim();
if (!text) continue;
appendThinking(text);
continue;
}
if (isToolMarkdown(line)) {
if (!toolCallingEnabled) continue;
items.push({
kind: "tool",
text: line,
...(currentMeta ? { timestampMs: currentMeta.timestampMs } : {}),
});
continue;
}
const trimmed = line.trim();
if (trimmed.startsWith(">")) {
const text = trimmed.replace(/^>\s?/, "").trim();
if (text) {
const normalized = normalizeUserDisplayText(text);
const currentTimestamp =
currentMeta?.role === "user" ? currentMeta.timestampMs : undefined;
const previous = items[items.length - 1];
if (previous?.kind === "user") {
const previousNormalized = normalizeUserDisplayText(previous.text);
const previousTimestamp = previous.timestampMs;
const shouldCollapse =
previousNormalized === normalized &&
((typeof previousTimestamp === "number" &&
typeof currentTimestamp === "number" &&
previousTimestamp === currentTimestamp) ||
(previousTimestamp === undefined &&
typeof currentTimestamp === "number"));
if (
shouldCollapse
) {
previous.text = normalized;
if (typeof currentTimestamp === "number") {
previous.timestampMs = currentTimestamp;
}
if (currentMeta?.role === "user") {
currentMeta = null;
}
continue;
}
}
items.push({
kind: "user",
text: normalized,
...(typeof currentTimestamp === "number" ? { timestampMs: currentTimestamp } : {}),
});
if (currentMeta?.role === "user") {
currentMeta = null;
}
}
continue;
}
const normalizedAssistant = normalizeAssistantDisplayText(line);
if (!normalizedAssistant) continue;
items.push({
kind: "assistant",
text: normalizedAssistant,
...(currentMeta ? { timestampMs: currentMeta.timestampMs, thinkingDurationMs: currentMeta.thinkingDurationMs } : {}),
});
}
return items;
};
export const buildAgentChatItems = ({
outputLines,
streamText,
liveThinkingTrace,
showThinkingTraces,
toolCallingEnabled,
}: BuildAgentChatItemsInput): AgentChatItem[] => {
const items: AgentChatItem[] = [];
let currentMeta: ItemMeta | null = null;
const appendThinking = (text: string, live?: boolean) => {
const normalized = text.trim();
if (!normalized) return;
const previous = items[items.length - 1];
if (!previous || previous.kind !== "thinking") {
items.push({
kind: "thinking",
text: normalized,
live,
...(currentMeta ? { timestampMs: currentMeta.timestampMs, thinkingDurationMs: currentMeta.thinkingDurationMs } : {}),
});
return;
}
if (previous.text === normalized) {
if (live) previous.live = true;
return;
}
if (normalized.startsWith(previous.text)) {
previous.text = normalized;
if (live) previous.live = true;
return;
}
if (previous.text.startsWith(normalized)) {
if (live) previous.live = true;
return;
}
previous.text = `${previous.text}\n\n${normalized}`;
if (live) previous.live = true;
};
for (const line of outputLines) {
if (!line) continue;
if (isMetaMarkdown(line)) {
const parsed = parseMetaMarkdown(line);
if (parsed) {
currentMeta = {
role: parsed.role,
timestampMs: parsed.timestamp,
...(typeof parsed.thinkingDurationMs === "number" ? { thinkingDurationMs: parsed.thinkingDurationMs } : {}),
};
}
continue;
}
if (isTraceMarkdown(line)) {
if (!showThinkingTraces) continue;
const text = stripTraceMarkdown(line).trim();
if (!text) continue;
appendThinking(text);
continue;
}
if (isToolMarkdown(line)) {
if (!toolCallingEnabled) continue;
items.push({
kind: "tool",
text: line,
...(currentMeta ? { timestampMs: currentMeta.timestampMs } : {}),
});
continue;
}
const trimmed = line.trim();
if (trimmed.startsWith(">")) {
const text = trimmed.replace(/^>\s?/, "").trim();
if (text) {
const currentTimestamp =
currentMeta?.role === "user" ? currentMeta.timestampMs : undefined;
items.push({
kind: "user",
text: normalizeUserDisplayText(text),
...(typeof currentTimestamp === "number" ? { timestampMs: currentTimestamp } : {}),
});
if (currentMeta?.role === "user") {
currentMeta = null;
}
}
continue;
}
const normalizedAssistant = normalizeAssistantDisplayText(line);
if (!normalizedAssistant) continue;
items.push({
kind: "assistant",
text: normalizedAssistant,
...(currentMeta ? { timestampMs: currentMeta.timestampMs, thinkingDurationMs: currentMeta.thinkingDurationMs } : {}),
});
}
const liveStream = streamText?.trim();
if (showThinkingTraces) {
const normalizedLiveThinking = normalizeThinkingDisplayText(liveThinkingTrace);
if (normalizedLiveThinking) {
appendThinking(normalizedLiveThinking, true);
}
}
if (liveStream) {
const normalizedStream = normalizeAssistantDisplayText(liveStream);
if (normalizedStream) {
items.push({ kind: "assistant", text: normalizedStream, live: true });
}
}
return items;
};
const mergeIncrementalText = (existing: string, next: string): string => {
if (existing === next) return existing;
if (next.startsWith(existing)) return next;
if (existing.startsWith(next)) return existing;
return `${existing}\n\n${next}`;
};
const appendThinkingTraceEvent = (events: AssistantTraceEvent[], text: string) => {
const normalized = text.trim();
if (!normalized) return;
const previous = events[events.length - 1];
if (!previous || previous.kind !== "thinking") {
events.push({ kind: "thinking", text: normalized });
return;
}
previous.text = mergeIncrementalText(previous.text, normalized);
};
const hasMismatchedTimestamps = (
left?: number,
right?: number
): boolean => {
if (typeof left !== "number" || typeof right !== "number") return false;
return left !== right;
};
export const buildAgentChatRenderBlocks = (
chatItems: AgentChatItem[]
): AgentChatRenderBlock[] => {
const blocks: AgentChatRenderBlock[] = [];
let currentAssistant: Extract<AgentChatRenderBlock, { kind: "assistant" }> | null = null;
const flushAssistant = () => {
if (!currentAssistant) return;
if (currentAssistant.text || currentAssistant.traceEvents.length > 0) {
blocks.push(currentAssistant);
}
currentAssistant = null;
};
const ensureAssistant = (meta?: {
timestampMs?: number;
thinkingDurationMs?: number;
}) => {
if (!currentAssistant) {
currentAssistant = {
kind: "assistant",
text: null,
traceEvents: [],
...(typeof meta?.timestampMs === "number" ? { timestampMs: meta.timestampMs } : {}),
...(typeof meta?.thinkingDurationMs === "number"
? { thinkingDurationMs: meta.thinkingDurationMs }
: {}),
};
return currentAssistant;
}
if (
currentAssistant.text &&
hasMismatchedTimestamps(currentAssistant.timestampMs, meta?.timestampMs)
) {
flushAssistant();
currentAssistant = {
kind: "assistant",
text: null,
traceEvents: [],
...(typeof meta?.timestampMs === "number" ? { timestampMs: meta.timestampMs } : {}),
...(typeof meta?.thinkingDurationMs === "number"
? { thinkingDurationMs: meta.thinkingDurationMs }
: {}),
};
return currentAssistant;
}
if (
typeof currentAssistant.timestampMs !== "number" &&
typeof meta?.timestampMs === "number"
) {
currentAssistant.timestampMs = meta.timestampMs;
}
if (typeof meta?.thinkingDurationMs === "number") {
currentAssistant.thinkingDurationMs = meta.thinkingDurationMs;
}
return currentAssistant;
};
for (const item of chatItems) {
if (item.kind === "user") {
flushAssistant();
blocks.push({ kind: "user", text: item.text, timestampMs: item.timestampMs });
continue;
}
if (item.kind === "thinking") {
const assistant = ensureAssistant({
timestampMs: item.timestampMs,
thinkingDurationMs: item.thinkingDurationMs,
});
appendThinkingTraceEvent(assistant.traceEvents, item.text);
continue;
}
if (item.kind === "tool") {
const assistant = ensureAssistant({ timestampMs: item.timestampMs });
assistant.traceEvents.push({ kind: "tool", text: item.text });
continue;
}
const assistant = ensureAssistant({
timestampMs: item.timestampMs,
thinkingDurationMs: item.thinkingDurationMs,
});
const normalized = item.text.trim();
if (!normalized) continue;
assistant.text =
typeof assistant.text === "string"
? mergeIncrementalText(assistant.text, normalized)
: normalized;
}
flushAssistant();
return blocks;
};
const stripTrailingToolCallId = (
label: string
): { toolLabel: string; toolCallId: string | null } => {
const trimmed = label.trim();
const match = trimmed.match(/^(.*?)\s*\(([^)]+)\)\s*$/);
if (!match) return { toolLabel: trimmed, toolCallId: null };
const toolLabel = (match[1] ?? "").trim();
const toolCallId = (match[2] ?? "").trim();
return { toolLabel: toolLabel || trimmed, toolCallId: toolCallId || null };
};
const toDisplayToolName = (label: string): string => {
const cleaned = label.trim();
if (!cleaned) return "tool";
const segments = cleaned.split(/[.:/]/).map((s) => s.trim()).filter(Boolean);
return segments[segments.length - 1] ?? cleaned;
};
const truncateInline = (value: string, maxChars: number): string => {
const cleaned = value.replace(/\s+/g, " ").trim();
if (cleaned.length <= maxChars) return cleaned;
return `${cleaned.slice(0, Math.max(0, maxChars - 1)).trimEnd()}`;
};
const extractToolMetaLine = (body: string): string | null => {
const trimmed = body.trim();
if (!trimmed) return null;
const [firstLine] = trimmed.split(/\r?\n/, 1);
const meta = (firstLine ?? "").trim();
if (!meta) return null;
if (meta.startsWith("```")) return null;
return meta;
};
const extractFirstCodeBlockLine = (body: string): string | null => {
const match = body.match(/```[a-zA-Z0-9_-]*\r?\n([^\r\n]+)\r?\n/);
const line = (match?.[1] ?? "").trim();
return line ? truncateInline(line, 96) : null;
};
const extractToolArgSummary = (body: string): string | null => {
const matchers: Array<[RegExp, (m: RegExpMatchArray) => string | null]> = [
[/"command"\s*:\s*"([^"]+)"/, (m) => (m[1] ? m[1] : null)],
[/"file_path"\s*:\s*"([^"]+)"/, (m) => (m[1] ? m[1] : null)],
[/"filePath"\s*:\s*"([^"]+)"/, (m) => (m[1] ? m[1] : null)],
[/"path"\s*:\s*"([^"]+)"/, (m) => (m[1] ? m[1] : null)],
[/"url"\s*:\s*"([^"]+)"/, (m) => (m[1] ? m[1] : null)],
];
for (const [re, toSummary] of matchers) {
const m = body.match(re);
const summary = m ? toSummary(m) : null;
if (summary) return truncateInline(summary, 96);
}
return null;
};
export const summarizeToolLabel = (
line: string
): { summaryText: string; body: string; inlineOnly?: boolean } => {
const parsed = parseToolMarkdown(line);
const { toolLabel } = stripTrailingToolCallId(parsed.label);
const toolName = toDisplayToolName(toolLabel).toUpperCase();
const metaLine = parsed.kind === "result" ? extractToolMetaLine(parsed.body) : null;
const argSummary = parsed.kind === "call" ? extractToolArgSummary(parsed.body) : null;
const toolIsRead = toolName === "READ";
if (toolIsRead && parsed.kind === "call" && argSummary) {
return {
summaryText: `read ${argSummary}`,
body: "",
inlineOnly: true,
};
}
const suffix = metaLine ?? argSummary;
const toolIsExec = toolName === "EXEC";
const execSummary =
parsed.kind === "call"
? argSummary
: metaLine ?? extractFirstCodeBlockLine(parsed.body);
const summaryText = toolIsExec
? (execSummary ?? metaLine ?? toolName)
: (suffix ? `${toolName} · ${suffix}` : toolName);
return {
summaryText,
body: parsed.body,
};
};
@@ -0,0 +1,38 @@
import type { AgentStatus } from "@/features/agents/state/store";
import type { GatewayStatus } from "@/lib/gateway/GatewayClient";
export const AGENT_STATUS_LABEL: Record<AgentStatus, string> = {
idle: "Idle",
running: "Running",
error: "Error",
};
export const AGENT_STATUS_BADGE_CLASS: Record<AgentStatus, string> = {
idle: "ui-badge-status-idle",
running: "ui-badge-status-running",
error: "ui-badge-status-error",
};
export const GATEWAY_STATUS_LABEL: Record<GatewayStatus, string> = {
disconnected: "Disconnected",
connecting: "Connecting",
connected: "Connected",
};
export const GATEWAY_STATUS_BADGE_CLASS: Record<GatewayStatus, string> = {
disconnected: "ui-badge-status-disconnected",
connecting: "ui-badge-status-connecting",
connected: "ui-badge-status-connected",
};
export const NEEDS_APPROVAL_BADGE_CLASS = "ui-badge-approval";
export const resolveAgentStatusBadgeClass = (status: AgentStatus): string =>
AGENT_STATUS_BADGE_CLASS[status];
export const resolveGatewayStatusBadgeClass = (status: GatewayStatus): string =>
GATEWAY_STATUS_BADGE_CLASS[status];
export const resolveAgentStatusLabel = (status: AgentStatus): string => AGENT_STATUS_LABEL[status];
export const resolveGatewayStatusLabel = (status: GatewayStatus): string => GATEWAY_STATUS_LABEL[status];
@@ -0,0 +1,221 @@
"use client";
import { useCallback, useEffect, useMemo, type ReactNode } from "react";
import type { AgentState } from "@/features/agents/state/store";
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
import { parsePersonalityFiles, serializePersonalityFiles } from "@/lib/agents/personalityBuilder";
import { useAgentFilesEditor } from "@/features/agents/hooks/useAgentFilesEditor";
export type AgentBrainPanelProps = {
client: GatewayClient;
agents: AgentState[];
selectedAgentId: string | null;
onUnsavedChangesChange?: (dirty: boolean) => void;
};
const AgentBrainPanelSection = ({
title,
children,
}: {
title: string;
children: ReactNode;
}) => (
<section className="space-y-3 border-t border-border/55 pt-8 first:border-t-0 first:pt-0">
<h3 className="text-sm font-medium text-foreground">{title}</h3>
{children}
</section>
);
export const AgentBrainPanel = ({
client,
agents,
selectedAgentId,
onUnsavedChangesChange,
}: AgentBrainPanelProps) => {
const selectedAgent = useMemo(
() =>
selectedAgentId
? agents.find((entry) => entry.agentId === selectedAgentId) ?? null
: null,
[agents, selectedAgentId]
);
const {
agentFiles,
agentFilesLoading,
agentFilesSaving,
agentFilesDirty,
agentFilesError,
setAgentFileContent,
saveAgentFiles,
discardAgentFileChanges,
} = useAgentFilesEditor({ client, agentId: selectedAgent?.agentId ?? null });
const draft = useMemo(() => parsePersonalityFiles(agentFiles), [agentFiles]);
const setIdentityField = useCallback(
(field: "name" | "creature" | "vibe" | "emoji" | "avatar", value: string) => {
const nextDraft = parsePersonalityFiles(agentFiles);
nextDraft.identity[field] = value;
const serialized = serializePersonalityFiles(nextDraft);
setAgentFileContent("IDENTITY.md", serialized["IDENTITY.md"]);
},
[agentFiles, setAgentFileContent]
);
const handleSave = useCallback(async () => {
if (agentFilesLoading || agentFilesSaving || !agentFilesDirty) return;
await saveAgentFiles();
}, [agentFilesDirty, agentFilesLoading, agentFilesSaving, saveAgentFiles]);
useEffect(() => {
onUnsavedChangesChange?.(agentFilesDirty);
}, [agentFilesDirty, onUnsavedChangesChange]);
useEffect(() => {
return () => {
onUnsavedChangesChange?.(false);
};
}, [onUnsavedChangesChange]);
return (
<div
className="agent-inspect-panel flex min-h-0 flex-col overflow-hidden"
data-testid="agent-personality-panel"
style={{ position: "relative", left: "auto", top: "auto", width: "100%", height: "100%" }}
>
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto px-6 py-6">
<section
className="mx-auto flex min-h-0 w-full max-w-[920px] flex-col"
data-testid="agent-personality-files"
>
{agentFilesError ? (
<div className="ui-alert-danger mb-4 rounded-md px-3 py-2 text-xs">
{agentFilesError}
</div>
) : null}
<div className="mb-6 flex items-center justify-end gap-2">
<button
type="button"
className="ui-btn-secondary px-3 py-1 font-mono text-[10px] font-semibold tracking-[0.06em] disabled:opacity-50"
disabled={agentFilesLoading || agentFilesSaving || !agentFilesDirty}
onClick={discardAgentFileChanges}
>
Discard
</button>
<button
type="button"
className="ui-btn-primary px-3 py-1 font-mono text-[10px] font-semibold tracking-[0.06em] disabled:cursor-not-allowed disabled:border-border disabled:bg-muted disabled:text-muted-foreground"
disabled={agentFilesLoading || agentFilesSaving || !agentFilesDirty}
onClick={() => {
void handleSave();
}}
>
Save
</button>
</div>
<div className="space-y-8 pb-8">
<AgentBrainPanelSection title="Persona">
<textarea
aria-label="Persona"
className="h-56 w-full resize-y rounded-md border border-border/80 bg-background px-4 py-3 font-mono text-sm leading-6 text-foreground outline-none"
value={agentFiles["SOUL.md"].content}
disabled={agentFilesLoading || agentFilesSaving}
onChange={(event) => {
setAgentFileContent("SOUL.md", event.target.value);
}}
/>
</AgentBrainPanelSection>
<AgentBrainPanelSection title="Directives">
<textarea
aria-label="Directives"
className="h-56 w-full resize-y rounded-md border border-border/80 bg-background px-4 py-3 font-mono text-sm leading-6 text-foreground outline-none"
value={agentFiles["AGENTS.md"].content}
disabled={agentFilesLoading || agentFilesSaving}
onChange={(event) => {
setAgentFileContent("AGENTS.md", event.target.value);
}}
/>
</AgentBrainPanelSection>
<AgentBrainPanelSection title="Context">
<textarea
aria-label="Context"
className="h-56 w-full resize-y rounded-md border border-border/80 bg-background px-4 py-3 font-mono text-sm leading-6 text-foreground outline-none"
value={agentFiles["USER.md"].content}
disabled={agentFilesLoading || agentFilesSaving}
onChange={(event) => {
setAgentFileContent("USER.md", event.target.value);
}}
/>
</AgentBrainPanelSection>
<section className="space-y-3 border-t border-border/55 pt-8">
<h3 className="text-sm font-medium text-foreground">Identity</h3>
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-2 text-xs text-muted-foreground">
Name
<input
className="h-10 rounded-md border border-border/80 bg-background px-3 text-sm text-foreground outline-none"
value={draft.identity.name}
disabled={agentFilesLoading || agentFilesSaving}
onChange={(event) => {
setIdentityField("name", event.target.value);
}}
/>
</label>
<label className="flex flex-col gap-2 text-xs text-muted-foreground">
Creature
<input
className="h-10 rounded-md border border-border/80 bg-background px-3 text-sm text-foreground outline-none"
value={draft.identity.creature}
disabled={agentFilesLoading || agentFilesSaving}
onChange={(event) => {
setIdentityField("creature", event.target.value);
}}
/>
</label>
<label className="flex flex-col gap-2 text-xs text-muted-foreground">
Vibe
<input
className="h-10 rounded-md border border-border/80 bg-background px-3 text-sm text-foreground outline-none"
value={draft.identity.vibe}
disabled={agentFilesLoading || agentFilesSaving}
onChange={(event) => {
setIdentityField("vibe", event.target.value);
}}
/>
</label>
<label className="flex flex-col gap-2 text-xs text-muted-foreground">
Emoji
<input
className="h-10 rounded-md border border-border/80 bg-background px-3 text-sm text-foreground outline-none"
value={draft.identity.emoji}
disabled={agentFilesLoading || agentFilesSaving}
onChange={(event) => {
setIdentityField("emoji", event.target.value);
}}
/>
</label>
</div>
<label className="flex flex-col gap-2 text-xs text-muted-foreground">
Avatar
<input
className="h-10 rounded-md border border-border/80 bg-background px-3 text-sm text-foreground outline-none"
value={draft.identity.avatar}
disabled={agentFilesLoading || agentFilesSaving}
onChange={(event) => {
setIdentityField("avatar", event.target.value);
}}
/>
</label>
</section>
</div>
</section>
</div>
</div>
);
};
@@ -0,0 +1,61 @@
"use client";
import { X } from "lucide-react";
export type AgentInspectHeaderProps = {
label?: string;
title?: string;
onClose: () => void;
closeTestId: string;
closeDisabled?: boolean;
};
export const AgentInspectHeader = ({
label,
title,
onClose,
closeTestId,
closeDisabled,
}: AgentInspectHeaderProps) => {
const normalizedLabel = label?.trim() ?? "";
const normalizedTitle = title?.trim() ?? "";
const hasLabel = normalizedLabel.length > 0;
const hasTitle = normalizedTitle.length > 0;
if (!hasLabel && !hasTitle) {
return null;
}
return (
<div className="flex items-center justify-between pl-4 pr-2 pb-3 pt-2">
<div>
{hasLabel ? (
<div className="font-mono text-[9px] font-medium tracking-[0.04em] text-muted-foreground/58">
{normalizedLabel}
</div>
) : null}
{hasTitle ? (
<div
className={
hasLabel
? "text-[1.45rem] font-semibold leading-[1.05] tracking-[0.01em] text-foreground"
: "font-mono text-[12px] font-semibold tracking-[0.05em] text-foreground"
}
>
{normalizedTitle}
</div>
) : null}
</div>
<button
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground/55 transition hover:bg-surface-2 hover:text-muted-foreground/85"
type="button"
data-testid={closeTestId}
aria-label="Close panel"
disabled={closeDisabled}
onClick={onClose}
>
<X className="h-4 w-4" aria-hidden="true" />
</button>
</div>
);
};
File diff suppressed because it is too large Load Diff