First Release of Claw3D (#11)
Co-authored-by: iamlukethedev <iamlukethedev@users.noreply.github.com>
This commit is contained in:
@@ -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://<your-tailnet-host></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>
|
||||
);
|
||||
};
|
||||
@@ -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
Reference in New Issue
Block a user