9 Commits

19 changed files with 3741 additions and 10 deletions

View File

@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -20,6 +20,7 @@
"@omnia/scenario": "workspace:*",
"@omnia/spatial": "workspace:*",
"@radix-ui/react-dialog": "^1.1.19",
"@radix-ui/react-dropdown-menu": "^2.1.20",
"@radix-ui/react-separator": "^1.1.11",
"@radix-ui/react-slot": "^1.3.0",
"@radix-ui/react-tooltip": "^1.2.12",

View File

@@ -10,6 +10,7 @@ import {
AVAILABLE_PROVIDERS,
ModelProviderMeta,
} from "@omnia/llm";
import { ScenarioSchema } from "@omnia/scenario";
function resolveScenarioPath(relative: string): string {
const cwd = process.cwd();
@@ -313,3 +314,42 @@ export async function regenerateEmbeddings(
): Promise<void> {
await simulationManager.regenerateAllEmbeddings(newProviderInstanceId);
}
export async function saveScenario(
scenario: unknown,
): Promise<{ ok: true } | { ok: false; error: string }> {
try {
const parsed = ScenarioSchema.parse(scenario);
const cwd = process.cwd();
const dir = path.resolve(cwd, "content/demo/scenarios");
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
const filePath = path.join(dir, `${parsed.id}.json`);
fs.writeFileSync(filePath, JSON.stringify(parsed, null, 2), "utf-8");
return { ok: true };
} catch (err) {
return {
ok: false,
error: err instanceof Error ? err.message : String(err),
};
}
}
export async function loadScenarioJson(
scenarioPath: string,
): Promise<{ ok: true; scenario: unknown } | { ok: false; error: string }> {
try {
const resolved = resolveScenarioPath(scenarioPath);
if (!fs.existsSync(resolved)) {
return { ok: false, error: `Scenario file not found: ${scenarioPath}` };
}
const content = JSON.parse(fs.readFileSync(resolved, "utf-8"));
return { ok: true, scenario: content };
} catch (err) {
return {
ok: false,
error: err instanceof Error ? err.message : String(err),
};
}
}

View File

@@ -1,18 +1,766 @@
"use client";
import { useEffect, useState, useMemo } from "react";
import { useRouter } from "next/navigation";
import {
SidebarProvider,
Sidebar,
SidebarContent,
} from "@/components/ui/sidebar";
import {
Menubar,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarItem,
MenubarSeparator,
MenubarSub,
MenubarSubTrigger,
MenubarSubContent,
MenubarRadioGroup,
MenubarRadioItem,
} from "@/components/ui/menubar";
import { getConfigStatus, loadScenarioJson, saveScenario } from "@/app/actions";
import type { Scenario } from "@omnia/scenario";
import {
Save,
FileJson,
Globe,
MapPin,
Users,
Info,
Eye,
Pencil,
} from "lucide-react";
// Import refactored builder components
import { MetadataTab } from "@/components/builder/MetadataTab";
import { LocationsTab } from "@/components/builder/LocationsTab";
import { EntitiesTab } from "@/components/builder/EntitiesTab";
import { JsonTab } from "@/components/builder/JsonTab";
import type {
LocationData,
EntityData,
AttributeData,
} from "@/components/builder/types";
const generateUUID = () => {
if (
typeof window !== "undefined" &&
window.crypto &&
window.crypto.randomUUID
) {
return window.crypto.randomUUID();
}
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
};
export default function BuilderPage() {
const router = useRouter();
// Load scenarios templates list
const [availableScenarios, setAvailableScenarios] = useState<
{ path: string; name: string; description: string }[]
>([]);
// Tabs: "metadata", "locations", "entities", "json"
const [activeTab, setActiveTab] = useState<
"metadata" | "locations" | "entities" | "json"
>("metadata");
// Form State
const [scenarioId, setScenarioId] = useState("");
const [name, setName] = useState("My Custom Scenario");
const [description, setDescription] = useState(
"A custom scenario template created via builder.",
);
const [startTime, setStartTime] = useState("2026-07-06T12:00:00.000Z");
const [worldAttributes, setWorldAttributes] = useState<AttributeData[]>([]);
const [locations, setLocations] = useState<LocationData[]>([]);
const [entities, setEntities] = useState<EntityData[]>([]);
// Selected sub-items for active editing lists
const [selectedLocIndex, setSelectedLocIndex] = useState(0);
const [selectedEntIndex, setSelectedEntIndex] = useState(0);
// Status & Notification Banners
const [statusMessage, setStatusMessage] = useState<{
text: string;
type: "success" | "error" | "info";
} | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
// Initialize dynamic UUIDs client-side to prevent NextJS SSR hydration mismatch
useEffect(() => {
if (!scenarioId) {
const uId = generateUUID();
const locId = generateUUID();
const entId = generateUUID();
setScenarioId(uId);
setLocations([{ id: locId, attributes: [], connections: [] }]);
setEntities([
{
id: entId,
locationId: locId,
attributes: [
{
name: "role",
value: "adventurer",
visibility: "PUBLIC",
allowedEntities: [],
},
],
aliases: {},
initialMemories: [],
},
]);
}
}, [scenarioId]);
// Fetch available templates on load
useEffect(() => {
async function loadTemplates() {
try {
const config = await getConfigStatus();
setAvailableScenarios(config.availableScenarios);
} catch (err) {
console.error("Failed to load scenario list:", err);
}
}
loadTemplates();
}, []);
// Set timeout to dismiss messages
useEffect(() => {
if (statusMessage) {
const timer = setTimeout(() => {
setStatusMessage(null);
}, 6000);
return () => clearTimeout(timer);
}
}, [statusMessage]);
// Populate helper lists
const locationIds = useMemo(
() => locations.map((l) => l.id).filter(Boolean),
[locations],
);
const entityIds = useMemo(
() => entities.map((e) => e.id).filter(Boolean),
[entities],
);
// Load selected template
const handleLoadTemplate = async (path: string) => {
if (!path) return;
setStatusMessage({ text: "Loading template...", type: "info" });
try {
const res = await loadScenarioJson(path);
if (!res.ok) {
setStatusMessage({
text: res.error || "Failed to load template.",
type: "error",
});
return;
}
const s = res.scenario as Scenario;
if (!s) {
setStatusMessage({
text: "Scenario template was empty.",
type: "error",
});
return;
}
setScenarioId(s.id || "custom-scenario");
setName(s.name || "Loaded Scenario");
setDescription(s.description || "");
setStartTime(s.startTime || "2026-07-06T12:00:00.000Z");
// World attributes
const wAttrs = (s.world?.attributes || []).map((a) => ({
name: a.name,
value: a.value,
visibility: a.visibility,
allowedEntities: a.allowedEntities || [],
}));
setWorldAttributes(wAttrs);
// Locations
const locs = (s.locations || []).map((l) => ({
id: l.id,
parentId: l.parentId || undefined,
attributes: (l.attributes || []).map((a) => ({
name: a.name,
value: a.value,
visibility: a.visibility,
allowedEntities: a.allowedEntities || [],
})),
connections: (l.connections || []).map((c) => ({
targetId: c.targetId,
portalName: c.portalName,
portalStateDescriptor: c.portalStateDescriptor,
visionProp: c.visionProp,
soundProp: c.soundProp,
bidirectional: c.bidirectional ?? true,
})),
}));
setLocations(
locs.length > 0
? locs
: [{ id: generateUUID(), attributes: [], connections: [] }],
);
setSelectedLocIndex(0);
// Entities
const ents = (s.entities || []).map((e) => ({
id: e.id,
locationId: e.locationId || undefined,
attributes: (e.attributes || []).map((a) => ({
name: a.name,
value: a.value,
visibility: a.visibility,
allowedEntities: a.allowedEntities || [],
})),
aliases: e.aliases || {},
initialMemories: (e.initialMemories || []).map((m) => ({
id: m.id || generateUUID(),
timestamp: m.timestamp || s.startTime,
locationId: m.locationId || null,
intent: {
type: m.intent.type,
originalText: m.intent.originalText,
description: m.intent.description,
selfDescription: m.intent.selfDescription,
actorId: m.intent.actorId || e.id,
targetIds: m.intent.targetIds || [],
modifiers: m.intent.modifiers || [],
},
outcome: m.outcome
? {
isValid: m.outcome.isValid,
reason: m.outcome.reason,
}
: undefined,
})),
}));
setEntities(
ents.length > 0
? ents
: [
{
id: generateUUID(),
locationId: locs[0]?.id || generateUUID(),
attributes: [],
aliases: {},
initialMemories: [],
},
],
);
setSelectedEntIndex(0);
setStatusMessage({
text: "Template loaded successfully!",
type: "success",
});
} catch (err) {
setStatusMessage({
text: err instanceof Error ? err.message : String(err),
type: "error",
});
}
};
// Compile full scenario object
const compiledScenario = useMemo(() => {
return {
id: scenarioId.trim(),
name: name.trim(),
description: description.trim(),
startTime: startTime.trim(),
world:
worldAttributes.length > 0
? {
attributes: worldAttributes.map((a) => ({
name: a.name.trim(),
value: a.value.trim(),
visibility: a.visibility,
...(a.visibility === "PRIVATE" && a.allowedEntities.length > 0
? { allowedEntities: a.allowedEntities }
: {}),
})),
}
: undefined,
locations: locations.map((l) => ({
id: l.id.trim(),
...(l.parentId ? { parentId: l.parentId } : {}),
...(l.attributes.length > 0
? {
attributes: l.attributes.map((a) => ({
name: a.name.trim(),
value: a.value.trim(),
visibility: a.visibility,
...(a.visibility === "PRIVATE" && a.allowedEntities.length > 0
? { allowedEntities: a.allowedEntities }
: {}),
})),
}
: {}),
...(l.connections.length > 0
? {
connections: l.connections.map((c) => ({
targetId: c.targetId,
...(c.portalName ? { portalName: c.portalName.trim() } : {}),
...(c.portalStateDescriptor
? { portalStateDescriptor: c.portalStateDescriptor.trim() }
: {}),
visionProp: Number(c.visionProp),
soundProp: Number(c.soundProp),
bidirectional: !!c.bidirectional,
})),
}
: {}),
})),
entities: entities.map((e) => ({
id: e.id.trim(),
...(e.locationId ? { locationId: e.locationId } : {}),
...(e.attributes.length > 0
? {
attributes: e.attributes.map((a) => ({
name: a.name.trim(),
value: a.value.trim(),
visibility: a.visibility,
...(a.visibility === "PRIVATE" && a.allowedEntities.length > 0
? { allowedEntities: a.allowedEntities }
: {}),
})),
}
: {}),
...(Object.keys(e.aliases).length > 0 ? { aliases: e.aliases } : {}),
...(e.initialMemories.length > 0
? {
initialMemories: e.initialMemories.map((m) => ({
id: m.id,
timestamp: m.timestamp,
locationId: m.locationId,
intent: {
type: m.intent.type,
originalText: m.intent.originalText.trim(),
description: m.intent.description.trim(),
...(m.intent.selfDescription
? { selfDescription: m.intent.selfDescription.trim() }
: {}),
actorId: m.intent.actorId,
targetIds: m.intent.targetIds,
...(m.intent.modifiers && m.intent.modifiers.length > 0
? { modifiers: m.intent.modifiers }
: []),
},
...(m.outcome
? {
outcome: {
isValid: !!m.outcome.isValid,
reason: m.outcome.reason.trim(),
},
}
: {}),
})),
}
: {}),
})),
};
}, [
scenarioId,
name,
description,
startTime,
worldAttributes,
locations,
entities,
]);
// Save scenario to server
const handleSaveToServer = async () => {
if (!scenarioId.trim()) {
setStatusMessage({
text: "Scenario Template ID is required to save.",
type: "error",
});
return;
}
setIsSubmitting(true);
setStatusMessage({ text: "Saving scenario file...", type: "info" });
try {
const res = await saveScenario(compiledScenario);
if (res.ok) {
setStatusMessage({
text: `Scenario template saved as ${scenarioId}.json successfully!`,
type: "success",
});
// Refresh template list
const config = await getConfigStatus();
setAvailableScenarios(config.availableScenarios);
} else {
setStatusMessage({
text: res.error || "Failed to save scenario.",
type: "error",
});
}
} catch (err) {
setStatusMessage({
text: err instanceof Error ? err.message : String(err),
type: "error",
});
} finally {
setIsSubmitting(false);
}
};
// Download scenario file directly
const handleDownloadJson = () => {
try {
const jsonStr = JSON.stringify(compiledScenario, null, 2);
const blob = new Blob([jsonStr], { type: "application/json" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${scenarioId || "scenario"}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
setStatusMessage({ text: "JSON download initiated.", type: "success" });
} catch {
setStatusMessage({ text: "Download failed.", type: "error" });
}
};
const handleResetScenario = () => {
setScenarioId("");
setName("My Custom Scenario");
setDescription("A custom scenario template created via builder.");
setStartTime("2026-07-06T12:00:00.000Z");
setWorldAttributes([]);
setSelectedLocIndex(0);
setSelectedEntIndex(0);
setStatusMessage({ text: "Scenario reset successfully.", type: "success" });
};
const handleAddLocation = () => {
const newId = generateUUID();
setLocations([
...locations,
{ id: newId, attributes: [], connections: [] },
]);
setSelectedLocIndex(locations.length);
setActiveTab("locations");
};
const handleAddEntity = () => {
const newId = generateUUID();
setEntities([
...entities,
{
id: newId,
locationId: locationIds[0] || "",
attributes: [],
aliases: {},
initialMemories: [],
isAgent: true,
},
]);
setSelectedEntIndex(entities.length);
setActiveTab("entities");
};
return (
<div className="flex-1 overflow-y-auto w-full">
<div className="mx-auto max-w-[800px] px-10 py-12">
<h1 className="mb-6 text-headline-lg text-primary animate-fade-in">
Scenario Builder
</h1>
<div className="border border-border/30 bg-card p-6 shadow-[2px_2px_0_0_var(--border)] min-h-[300px] flex flex-col items-center justify-center">
<p className="text-body-md text-muted-foreground font-mono text-center">
Scenario builder interface coming soon...
</p>
<div className="flex flex-col flex-1 min-h-0 w-full bg-background overflow-hidden">
{/* Save Status Banner */}
{statusMessage && (
<div
className={`fixed top-14 right-4 z-50 max-w-sm border p-4 shadow-lg animate-fade-in ${
statusMessage.type === "success"
? "bg-emerald-950/80 border-emerald-500 text-emerald-300"
: statusMessage.type === "error"
? "bg-destructive/10 border-destructive text-destructive"
: "bg-secondary/90 border-border text-foreground"
}`}
>
<div className="flex items-start gap-3">
<Info className="size-4 shrink-0 mt-0.5" />
<div className="text-xs">{statusMessage.text}</div>
</div>
</div>
)}
{/* Menubar spanning full page width right below navbar */}
<div className="w-full border-b border-border/20 bg-card py-1.5 px-4 flex items-center justify-between shrink-0">
<div className="flex items-center gap-4">
<span className="text-xs font-head text-primary tracking-wider font-bold flex items-center gap-1.5">
<Pencil className="size-4 text-primary" />
</span>
<Menubar className="border-none shadow-none bg-transparent h-7 p-0">
<MenubarMenu>
<MenubarTrigger className="cursor-pointer">File</MenubarTrigger>
<MenubarContent>
<MenubarSub>
<MenubarSubTrigger className="cursor-pointer">
Load Template
</MenubarSubTrigger>
<MenubarSubContent>
{availableScenarios.length === 0 ? (
<MenubarItem disabled>No templates</MenubarItem>
) : (
availableScenarios.map((sc) => (
<MenubarItem
key={sc.path}
className="cursor-pointer"
onClick={() => {
handleLoadTemplate(sc.path);
}}
>
{sc.name}
</MenubarItem>
))
)}
</MenubarSubContent>
</MenubarSub>
<MenubarSeparator />
<MenubarItem
className="cursor-pointer"
onClick={handleSaveToServer}
disabled={isSubmitting || !scenarioId.trim()}
>
<Save className="size-4 mr-2 inline" /> Save to Server
</MenubarItem>
<MenubarItem
className="cursor-pointer"
onClick={handleDownloadJson}
>
<FileJson className="size-4 mr-2 inline" /> Export JSON
</MenubarItem>
</MenubarContent>
</MenubarMenu>
<MenubarMenu>
<MenubarTrigger className="cursor-pointer">Edit</MenubarTrigger>
<MenubarContent>
<MenubarItem
className="cursor-pointer"
onClick={handleResetScenario}
>
Reset Scenario
</MenubarItem>
<MenubarSeparator />
<MenubarItem
className="cursor-pointer"
onClick={handleAddLocation}
>
Add New Location
</MenubarItem>
<MenubarItem
className="cursor-pointer"
onClick={handleAddEntity}
>
Add New Entity
</MenubarItem>
</MenubarContent>
</MenubarMenu>
<MenubarMenu>
<MenubarTrigger className="cursor-pointer">View</MenubarTrigger>
<MenubarContent>
<MenubarRadioGroup
value={activeTab}
onValueChange={(val) => setActiveTab(val as typeof activeTab)}
>
<MenubarRadioItem value="metadata" className="cursor-pointer">
World Metadata
</MenubarRadioItem>
<MenubarRadioItem
value="locations"
className="cursor-pointer"
>
Locations
</MenubarRadioItem>
<MenubarRadioItem value="entities" className="cursor-pointer">
Entities
</MenubarRadioItem>
<MenubarRadioItem value="json" className="cursor-pointer">
Live JSON Preview
</MenubarRadioItem>
</MenubarRadioGroup>
</MenubarContent>
</MenubarMenu>
</Menubar>
</div>
<div className="text-[10px] font-mono text-muted-foreground">
{scenarioId ? `ID: ${scenarioId}` : "Unsaved Scenario"}
</div>
</div>
<SidebarProvider className="flex-1 min-h-0">
<div className="flex flex-1 min-h-0 w-full overflow-hidden">
{/* Viewport-level Vertical Sidebar on the Left Side */}
<Sidebar
collapsible="none"
className="h-full border-r border-border/30 bg-card shrink-0"
>
<SidebarContent className="flex flex-col justify-between h-full bg-card p-6">
<div className="flex flex-col gap-2 font-head">
<span className="text-[10px] font-mono uppercase tracking-wider text-muted-foreground block mb-2">
Configuration
</span>
<button
onClick={() => setActiveTab("metadata")}
className={`w-full text-left px-4 py-2.5 text-xs font-mono font-bold tracking-wide border transition-all flex items-center gap-2 cursor-pointer ${
activeTab === "metadata"
? "border-primary bg-primary/10 text-primary shadow-[2px_2px_0_0_var(--primary)] font-bold"
: "border-border/30 hover:bg-secondary text-muted-foreground hover:text-foreground"
}`}
>
<Globe className="size-3.5" />
<span>World Metadata</span>
</button>
<button
onClick={() => setActiveTab("locations")}
className={`w-full text-left px-4 py-2.5 text-xs font-mono font-bold tracking-wide border transition-all flex items-center gap-2 cursor-pointer ${
activeTab === "locations"
? "border-primary bg-primary/10 text-primary shadow-[2px_2px_0_0_var(--primary)] font-bold"
: "border-border/30 hover:bg-secondary text-muted-foreground hover:text-foreground"
}`}
>
<MapPin className="size-3.5" />
<span>Locations</span>
<span className="ml-auto text-[10px] font-mono border border-muted-foreground/20 bg-muted/10 px-1 rounded">
{locations.length}
</span>
</button>
<button
onClick={() => setActiveTab("entities")}
className={`w-full text-left px-4 py-2.5 text-xs font-mono font-bold tracking-wide border transition-all flex items-center gap-2 cursor-pointer ${
activeTab === "entities"
? "border-primary bg-primary/10 text-primary shadow-[2px_2px_0_0_var(--primary)] font-bold"
: "border-border/30 hover:bg-secondary text-muted-foreground hover:text-foreground"
}`}
>
<Users className="size-3.5" />
<span>Entities</span>
<span className="ml-auto text-[10px] font-mono border border-muted-foreground/20 bg-muted/10 px-1 rounded">
{entities.length}
</span>
</button>
<button
onClick={() => setActiveTab("json")}
className={`w-full text-left px-4 py-2.5 text-xs font-mono font-bold tracking-wide border transition-all flex items-center gap-2 cursor-pointer ${
activeTab === "json"
? "border-primary bg-primary/10 text-primary shadow-[2px_2px_0_0_var(--primary)] font-bold"
: "border-border/30 hover:bg-secondary text-muted-foreground hover:text-foreground"
}`}
>
<Eye className="size-3.5" />
<span>Live JSON Preview</span>
</button>
</div>
{/* Sidebar Footer link */}
<div className="border-t border-border/10 pt-3 flex items-center justify-between text-[10px] text-muted-foreground">
<button
onClick={() => router.push("/")}
className="w-full py-1 text-center hover:text-foreground text-primary font-bold uppercase transition-colors cursor-pointer"
>
Back to Dashboard
</button>
</div>
</SidebarContent>
</Sidebar>
{/* Main Centered Content Pane on the Right */}
<main className="flex-1 overflow-y-auto px-10 py-8 min-h-0 flex flex-col">
<div className="mx-auto max-w-[1200px] w-full flex-1 flex flex-col min-h-0 gap-6">
{/* Header block with Page Name */}
<div className="shrink-0">
<h1 className="text-headline-md text-primary flex items-center gap-2 font-head">
Scenario Builder
</h1>
</div>
{/* Active configuration tab form */}
<div className="flex-1 min-h-0">
{/* TAB 1: World Metadata & Attributes */}
{activeTab === "metadata" && (
<MetadataTab
scenarioId={scenarioId}
setScenarioId={setScenarioId}
name={name}
setName={setName}
description={description}
setDescription={setDescription}
startTime={startTime}
setStartTime={setStartTime}
worldAttributes={worldAttributes}
setWorldAttributes={setWorldAttributes}
entityIds={entityIds}
entities={entities}
/>
)}
{/* TAB 2: Locations & Spatial connections */}
{activeTab === "locations" && (
<LocationsTab
locations={locations}
setLocations={setLocations}
entities={entities}
locationIds={locationIds}
entityIds={entityIds}
selectedLocIndex={selectedLocIndex}
setSelectedLocIndex={setSelectedLocIndex}
generateUUID={generateUUID}
/>
)}
{/* TAB 3: Entities */}
{activeTab === "entities" && (
<EntitiesTab
entities={entities}
setEntities={setEntities}
locations={locations}
locationIds={locationIds}
entityIds={entityIds}
selectedEntIndex={selectedEntIndex}
setSelectedEntIndex={setSelectedEntIndex}
startTime={startTime}
generateUUID={generateUUID}
/>
)}
{/* TAB 4: Live JSON Preview */}
{activeTab === "json" && (
<JsonTab
compiledScenario={compiledScenario}
onCopySuccess={() =>
setStatusMessage({
text: "JSON copied to clipboard!",
type: "success",
})
}
/>
)}
</div>
</div>
</main>
</div>
</SidebarProvider>
</div>
);
}

View File

@@ -8,6 +8,7 @@
@custom-variant data-horizontal (&[data-orientation="horizontal"]);
@custom-variant data-vertical (&[data-orientation="vertical"]);
@custom-variant data-popup-open (&[data-state="open"]);
@custom-variant data-highlighted (&[data-highlighted]);
@theme inline {
--font-head: var(--font-head);

View File

@@ -34,6 +34,7 @@ const spaceMono = Space_Mono({
const links = [
{ href: "/", label: "Home" },
{ href: "/builder", label: "Builder" },
{ href: "/config", label: "Config" },
];

View File

@@ -0,0 +1,167 @@
"use client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Plus, Trash2 } from "lucide-react";
import type { AttributeData, EntityData } from "./types";
import { getEntityDisplayNameById } from "./utils";
interface AttributeEditorProps {
title?: string;
attributes: AttributeData[];
onChange: (attrs: AttributeData[]) => void;
onAdd: () => void;
entityIds: string[];
entities?: EntityData[];
}
export function AttributeEditor({
title = "Attributes",
attributes,
onChange,
onAdd,
entityIds,
entities,
}: AttributeEditorProps) {
const handleAttrChange = <K extends keyof AttributeData>(
index: number,
key: K,
val: AttributeData[K],
) => {
const copy = [...attributes];
copy[index] = { ...copy[index], [key]: val };
onChange(copy);
};
const handleToggleEntityAccess = (index: number, entId: string) => {
const copy = [...attributes];
const allowed = copy[index].allowedEntities || [];
if (allowed.includes(entId)) {
copy[index].allowedEntities = allowed.filter((id) => id !== entId);
} else {
copy[index].allowedEntities = [...allowed, entId];
}
onChange(copy);
};
return (
<div className="space-y-4">
<div className="flex justify-between items-center border-b border-border/20 pb-2">
<h3 className="text-body-md font-mono text-foreground font-bold">
{title}
</h3>
<Button
type="button"
size="sm"
variant="outline"
onClick={onAdd}
className="h-7 text-xs flex gap-1 cursor-pointer"
>
<Plus className="size-3" /> Add Attribute
</Button>
</div>
{attributes.length === 0 ? (
<p className="text-xs text-muted-foreground italic">
No attributes defined yet.
</p>
) : (
<div className="space-y-3">
{attributes.map((attr, index) => (
<div
key={index}
className="border border-border/20 bg-secondary/10 p-3 rounded space-y-3"
>
<div className="flex gap-2 items-center">
<div className="flex-1 grid grid-cols-2 gap-2">
<Input
placeholder="Name (e.g. role)"
value={attr.name}
onChange={(e) =>
handleAttrChange(index, "name", e.target.value)
}
className="h-8 font-mono text-xs"
/>
<Input
placeholder="Value (e.g. merchant)"
value={attr.value}
onChange={(e) =>
handleAttrChange(index, "value", e.target.value)
}
className="h-8 text-xs"
/>
</div>
<Button
type="button"
variant="destructive"
size="icon"
className="size-8 shrink-0 cursor-pointer"
onClick={() =>
onChange(attributes.filter((_, i) => i !== index))
}
>
<Trash2 className="size-4" />
</Button>
</div>
<div className="flex items-center gap-4 text-xs">
<div className="flex items-center gap-2">
<Label className="text-muted-foreground cursor-pointer flex items-center gap-1.5">
<Checkbox
checked={attr.visibility === "PUBLIC"}
onCheckedChange={(checked) =>
handleAttrChange(
index,
"visibility",
checked ? "PUBLIC" : "PRIVATE",
)
}
/>
Publicly Visible
</Label>
</div>
{attr.visibility === "PRIVATE" && (
<div className="flex-1 border-l border-border/25 pl-4">
<span className="text-muted-foreground font-semibold block mb-1">
Visible to Entities:
</span>
{entityIds.length === 0 ? (
<span className="text-[10px] italic text-muted-foreground">
Add entities first to grant private access
</span>
) : (
<div className="flex flex-wrap gap-1">
{entityIds.map((entId) => {
const isAllowed =
attr.allowedEntities?.includes(entId);
return (
<button
key={entId}
type="button"
onClick={() =>
handleToggleEntityAccess(index, entId)
}
className={`px-2 py-0.5 rounded text-[10px] font-mono border transition-all cursor-pointer ${
isAllowed
? "bg-primary/20 border-primary text-primary font-bold"
: "bg-background border-border/30 text-muted-foreground hover:bg-secondary"
}`}
>
{entities
? getEntityDisplayNameById(entId, entities)
: entId}
</button>
);
})}
</div>
)}
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,563 @@
"use client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox";
import { AttributeEditor } from "./AttributeEditor";
import { Plus, Trash2, ChevronRight } from "lucide-react";
import type { EntityData, MemoryData, LocationData } from "./types";
import {
getEntityDisplayName,
getEntityDisplayNameById,
getLocationDisplayNameById,
} from "./utils";
interface EntitiesTabProps {
entities: EntityData[];
setEntities: (ents: EntityData[]) => void;
locations: LocationData[];
locationIds: string[];
entityIds: string[];
selectedEntIndex: number;
setSelectedEntIndex: (idx: number) => void;
startTime: string;
generateUUID: () => string;
}
export function EntitiesTab({
entities,
setEntities,
locations,
locationIds,
entityIds,
selectedEntIndex,
setSelectedEntIndex,
startTime,
generateUUID,
}: EntitiesTabProps) {
const selectedEnt = entities[selectedEntIndex];
return (
<div className="grid grid-cols-1 md:grid-cols-4 gap-8 items-start min-h-0 pb-12">
{/* Left sidebar: Entities list */}
<div className="md:col-span-1 border border-border/20 bg-card shadow-[2px_2px_0_0_var(--border)] flex flex-col max-h-[500px]">
<div className="p-3 border-b border-border/25 flex justify-between items-center bg-secondary/15">
<strong className="text-xs font-mono uppercase tracking-wider text-muted-foreground">
Entities
</strong>
<Button
type="button"
size="sm"
onClick={() => {
const newId = generateUUID();
setEntities([
...entities,
{
id: newId,
locationId: locationIds[0],
attributes: [],
aliases: {},
initialMemories: [],
isAgent: true,
},
]);
setSelectedEntIndex(entities.length);
}}
className="h-6 text-[10px] px-2 flex gap-1 cursor-pointer"
>
<Plus className="size-3" /> Add
</Button>
</div>
<div className="overflow-y-auto divide-y divide-border/10 flex-1">
{entities.map((ent, idx) => (
<div
key={idx}
onClick={() => setSelectedEntIndex(idx)}
className={`p-3 text-xs font-mono cursor-pointer flex justify-between items-center transition-all ${
selectedEntIndex === idx
? "bg-primary/10 text-primary font-bold border-l-4 border-primary"
: "hover:bg-secondary/40 text-foreground"
}`}
>
<span className="truncate">
{getEntityDisplayName(ent) || `(Empty ID)`}
</span>
{entities.length > 1 && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
const newEnts = entities.filter((_, i) => i !== idx);
setEntities(newEnts);
setSelectedEntIndex(Math.max(0, idx - 1));
}}
className="text-muted-foreground hover:text-destructive pl-2 cursor-pointer"
>
<Trash2 className="size-3.5" />
</button>
)}
</div>
))}
</div>
</div>
{/* Right panel: Edit selected entity details */}
{selectedEnt ? (
<div className="md:col-span-3 space-y-6">
{/* Entity Configuration Card */}
<div className="border border-border/20 bg-card p-6 shadow-[2px_2px_0_0_var(--border)] space-y-4">
<h2 className="text-body-lg text-primary font-bold border-b border-border/20 pb-2">
Entity Configuration
</h2>
<div className="space-y-1.5">
<Label>Entity ID</Label>
<Input
value={selectedEnt.id}
readOnly
className="font-mono text-xs bg-muted cursor-not-allowed text-muted-foreground"
/>
</div>
<div className="space-y-1.5">
<Label>Current Location</Label>
<Combobox
items={locationIds}
value={selectedEnt.locationId || ""}
onValueChange={(value) => {
const copy = [...entities];
copy[selectedEntIndex].locationId = value || undefined;
setEntities(copy);
}}
>
<ComboboxInput placeholder="Select a location..." showClear />
<ComboboxContent>
<ComboboxEmpty>No locations found.</ComboboxEmpty>
<ComboboxList>
{(id: string) => (
<ComboboxItem key={id} value={id}>
{getLocationDisplayNameById(id, locations)}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
<div className="space-y-1.5 pt-1 pb-2">
<div className="flex items-center gap-2">
<Label className="text-muted-foreground cursor-pointer flex items-center gap-1.5 text-sm font-semibold">
<Checkbox
checked={selectedEnt.isAgent !== false}
onCheckedChange={(checked) => {
const copy = [...entities];
copy[selectedEntIndex].isAgent = !!checked;
setEntities(copy);
}}
/>
Is Agent?
</Label>
</div>
<p className="text-[11px] text-muted-foreground/75 pl-5 select-none leading-normal">
When enabled, this entity will run an autonomous LLM loop to
perceive its environment, update its memories, and generate
prose narrative actions.
</p>
</div>
{/* Attributes */}
<div className="pt-2">
<AttributeEditor
title="Entity Attributes"
attributes={selectedEnt.attributes || []}
onChange={(newAttrs) => {
const copy = [...entities];
copy[selectedEntIndex].attributes = newAttrs;
setEntities(copy);
}}
onAdd={() => {
const copy = [...entities];
copy[selectedEntIndex].attributes = [
...copy[selectedEntIndex].attributes,
{
name: "",
value: "",
visibility: "PUBLIC",
allowedEntities: [],
},
];
setEntities(copy);
}}
entityIds={entityIds}
entities={entities}
/>
</div>
</div>
{/* Aliases Card */}
<div className="border border-border/20 bg-card p-6 shadow-[2px_2px_0_0_var(--border)] space-y-3">
<div className="flex justify-between items-center border-b border-border/20 pb-2">
<h3 className="text-body-md text-foreground font-bold">
Aliases (Perceptions)
</h3>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
const copy = [...entities];
const target = entityIds.find(
(id) => id !== selectedEnt.id && !selectedEnt.aliases[id],
);
if (target) {
copy[selectedEntIndex].aliases = {
...selectedEnt.aliases,
[target]: "",
};
setEntities(copy);
}
}}
disabled={entityIds.length <= 1}
className="h-7 text-xs flex gap-1 cursor-pointer"
>
<Plus className="size-3" /> Add Alias
</Button>
</div>
{Object.keys(selectedEnt.aliases || {}).length === 0 ? (
<p className="text-xs text-muted-foreground italic">
No descriptive aliases configured. Defaults to actual entity ID.
</p>
) : (
<div className="space-y-2">
{Object.entries(selectedEnt.aliases).map(
([targetId, aliasText]) => (
<div
key={targetId}
className="flex gap-2 items-center bg-secondary/15 p-2 rounded"
>
<span
className="text-[11px] font-mono text-muted-foreground w-1/3 truncate"
title={targetId}
>
{getEntityDisplayNameById(targetId, entities)}
</span>
<ChevronRight className="size-3 text-muted-foreground shrink-0" />
<Input
placeholder="Descriptive name (e.g. the guard)"
value={aliasText}
onChange={(e) => {
const copy = [...entities];
copy[selectedEntIndex].aliases = {
...selectedEnt.aliases,
[targetId]: e.target.value,
};
setEntities(copy);
}}
className="h-7 text-xs flex-1"
/>
<button
type="button"
onClick={() => {
const copy = [...entities];
const updated = { ...selectedEnt.aliases };
delete updated[targetId];
copy[selectedEntIndex].aliases = updated;
setEntities(copy);
}}
className="text-muted-foreground hover:text-destructive cursor-pointer"
>
<Trash2 className="size-3.5" />
</button>
</div>
),
)}
</div>
)}
</div>
{/* Initial Memories Card */}
<div className="border border-border/20 bg-card p-6 shadow-[2px_2px_0_0_var(--border)] space-y-3">
<div className="flex justify-between items-center border-b border-border/20 pb-2">
<h3 className="text-body-md text-foreground font-bold">
Initial Memories
</h3>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
const copy = [...entities];
const newMem: MemoryData = {
id: generateUUID(),
timestamp: startTime,
locationId: selectedEnt.locationId || null,
intent: {
type: "dialogue",
originalText: "",
description: "",
actorId: selectedEnt.id,
targetIds: [],
},
};
copy[selectedEntIndex].initialMemories = [
...selectedEnt.initialMemories,
newMem,
];
setEntities(copy);
}}
className="h-7 text-xs flex gap-1 cursor-pointer"
>
<Plus className="size-3" /> Add Memory
</Button>
</div>
{!selectedEnt.initialMemories ||
selectedEnt.initialMemories.length === 0 ? (
<p className="text-xs text-muted-foreground italic">
No initial memories loaded. Entities will start blank.
</p>
) : (
<div className="space-y-4 overflow-y-auto max-h-[300px] pr-1">
{selectedEnt.initialMemories.map((mem, memIdx) => (
<div
key={mem.id}
className="border border-border/20 bg-secondary/5 p-3 rounded space-y-3 relative"
>
<button
type="button"
onClick={() => {
const copy = [...entities];
copy[selectedEntIndex].initialMemories =
selectedEnt.initialMemories.filter(
(_, i) => i !== memIdx,
);
setEntities(copy);
}}
className="absolute top-2 right-2 text-muted-foreground hover:text-destructive cursor-pointer"
>
<Trash2 className="size-3.5" />
</button>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<span className="text-[10px] font-semibold text-muted-foreground">
Type
</span>
<select
className="bg-card border border-border/30 px-2 py-0.5 text-xs outline-none w-full rounded"
value={mem.intent.type}
onChange={(e) => {
const copy = [...entities];
copy[selectedEntIndex].initialMemories[
memIdx
].intent.type = e.target.value as
"dialogue" | "action" | "monologue";
setEntities(copy);
}}
>
<option value="dialogue">Dialogue</option>
<option value="action">Action</option>
<option value="monologue">Monologue</option>
</select>
</div>
<div className="space-y-1">
<span className="text-[10px] font-semibold text-muted-foreground">
Location
</span>
<select
className="bg-card border border-border/30 px-2 py-0.5 text-xs outline-none w-full rounded font-mono"
value={mem.locationId || ""}
onChange={(e) => {
const copy = [...entities];
copy[selectedEntIndex].initialMemories[
memIdx
].locationId = e.target.value || null;
setEntities(copy);
}}
>
<option value="">-- Nowhere --</option>
{locationIds.map((id) => (
<option key={id} value={id}>
{getLocationDisplayNameById(id, locations)}
</option>
))}
</select>
</div>
</div>
<div className="space-y-1.5">
<span className="text-[10px] font-semibold text-muted-foreground">
Verbatim Text (originalText)
</span>
<Input
placeholder='e.g. "We should leave," Alice said.'
value={mem.intent.originalText}
onChange={(e) => {
const copy = [...entities];
copy[selectedEntIndex].initialMemories[
memIdx
].intent.originalText = e.target.value;
setEntities(copy);
}}
className="h-7 text-xs"
/>
</div>
<div className="space-y-1.5">
<span className="text-[10px] font-semibold text-muted-foreground">
Objective Description
</span>
<Input
placeholder="e.g. Alice says she wants to leave."
value={mem.intent.description}
onChange={(e) => {
const copy = [...entities];
copy[selectedEntIndex].initialMemories[
memIdx
].intent.description = e.target.value;
setEntities(copy);
}}
className="h-7 text-xs"
/>
</div>
{/* Targets multi-select */}
<div className="space-y-1">
<span className="text-[10px] font-semibold text-muted-foreground block">
Involved Targets
</span>
<div className="flex flex-wrap gap-1">
{entityIds
.filter((id) => id !== selectedEnt.id)
.map((entId) => {
const isSelected =
mem.intent.targetIds?.includes(entId);
return (
<button
key={entId}
type="button"
onClick={() => {
const copy = [...entities];
const targets =
copy[selectedEntIndex].initialMemories[
memIdx
].intent.targetIds || [];
if (targets.includes(entId)) {
copy[selectedEntIndex].initialMemories[
memIdx
].intent.targetIds = targets.filter(
(t) => t !== entId,
);
} else {
copy[selectedEntIndex].initialMemories[
memIdx
].intent.targetIds = [...targets, entId];
}
setEntities(copy);
}}
className={`px-1.5 py-0.5 rounded text-[9px] font-mono border transition-all cursor-pointer ${
isSelected
? "bg-primary/20 border-primary text-primary"
: "bg-background border-border/30 text-muted-foreground hover:bg-secondary"
}`}
>
{getEntityDisplayNameById(entId, entities)}
</button>
);
})}
</div>
</div>
{/* Action Validation outcome */}
{mem.intent.type === "action" && (
<div className="border-t border-border/20 pt-2 space-y-2">
<Label className="text-[10px] text-muted-foreground flex items-center gap-1.5 cursor-pointer">
<Checkbox
checked={!!mem.outcome}
onCheckedChange={(checked) => {
const copy = [...entities];
if (checked) {
copy[selectedEntIndex].initialMemories[
memIdx
].outcome = { isValid: true, reason: "" };
} else {
copy[selectedEntIndex].initialMemories[
memIdx
].outcome = undefined;
}
setEntities(copy);
}}
/>
Include validation outcome
</Label>
{mem.outcome && (
<div className="grid grid-cols-3 gap-2 bg-secondary/15 p-2 rounded">
<div className="col-span-1 flex flex-col justify-center">
<Label className="text-[9px] mb-1">isValid</Label>
<Checkbox
checked={mem.outcome.isValid}
onCheckedChange={(checked) => {
const copy = [...entities];
if (
copy[selectedEntIndex].initialMemories[
memIdx
].outcome
) {
copy[selectedEntIndex].initialMemories[
memIdx
].outcome!.isValid = !!checked;
setEntities(copy);
}
}}
/>
</div>
<div className="col-span-2 space-y-1">
<Label className="text-[9px]">Reason</Label>
<Input
placeholder="Reason for valid/invalid status"
value={mem.outcome.reason}
onChange={(e) => {
const copy = [...entities];
if (
copy[selectedEntIndex].initialMemories[
memIdx
].outcome
) {
copy[selectedEntIndex].initialMemories[
memIdx
].outcome!.reason = e.target.value;
setEntities(copy);
}
}}
className="h-6 text-[10px]"
/>
</div>
</div>
)}
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
) : (
<div className="md:col-span-3 border border-border/20 bg-card p-6 shadow-[2px_2px_0_0_var(--border)] text-center text-xs text-muted-foreground">
No entities defined.
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,38 @@
"use client";
import { Button } from "@/components/ui/button";
interface JsonTabProps {
compiledScenario: Record<string, unknown>;
onCopySuccess: () => void;
}
export function JsonTab({ compiledScenario, onCopySuccess }: JsonTabProps) {
return (
<div className="flex-1 flex flex-col border border-border/20 bg-card p-6 shadow-[2px_2px_0_0_var(--border)] min-h-[400px]">
<div className="flex justify-between items-center border-b border-border/20 pb-3 mb-4">
<h2 className="text-body-lg text-primary font-bold">
Scenario JSON Code Output
</h2>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
navigator.clipboard.writeText(
JSON.stringify(compiledScenario, null, 2),
);
onCopySuccess();
}}
className="h-8 text-xs cursor-pointer"
>
Copy to Clipboard
</Button>
</div>
</div>
<pre className="flex-1 bg-black border border-border/10 p-4 rounded overflow-auto font-mono text-xs text-emerald-400 select-text leading-relaxed">
{JSON.stringify(compiledScenario, null, 2)}
</pre>
</div>
);
}

View File

@@ -0,0 +1,419 @@
"use client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox";
import { AttributeEditor } from "./AttributeEditor";
import { Plus, Trash2 } from "lucide-react";
import type { LocationData, ConnectionData, EntityData } from "./types";
import { getLocationDisplayName, getLocationDisplayNameById } from "./utils";
import { WorldMap } from "./WorldMap";
interface LocationsTabProps {
locations: LocationData[];
setLocations: (locs: LocationData[]) => void;
entities: EntityData[];
locationIds: string[];
entityIds: string[];
selectedLocIndex: number;
setSelectedLocIndex: (idx: number) => void;
generateUUID: () => string;
}
export function LocationsTab({
locations,
setLocations,
entities,
locationIds,
entityIds,
selectedLocIndex,
setSelectedLocIndex,
generateUUID,
}: LocationsTabProps) {
const addLocationConnection = (locIndex: number) => {
const copy = [...locations];
copy[locIndex].connections = [
...copy[locIndex].connections,
{
targetId:
locationIds.filter((id) => id !== locations[locIndex].id)[0] || "",
visionProp: 10,
soundProp: 10,
bidirectional: true,
},
];
setLocations(copy);
};
const updateLocationConnection = <K extends keyof ConnectionData>(
locIndex: number,
connIndex: number,
key: K,
val: ConnectionData[K],
) => {
const copy = [...locations];
copy[locIndex].connections[connIndex] = {
...copy[locIndex].connections[connIndex],
[key]: val,
};
setLocations(copy);
};
const removeLocationConnection = (locIndex: number, connIndex: number) => {
const copy = [...locations];
copy[locIndex].connections = copy[locIndex].connections.filter(
(_, i) => i !== connIndex,
);
setLocations(copy);
};
const selectedLoc = locations[selectedLocIndex];
return (
<div className="grid grid-cols-1 md:grid-cols-4 gap-8 items-start min-h-0 pb-12">
{/* Map Visualizer */}
{locations.length > 0 && (
<div className="md:col-span-4 w-full">
<WorldMap
locations={locations}
selectedLocId={selectedLoc?.id}
onSelectLocId={(id) => {
const idx = locations.findIndex((l) => l.id === id);
if (idx !== -1) setSelectedLocIndex(idx);
}}
/>
</div>
)}
{/* Left sidebar: Locations list */}
<div className="md:col-span-1 border border-border/20 bg-card shadow-[2px_2px_0_0_var(--border)] flex flex-col max-h-[500px]">
<div className="p-3 border-b border-border/25 flex justify-between items-center bg-secondary/15">
<strong className="text-xs font-mono uppercase tracking-wider text-muted-foreground">
Locations
</strong>
<Button
type="button"
size="sm"
onClick={() => {
const newId = generateUUID();
setLocations([
...locations,
{ id: newId, attributes: [], connections: [] },
]);
setSelectedLocIndex(locations.length);
}}
className="h-6 text-[10px] px-2 flex gap-1 cursor-pointer"
>
<Plus className="size-3" /> Add
</Button>
</div>
<div className="overflow-y-auto divide-y divide-border/10 flex-1">
{locations.map((loc, idx) => (
<div
key={idx}
onClick={() => setSelectedLocIndex(idx)}
className={`p-3 text-xs font-mono cursor-pointer flex justify-between items-center transition-all ${
selectedLocIndex === idx
? "bg-primary/10 text-primary font-bold border-l-4 border-primary"
: "hover:bg-secondary/40 text-foreground"
}`}
>
<span className="truncate">
{getLocationDisplayName(loc) || `(Empty ID)`}
</span>
{locations.length > 1 && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
const newLocs = locations.filter((_, i) => i !== idx);
setLocations(newLocs);
setSelectedLocIndex(Math.max(0, idx - 1));
}}
className="text-muted-foreground hover:text-destructive transition-colors pl-2 cursor-pointer"
>
<Trash2 className="size-3.5" />
</button>
)}
</div>
))}
</div>
</div>
{/* Right panel wrapper */}
<div className="md:col-span-3 space-y-6">
{selectedLoc ? (
<div className="border border-border/20 bg-card p-6 shadow-[2px_2px_0_0_var(--border)] grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Basic location fields */}
<div className="space-y-4">
<h2 className="text-body-lg text-primary font-bold border-b border-border/20 pb-2">
Location Configuration
</h2>
<div className="space-y-1.5">
<Label>Location ID</Label>
<Input
value={selectedLoc.id}
readOnly
className="font-mono text-xs bg-muted cursor-not-allowed text-muted-foreground"
/>
</div>
<div className="space-y-1.5">
<Label>Parent Location (Optional)</Label>
<Combobox
items={locationIds.filter((id) => id !== selectedLoc.id)}
value={selectedLoc.parentId || ""}
onValueChange={(value) => {
const copy = [...locations];
copy[selectedLocIndex].parentId = value || undefined;
setLocations(copy);
}}
>
<ComboboxInput
placeholder="Select a parent location..."
showClear
/>
<ComboboxContent>
<ComboboxEmpty>No locations found.</ComboboxEmpty>
<ComboboxList>
{(id: string) => (
<ComboboxItem key={id} value={id}>
{getLocationDisplayNameById(id, locations)}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
{/* Attributes for Location */}
<div className="pt-2">
<AttributeEditor
title="Location Attributes"
attributes={selectedLoc.attributes || []}
onChange={(newAttrs) => {
const copy = [...locations];
copy[selectedLocIndex].attributes = newAttrs;
setLocations(copy);
}}
onAdd={() => {
const copy = [...locations];
copy[selectedLocIndex].attributes = [
...copy[selectedLocIndex].attributes,
{
name: "",
value: "",
visibility: "PUBLIC",
allowedEntities: [],
},
];
setLocations(copy);
}}
entityIds={entityIds}
entities={entities}
/>
</div>
</div>
{/* Connections (spatial paths) */}
<div className="space-y-4 border-t lg:border-t-0 lg:border-l border-border/20 lg:pl-6 pt-4 lg:pt-0">
<div className="flex justify-between items-center border-b border-border/20 pb-2">
<h3 className="text-body-md text-foreground font-bold">
Connections / Portals
</h3>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => addLocationConnection(selectedLocIndex)}
className="h-7 text-xs flex gap-1 cursor-pointer"
>
<Plus className="size-3" /> Add Connection
</Button>
</div>
{!selectedLoc.connections ||
selectedLoc.connections.length === 0 ? (
<p className="text-xs text-muted-foreground italic">
No connections leading from this location.
</p>
) : (
<div className="space-y-4 overflow-y-auto max-h-[400px] pr-1">
{selectedLoc.connections.map((conn, connIdx) => (
<div
key={connIdx}
className="border border-border/20 bg-secondary/10 p-3 rounded space-y-3 relative group"
>
<button
type="button"
onClick={() =>
removeLocationConnection(selectedLocIndex, connIdx)
}
className="absolute top-2 right-2 text-muted-foreground hover:text-destructive cursor-pointer"
title="Delete connection"
>
<Trash2 className="size-3.5" />
</button>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 pr-6">
<div className="space-y-1">
<span className="text-[10px] font-semibold text-muted-foreground">
Target Location
</span>
<Combobox
items={locationIds.filter(
(id) => id !== selectedLoc.id,
)}
value={conn.targetId}
onValueChange={(value) =>
updateLocationConnection(
selectedLocIndex,
connIdx,
"targetId",
value ?? "",
)
}
>
<ComboboxInput
placeholder="Choose target..."
showClear
/>
<ComboboxContent>
<ComboboxEmpty>No locations found.</ComboboxEmpty>
<ComboboxList>
{(id: string) => (
<ComboboxItem key={id} value={id}>
{getLocationDisplayNameById(id, locations)}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
<div className="space-y-1">
<span className="text-[10px] font-semibold text-muted-foreground">
Portal Name
</span>
<Input
placeholder="e.g. wooden door"
value={conn.portalName || ""}
onChange={(e) =>
updateLocationConnection(
selectedLocIndex,
connIdx,
"portalName",
e.target.value,
)
}
className="h-7 text-xs"
/>
</div>
</div>
<div className="space-y-1">
<span className="text-[10px] font-semibold text-muted-foreground">
Portal State
</span>
<Input
placeholder="e.g. locked, closed, heavy iron gate"
value={conn.portalStateDescriptor || ""}
onChange={(e) =>
updateLocationConnection(
selectedLocIndex,
connIdx,
"portalStateDescriptor",
e.target.value,
)
}
className="h-7 text-xs"
/>
</div>
<div className="grid grid-cols-2 gap-4 text-xs">
<div className="space-y-1">
<span className="text-[10px] font-semibold text-muted-foreground block">
Vision Propagation ({conn.visionProp})
</span>
<input
type="range"
min="0"
max="10"
value={conn.visionProp}
onChange={(e) =>
updateLocationConnection(
selectedLocIndex,
connIdx,
"visionProp",
Number(e.target.value),
)
}
className="w-full h-1 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary"
/>
</div>
<div className="space-y-1">
<span className="text-[10px] font-semibold text-muted-foreground block">
Sound Propagation ({conn.soundProp})
</span>
<input
type="range"
min="0"
max="10"
value={conn.soundProp}
onChange={(e) =>
updateLocationConnection(
selectedLocIndex,
connIdx,
"soundProp",
Number(e.target.value),
)
}
className="w-full h-1 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary"
/>
</div>
</div>
<div className="flex items-center gap-2">
<Label className="text-[11px] text-muted-foreground flex items-center gap-1.5 cursor-pointer">
<Checkbox
checked={conn.bidirectional}
onCheckedChange={(checked) =>
updateLocationConnection(
selectedLocIndex,
connIdx,
"bidirectional",
!!checked,
)
}
/>
Bidirectional Connection (creates reverse path
automatically)
</Label>
</div>
</div>
))}
</div>
)}
</div>
</div>
) : (
<div className="border border-border/20 bg-card p-6 shadow-[2px_2px_0_0_var(--border)] text-center py-12 text-xs text-muted-foreground">
No location selected. Choose a location from the sidebar to edit, or
view the world layout below.
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,163 @@
"use client";
import { useMemo } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { AttributeEditor } from "./AttributeEditor";
import type { AttributeData, EntityData } from "./types";
interface MetadataTabProps {
scenarioId: string;
setScenarioId: (val: string) => void;
name: string;
setName: (val: string) => void;
description: string;
setDescription: (val: string) => void;
startTime: string;
setStartTime: (val: string) => void;
worldAttributes: AttributeData[];
setWorldAttributes: (attrs: AttributeData[]) => void;
entityIds: string[];
entities: EntityData[];
}
export function MetadataTab({
scenarioId,
name,
setName,
description,
setDescription,
startTime,
setStartTime,
worldAttributes,
setWorldAttributes,
entityIds,
entities,
}: MetadataTabProps) {
const addWorldAttribute = () => {
setWorldAttributes([
...worldAttributes,
{ name: "", value: "", visibility: "PUBLIC", allowedEntities: [] },
]);
};
// Parse initial state from ISO string (or fallback to now)
const parsedDate = useMemo(() => {
try {
const d = new Date(startTime);
if (isNaN(d.getTime())) return new Date();
return d;
} catch {
return new Date();
}
}, [startTime]);
const dateValue = useMemo(() => {
return parsedDate.toISOString().split("T")[0];
}, [parsedDate]);
const timeValue = useMemo(() => {
return parsedDate.toISOString().split("T")[1].slice(0, 8);
}, [parsedDate]);
const handleDateChange = (newDateStr: string) => {
if (!newDateStr) return;
const combined = `${newDateStr}T${timeValue}.000Z`;
setStartTime(combined);
};
const handleTimeChange = (newTimeStr: string) => {
if (!newTimeStr) return;
const formattedTime =
newTimeStr.split(":").length === 2 ? `${newTimeStr}:00` : newTimeStr;
const combined = `${dateValue}T${formattedTime}.000Z`;
setStartTime(combined);
};
return (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 pb-12">
{/* Basic Fields */}
<div className="lg:col-span-2 space-y-5 border border-border/20 bg-card p-6 shadow-[2px_2px_0_0_var(--border)]">
<h2 className="text-body-lg text-primary font-bold border-b border-border/20 pb-2">
Scenario Metadata
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label htmlFor="sc-id">Scenario Template ID</Label>
<Input
id="sc-id"
value={scenarioId}
readOnly
className="font-mono text-xs bg-muted cursor-not-allowed text-muted-foreground"
/>
<span className="text-[10px] text-muted-foreground">
Unique filename ID. Alphanumeric, hyphens and underscores only.
</span>
</div>
<div className="space-y-1.5">
<Label>Start Time</Label>
<div className="flex gap-2">
<div className="flex-1 space-y-1">
<Input
type="date"
value={dateValue}
onChange={(e) => handleDateChange(e.target.value)}
className="text-xs font-mono"
/>
</div>
<div className="flex-1 space-y-1">
<Input
type="time"
step="1"
value={timeValue}
onChange={(e) => handleTimeChange(e.target.value)}
className="text-xs font-mono"
/>
</div>
</div>
<span className="text-[10px] text-muted-foreground">
Global clock starting date and time (stored in ISO UTC).
</span>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="sc-name">Scenario Name</Label>
<Input
id="sc-name"
placeholder="e.g. The Quiet Tavern"
value={name}
onChange={(e) => setName(e.target.value)}
className="text-xs"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="sc-desc">Description</Label>
<Textarea
id="sc-desc"
placeholder="Describe the starting setup..."
value={description}
onChange={(e) => setDescription(e.target.value)}
className="text-xs h-24"
/>
</div>
</div>
{/* World level Attributes */}
<div className="border border-border/20 bg-card p-6 shadow-[2px_2px_0_0_var(--border)]">
<AttributeEditor
title="World Attributes"
attributes={worldAttributes}
onChange={setWorldAttributes}
onAdd={addWorldAttribute}
entityIds={entityIds}
entities={entities}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,528 @@
"use client";
import { useState, useMemo, useRef } from "react";
import { Button } from "@/components/ui/button";
import { ZoomIn, ZoomOut, RotateCcw } from "lucide-react";
import type { LocationData } from "./types";
interface BoxNode {
id: string;
name: string;
width: number;
height: number;
x: number;
y: number;
children: BoxNode[];
location: LocationData;
}
interface WorldMapProps {
locations: LocationData[];
selectedLocId?: string;
onSelectLocId: (id: string) => void;
}
export function WorldMap({
locations,
selectedLocId,
onSelectLocId,
}: WorldMapProps) {
// Zoom and Pan States for Visualizer
const [zoom, setZoom] = useState(1);
const [pan, setPan] = useState({ x: 0, y: 0 });
const [isPanning, setIsPanning] = useState(false);
const panStartRef = useRef({ x: 0, y: 0 });
const handleMouseDown = (e: React.MouseEvent<SVGSVGElement>) => {
if (e.button === 0) {
setIsPanning(true);
panStartRef.current = { x: e.clientX - pan.x, y: e.clientY - pan.y };
}
};
const handleMouseMove = (e: React.MouseEvent<SVGSVGElement>) => {
if (isPanning) {
setPan({
x: e.clientX - panStartRef.current.x,
y: e.clientY - panStartRef.current.y,
});
}
};
const handleMouseUp = () => {
setIsPanning(false);
};
// Build tree and layout computation
const { roots, nodeMap } = useMemo(() => {
const nodeMap = new Map<string, BoxNode>();
// 1. Initialize all nodes
locations.forEach((loc) => {
const nameAttr = loc.attributes?.find(
(a) => a.name.toLowerCase() === "name",
)?.value;
const name = nameAttr ? nameAttr : loc.id;
nodeMap.set(loc.id, {
id: loc.id,
name,
width: 0,
height: 0,
x: 0,
y: 0,
children: [],
location: loc,
});
});
const roots: BoxNode[] = [];
// 2. Link parents and children
locations.forEach((loc) => {
const node = nodeMap.get(loc.id)!;
if (loc.parentId && nodeMap.has(loc.parentId)) {
nodeMap.get(loc.parentId)!.children.push(node);
} else {
roots.push(node);
}
});
// 3. Measure recursively
const measureNode = (node: BoxNode) => {
const textWidth = node.name.length * 7.2 + 24;
if (node.children.length === 0) {
node.width = Math.max(140, textWidth);
node.height = 70;
return;
}
node.children.forEach((child) => measureNode(child));
const cols = Math.ceil(Math.sqrt(node.children.length));
const rows = Math.ceil(node.children.length / cols);
const gap = 20;
const padLeft = 20;
const padRight = 20;
const padTop = 45;
const padBottom = 20;
const colWidths = new Array(cols).fill(0);
const rowHeights = new Array(rows).fill(0);
node.children.forEach((child, idx) => {
const col = idx % cols;
const row = Math.floor(idx / cols);
colWidths[col] = Math.max(colWidths[col], child.width);
rowHeights[row] = Math.max(rowHeights[row], child.height);
});
const totalGridWidth =
colWidths.reduce((sum, w) => sum + w, 0) + (cols - 1) * gap;
const totalGridHeight =
rowHeights.reduce((sum, h) => sum + h, 0) + (rows - 1) * gap;
node.width = Math.max(
180,
totalGridWidth + padLeft + padRight,
textWidth,
);
node.height = Math.max(100, totalGridHeight + padTop + padBottom);
let yOffset = padTop;
for (let r = 0; r < rows; r++) {
let xOffset = padLeft;
for (let c = 0; c < cols; c++) {
const idx = r * cols + c;
if (idx >= node.children.length) break;
const child = node.children[idx];
const cellWidth = colWidths[c];
const cellHeight = rowHeights[r];
child.x = xOffset + (cellWidth - child.width) / 2;
child.y = yOffset + (cellHeight - child.height) / 2;
xOffset += cellWidth + gap;
}
yOffset += rowHeights[r] + gap;
}
};
roots.forEach((root) => measureNode(root));
// 4. Position roots and assign global coordinates
const assignGlobalCoordinates = (
node: BoxNode,
parentX: number,
parentY: number,
) => {
node.x += parentX;
node.y += parentY;
node.children.forEach((child) => {
assignGlobalCoordinates(child, node.x, node.y);
});
};
const cols = Math.ceil(Math.sqrt(roots.length));
const gap = 40;
const colWidths = new Array(cols).fill(0);
const rowHeights: number[] = [];
roots.forEach((root, idx) => {
const col = idx % cols;
const row = Math.floor(idx / cols);
colWidths[col] = Math.max(colWidths[col], root.width);
if (rowHeights[row] === undefined) rowHeights[row] = 0;
rowHeights[row] = Math.max(rowHeights[row], root.height);
});
let yOffset = 40;
let maxWidth = 0;
for (let r = 0; r < rowHeights.length; r++) {
let xOffset = 40;
for (let c = 0; c < cols; c++) {
const idx = r * cols + c;
if (idx >= roots.length) break;
const root = roots[idx];
root.x = xOffset;
root.y = yOffset;
assignGlobalCoordinates(root, 0, 0);
xOffset += colWidths[c] + gap;
}
yOffset += rowHeights[r] + gap;
maxWidth = Math.max(maxWidth, xOffset);
}
return { roots, nodeMap };
}, [locations]);
// Connection lines computation
const connectionLines = useMemo(() => {
const lines: {
id: string;
x1: number;
y1: number;
x2: number;
y2: number;
portalName?: string;
portalState?: string;
vision?: number;
sound?: number;
bidirectional?: boolean;
sourceId: string;
targetId: string;
}[] = [];
const seen = new Set<string>();
locations.forEach((loc) => {
const nodeA = nodeMap.get(loc.id);
if (!nodeA) return;
loc.connections?.forEach((conn, cIdx) => {
const nodeB = nodeMap.get(conn.targetId);
if (!nodeB) return;
const isParentChild =
loc.parentId === conn.targetId || nodeB.location.parentId === loc.id;
if (isParentChild) return;
const sortedIds = [loc.id, conn.targetId].sort();
const key = conn.bidirectional
? `bidi-${sortedIds[0]}-${sortedIds[1]}`
: `uni-${loc.id}-${conn.targetId}`;
if (seen.has(key)) return;
seen.add(key);
const x1 = nodeA.x + nodeA.width / 2;
const y1 = nodeA.y + nodeA.height / 2;
const x2 = nodeB.x + nodeB.width / 2;
const y2 = nodeB.y + nodeB.height / 2;
lines.push({
id: `${loc.id}-${conn.targetId}-${cIdx}`,
x1,
y1,
x2,
y2,
portalName: conn.portalName,
portalState: conn.portalStateDescriptor,
vision: conn.visionProp,
sound: conn.soundProp,
bidirectional: conn.bidirectional,
sourceId: loc.id,
targetId: conn.targetId,
});
});
});
return lines;
}, [locations, nodeMap]);
// Recursive render helper for boxes
const renderNode = (node: BoxNode) => {
const isSelected = selectedLocId === node.id;
// Find parent connection details
const parentLoc = locations.find((l) => l.id === node.location.parentId);
const childToParentConn = node.location.parentId
? node.location.connections?.find(
(c) => c.targetId === node.location.parentId,
)
: undefined;
const parentToChildConn =
node.location.parentId && parentLoc
? parentLoc.connections?.find((c) => c.targetId === node.id)
: undefined;
const hasParentConn = !!(childToParentConn || parentToChildConn);
const portalName =
childToParentConn?.portalName || parentToChildConn?.portalName;
return (
<g key={node.id}>
{/* Box */}
<rect
x={node.x}
y={node.y}
width={node.width}
height={node.height}
rx={8}
onClick={(e) => {
e.stopPropagation();
onSelectLocId(node.id);
}}
className={`transition-all cursor-pointer ${
isSelected
? "fill-primary/5 stroke-primary stroke-2"
: node.children.length > 0
? "fill-secondary/5 stroke-border/40 hover:stroke-border/80"
: "fill-secondary/20 stroke-border/40 hover:stroke-foreground/40 hover:fill-secondary/30"
}`}
style={{ strokeWidth: isSelected ? 2 : 1 }}
strokeDasharray={
hasParentConn
? "4, 4"
: node.children.length > 0
? "3, 3"
: undefined
}
/>
{/* Label */}
<text
x={node.x + 12}
y={node.y + 24}
className={`text-xs font-mono select-none font-semibold ${
isSelected ? "fill-primary font-bold" : "fill-muted-foreground"
}`}
>
{node.name}
</text>
{/* Portal Name on Boundary of Child */}
{hasParentConn && portalName && (
<g transform={`translate(${node.x + node.width / 2}, ${node.y})`}>
<rect
x={-((portalName.length * 5) / 2) - 4}
y={-6}
width={portalName.length * 5 + 8}
height={12}
rx={3}
className="fill-zinc-900 stroke stroke-border/40"
style={{ strokeWidth: 0.5 }}
/>
<text
textAnchor="middle"
y={3}
className="text-[8px] font-mono fill-primary font-semibold select-none"
>
{portalName}
</text>
</g>
)}
{/* Render children inside */}
{node.children.map((child) => renderNode(child))}
</g>
);
};
return (
<>
{/* Header */}
<div className="flex justify-between items-center pb-2">
{/* Controls */}
<div className="flex gap-1 bg-secondary/15 p-1 rounded border border-border/10">
<Button
type="button"
size="icon"
variant="ghost"
onClick={() => setZoom((z) => Math.min(4, z + 0.1))}
className="size-7 cursor-pointer hover:bg-secondary/40 text-muted-foreground hover:text-foreground"
title="Zoom In"
>
<ZoomIn className="size-4" />
</Button>
<Button
type="button"
size="icon"
variant="ghost"
onClick={() => setZoom((z) => Math.max(0.3, z - 0.1))}
className="size-7 cursor-pointer hover:bg-secondary/40 text-muted-foreground hover:text-foreground"
title="Zoom Out"
>
<ZoomOut className="size-4" />
</Button>
<Button
type="button"
size="icon"
variant="ghost"
onClick={() => {
setZoom(1);
setPan({ x: 0, y: 0 });
}}
className="size-7 cursor-pointer hover:bg-secondary/40 text-muted-foreground hover:text-foreground"
title="Reset view"
>
<RotateCcw className="size-4" />
</Button>
</div>
</div>
{/* Canvas container */}
<div className="relative border border-border/35 bg-zinc-950/95 rounded-lg overflow-hidden h-[450px]">
<svg
width="100%"
height="100%"
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
className={`w-full h-full select-none ${
isPanning ? "cursor-grabbing" : "cursor-grab"
}`}
>
<defs>
<pattern
id="grid-pattern"
width="40"
height="40"
patternUnits="userSpaceOnUse"
>
<path
d="M 40 0 L 0 0 0 40"
fill="none"
stroke="currentColor"
className="text-muted-foreground/10"
strokeWidth="1"
/>
</pattern>
</defs>
{/* Grid Background */}
<rect width="100%" height="100%" fill="url(#grid-pattern)" />
{/* Draggable/scalable group */}
<g transform={`translate(${pan.x}, ${pan.y}) scale(${zoom})`}>
{/* Draw Connection Lines first so they are behind boxes */}
{connectionLines.map((line) => {
const isSourceSelected = selectedLocId === line.sourceId;
const isTargetSelected = selectedLocId === line.targetId;
const isHighlighted = isSourceSelected || isTargetSelected;
const mx = (line.x1 + line.x2) / 2;
const my = (line.y1 + line.y2) / 2;
const angle =
Math.atan2(line.y2 - line.y1, line.x2 - line.x1) *
(180 / Math.PI);
return (
<g key={line.id} className="transition-all">
{/* Glow path for highlighted connections */}
{isHighlighted && (
<line
x1={line.x1}
y1={line.y1}
x2={line.x2}
y2={line.y2}
className="stroke-primary/30 stroke-[4px] blur-sm animate-pulse"
/>
)}
{/* Main Connection Line */}
<line
x1={line.x1}
y1={line.y1}
x2={line.x2}
y2={line.y2}
className={`transition-all ${
isHighlighted
? "stroke-primary stroke-2"
: "stroke-border/40 stroke-1"
}`}
style={{
strokeDasharray: line.bidirectional ? undefined : "4, 4",
}}
/>
{/* Midpoint Direction Arrow for Uni-directional connections */}
{!line.bidirectional && (
<g transform={`translate(${mx}, ${my}) rotate(${angle})`}>
<path
d="M -4 -3.5 L 4 0 L -4 3.5 Z"
className={
isHighlighted
? "fill-primary"
: "fill-muted-foreground/60"
}
/>
</g>
)}
{/* Portal Name Label bubble */}
{line.portalName && (
<g
transform={`translate(${mx}, ${my - 12})`}
className="cursor-default"
>
<rect
x={-((line.portalName.length * 6) / 2) - 4}
y={-8}
width={line.portalName.length * 6 + 8}
height={16}
rx={4}
className="fill-background/90 stroke stroke-border/30"
style={{ strokeWidth: 0.5 }}
/>
<text
textAnchor="middle"
y={3}
className="text-[9px] font-mono fill-muted-foreground select-none"
>
{line.portalName}
</text>
</g>
)}
</g>
);
})}
{/* Draw Box Nodes recursively */}
{roots.map((root) => renderNode(root))}
</g>
</svg>
{/* Map Hint Overlay */}
<div className="absolute bottom-2 right-2 px-2 py-1 rounded bg-black/60 border border-border/20 text-[9.5px] text-muted-foreground font-mono pointer-events-none">
Drag to pan Scroll or buttons to zoom Click boxes to select
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,50 @@
export interface AttributeData {
name: string;
value: string;
visibility: "PUBLIC" | "PRIVATE";
allowedEntities: string[];
}
export interface ConnectionData {
targetId: string;
portalName?: string;
portalStateDescriptor?: string;
visionProp: number;
soundProp: number;
bidirectional: boolean;
}
export interface LocationData {
id: string;
parentId?: string;
attributes: AttributeData[];
connections: ConnectionData[];
}
export interface MemoryData {
id: string;
timestamp: string;
locationId: string | null;
intent: {
type: "dialogue" | "action" | "monologue";
originalText: string;
description: string;
selfDescription?: string;
actorId: string;
targetIds: string[];
modifiers?: string[];
};
outcome?: {
isValid: boolean;
reason: string;
};
}
export interface EntityData {
id: string;
locationId?: string;
attributes: AttributeData[];
aliases: Record<string, string>;
initialMemories: MemoryData[];
isAgent?: boolean;
}

View File

@@ -0,0 +1,45 @@
import type { EntityData, LocationData } from "./types";
export function getEntityDisplayName(
entity: EntityData | undefined,
fallbackId: string = "",
): string {
if (!entity) return fallbackId;
const nameAttr = entity.attributes?.find(
(a) => a.name.toLowerCase() === "name",
);
if (nameAttr?.value) {
return `${nameAttr.value} (${entity.id})`;
}
return entity.id;
}
export function getEntityDisplayNameById(
id: string,
entities: EntityData[],
): string {
const entity = entities.find((e) => e.id === id);
return getEntityDisplayName(entity, id);
}
export function getLocationDisplayName(
location: LocationData | undefined,
fallbackId: string = "",
): string {
if (!location) return fallbackId;
const nameAttr = location.attributes?.find(
(a) => a.name.toLowerCase() === "name",
);
if (nameAttr?.value) {
return `${nameAttr.value} (${location.id})`;
}
return location.id;
}
export function getLocationDisplayNameById(
id: string,
locations: LocationData[],
): string {
const location = locations.find((l) => l.id === id);
return getLocationDisplayName(location, id);
}

View File

@@ -0,0 +1,302 @@
"use client";
import * as React from "react";
import { Combobox as ComboboxPrimitive } from "@base-ui/react";
import { CheckIcon, ChevronDownIcon, XIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@/components/ui/input-group";
const Combobox = ComboboxPrimitive.Root;
function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />;
}
function ComboboxTrigger({
className,
children,
...props
}: ComboboxPrimitive.Trigger.Props) {
return (
<ComboboxPrimitive.Trigger
data-slot="combobox-trigger"
className={cn("[&_svg:not([class*='size-'])]:size-4", className)}
{...props}
>
{children}
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
</ComboboxPrimitive.Trigger>
);
}
function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
return (
<ComboboxPrimitive.Clear
data-slot="combobox-clear"
render={<InputGroupButton variant="ghost" size="icon-xs" />}
className={cn(className)}
{...props}
>
<XIcon className="pointer-events-none" />
</ComboboxPrimitive.Clear>
);
}
function ComboboxInput({
className,
children,
disabled = false,
showTrigger = true,
showClear = false,
...props
}: ComboboxPrimitive.Input.Props & {
showTrigger?: boolean;
showClear?: boolean;
}) {
return (
<InputGroup className={cn("w-auto", className)}>
<ComboboxPrimitive.Input
render={<InputGroupInput disabled={disabled} />}
{...props}
/>
<InputGroupAddon align="inline-end">
{showTrigger && (
<ComboboxTrigger
render={
<InputGroupButton
size="icon-xs"
variant="ghost"
data-slot="input-group-button"
className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent"
disabled={disabled}
/>
}
/>
)}
{showClear && <ComboboxClear disabled={disabled} />}
</InputGroupAddon>
{children}
</InputGroup>
);
}
function ComboboxContent({
className,
side = "bottom",
sideOffset = 6,
align = "start",
alignOffset = 0,
anchor,
...props
}: ComboboxPrimitive.Popup.Props &
Pick<
ComboboxPrimitive.Positioner.Props,
"side" | "align" | "sideOffset" | "alignOffset" | "anchor"
>) {
return (
<ComboboxPrimitive.Portal>
<ComboboxPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
anchor={anchor}
className="isolate z-50"
>
<ComboboxPrimitive.Popup
data-slot="combobox-content"
data-chips={!!anchor}
className={cn(
"group/combobox-content relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden rounded border-2 bg-popover text-popover-foreground shadow-md duration-100 data-[chips=true]:min-w-(--anchor-width) data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:border-input/30 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:shadow-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className,
)}
{...props}
/>
</ComboboxPrimitive.Positioner>
</ComboboxPrimitive.Portal>
);
}
function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
return (
<ComboboxPrimitive.List
data-slot="combobox-list"
className={cn(
"no-scrollbar max-h-[min(calc(--spacing(72)---spacing(9)),calc(var(--available-height)---spacing(9)))] scroll-py-1 overflow-y-auto overscroll-contain p-1 data-empty:p-0",
className,
)}
{...props}
/>
);
}
function ComboboxItem({
className,
children,
...props
}: ComboboxPrimitive.Item.Props) {
return (
<ComboboxPrimitive.Item
data-slot="combobox-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1 pr-8 pl-1.5 text-sm outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground not-data-[variant=destructive]:data-highlighted:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<ComboboxPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</ComboboxPrimitive.ItemIndicator>
</ComboboxPrimitive.Item>
);
}
function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
return (
<ComboboxPrimitive.Group
data-slot="combobox-group"
className={cn(className)}
{...props}
/>
);
}
function ComboboxLabel({
className,
...props
}: ComboboxPrimitive.GroupLabel.Props) {
return (
<ComboboxPrimitive.GroupLabel
data-slot="combobox-label"
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
{...props}
/>
);
}
function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) {
return (
<ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />
);
}
function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
return (
<ComboboxPrimitive.Empty
data-slot="combobox-empty"
className={cn(
"hidden w-full justify-center py-2 text-center text-sm text-muted-foreground group-data-empty/combobox-content:flex",
className,
)}
{...props}
/>
);
}
function ComboboxSeparator({
className,
...props
}: ComboboxPrimitive.Separator.Props) {
return (
<ComboboxPrimitive.Separator
data-slot="combobox-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
);
}
function ComboboxChips({
className,
...props
}: React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips> &
ComboboxPrimitive.Chips.Props) {
return (
<ComboboxPrimitive.Chips
data-slot="combobox-chips"
className={cn(
"flex min-h-8 flex-wrap items-center gap-1 rounded border-2 bg-input bg-clip-padding px-2.5 py-1 text-sm shadow-sm transition-colors focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-primary has-aria-invalid:border-destructive has-data-[slot=combobox-chip]:px-1",
className,
)}
{...props}
/>
);
}
function ComboboxChip({
className,
children,
showRemove = true,
...props
}: ComboboxPrimitive.Chip.Props & {
showRemove?: boolean;
}) {
return (
<ComboboxPrimitive.Chip
data-slot="combobox-chip"
className={cn(
"flex h-[calc(--spacing(5.25))] w-fit items-center justify-center gap-1 rounded-sm border-2 bg-muted px-1.5 text-xs font-medium whitespace-nowrap text-foreground has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50 has-data-[slot=combobox-chip-remove]:pr-0",
className,
)}
{...props}
>
{children}
{showRemove && (
<ComboboxPrimitive.ChipRemove
render={<InputGroupButton variant="ghost" size="icon-xs" />}
className="-ml-1 opacity-50 hover:opacity-100"
data-slot="combobox-chip-remove"
>
<XIcon className="pointer-events-none" />
</ComboboxPrimitive.ChipRemove>
)}
</ComboboxPrimitive.Chip>
);
}
function ComboboxChipsInput({
className,
...props
}: ComboboxPrimitive.Input.Props) {
return (
<ComboboxPrimitive.Input
data-slot="combobox-chip-input"
className={cn("min-w-16 flex-1 outline-none", className)}
{...props}
/>
);
}
function useComboboxAnchor() {
return React.useRef<HTMLDivElement | null>(null);
}
export {
Combobox,
ComboboxInput,
ComboboxContent,
ComboboxList,
ComboboxItem,
ComboboxGroup,
ComboboxLabel,
ComboboxCollection,
ComboboxEmpty,
ComboboxSeparator,
ComboboxChips,
ComboboxChip,
ComboboxChipsInput,
ComboboxTrigger,
ComboboxValue,
useComboboxAnchor,
};

View File

@@ -0,0 +1,200 @@
"use client";
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cn } from "@/lib/utils";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
className,
)}
{...props}
/>
));
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className,
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className,
)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
);
};
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};

View File

@@ -0,0 +1,170 @@
"use client";
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
"group/input-group border-input dark:bg-input/30 shadow-xs relative flex w-full items-center rounded-md border outline-none transition-[color,box-shadow]",
"h-9 has-[>textarea]:h-auto",
// Variants based on alignment.
"has-[>[data-align=inline-start]]:[&>input]:pl-2",
"has-[>[data-align=inline-end]]:[&>input]:pr-2",
"has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3",
"has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
// Focus state.
"has-[[data-slot=input-group-control]:focus-visible]:ring-ring has-[[data-slot=input-group-control]:focus-visible]:ring-1",
// Error state.
"has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
className,
)}
{...props}
/>
);
}
const inputGroupAddonVariants = cva(
"text-muted-foreground flex h-auto cursor-text select-none items-center justify-center gap-2 py-1.5 text-sm font-medium group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
{
variants: {
align: {
"inline-start":
"order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]",
"inline-end":
"order-last pr-3 has-[>button]:mr-[-0.4rem] has-[>kbd]:mr-[-0.35rem]",
"block-start":
"[.border-b]:pb-3 order-first w-full justify-start px-3 pt-3 group-has-[>input]/input-group:pt-2.5",
"block-end":
"[.border-t]:pt-3 order-last w-full justify-start px-3 pb-3 group-has-[>input]/input-group:pb-2.5",
},
},
defaultVariants: {
align: "inline-start",
},
},
);
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return;
}
e.currentTarget.parentElement?.querySelector("input")?.focus();
}}
{...props}
/>
);
}
const inputGroupButtonVariants = cva(
"flex items-center gap-2 text-sm shadow-none",
{
variants: {
size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-2 has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5",
sm: "h-8 gap-1.5 rounded-md px-2.5 has-[>svg]:px-2.5",
"icon-xs":
"size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
},
);
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size"> &
VariantProps<typeof inputGroupButtonVariants>) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
);
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"text-muted-foreground flex items-center gap-2 text-sm [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none",
className,
)}
{...props}
/>
);
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent",
className,
)}
{...props}
/>
);
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent",
className,
)}
{...props}
/>
);
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
};

View File

@@ -0,0 +1,292 @@
"use client";
import * as React from "react";
import { Menu as MenuPrimitive } from "@base-ui/react/menu";
import { Menubar as MenubarPrimitive } from "@base-ui/react/menubar";
import { CheckIcon, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
function Menubar({ className, ...props }: MenubarPrimitive.Props) {
return (
<MenubarPrimitive
data-slot="menubar"
className={cn(
"flex h-8 items-center gap-0.5 rounded border-2 bg-background p-[3px] shadow-md",
className,
)}
{...props}
/>
);
}
function MenubarMenu({ ...props }: MenuPrimitive.Root.Props) {
return <MenuPrimitive.Root data-slot="menubar-menu" {...props} />;
}
function MenubarGroup({ ...props }: MenuPrimitive.Group.Props) {
return <MenuPrimitive.Group data-slot="menubar-group" {...props} />;
}
function MenubarPortal({ ...props }: MenuPrimitive.Portal.Props) {
return <MenuPrimitive.Portal data-slot="menubar-portal" {...props} />;
}
function MenubarTrigger({ className, ...props }: MenuPrimitive.Trigger.Props) {
return (
<MenuPrimitive.Trigger
data-slot="menubar-trigger"
className={cn(
"flex items-center rounded-sm px-1.5 py-[2px] text-sm font-medium outline-none select-none hover:bg-primary/15 hover:text-primary aria-expanded:bg-primary/15 aria-expanded:text-primary cursor-pointer",
className,
)}
{...props}
/>
);
}
function MenubarContent({
className,
align = "start",
alignOffset = -4,
sideOffset = 8,
...props
}: MenuPrimitive.Popup.Props & {
align?: MenuPrimitive.Positioner.Props["align"];
alignOffset?: MenuPrimitive.Positioner.Props["alignOffset"];
sideOffset?: MenuPrimitive.Positioner.Props["sideOffset"];
}) {
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
>
<MenuPrimitive.Popup
data-slot="menubar-content"
className={cn(
"min-w-36 rounded border-2 bg-popover p-1 text-popover-foreground shadow-md duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95",
className,
)}
{...props}
/>
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
);
}
function MenubarItem({
className,
inset,
variant = "default",
...props
}: MenuPrimitive.Item.Props & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<MenuPrimitive.Item
data-slot="menubar-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/menubar-item gap-1.5 rounded-sm px-2 py-1.5 text-sm focus:bg-primary/15 focus:text-primary data-highlighted:bg-primary/15 data-highlighted:text-primary not-data-[variant=destructive]:focus:**:text-primary not-data-[variant=destructive]:data-highlighted:**:text-primary data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:data-highlighted:bg-destructive/10 data-[variant=destructive]:data-highlighted:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 dark:data-[variant=destructive]:data-highlighted:bg-destructive/20 data-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive!",
className,
)}
{...props}
/>
);
}
function MenubarCheckboxItem({
className,
children,
checked,
inset,
...props
}: MenuPrimitive.CheckboxItem.Props & {
inset?: boolean;
}) {
return (
<MenuPrimitive.CheckboxItem
data-slot="menubar-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-sm py-1.5 pr-2 pl-7 text-sm outline-none select-none focus:bg-primary/15 focus:text-primary data-highlighted:bg-primary/15 data-highlighted:text-primary focus:**:text-primary data-highlighted:**:text-primary data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-1.5 flex size-4 items-center justify-center [&_svg:not([class*='size-'])]:size-4">
<MenuPrimitive.CheckboxItemIndicator>
<CheckIcon />
</MenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</MenuPrimitive.CheckboxItem>
);
}
function MenubarRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
return (
<MenuPrimitive.RadioGroup data-slot="menubar-radio-group" {...props} />
);
}
function MenubarRadioItem({
className,
children,
inset,
...props
}: MenuPrimitive.RadioItem.Props & {
inset?: boolean;
}) {
return (
<MenuPrimitive.RadioItem
data-slot="menubar-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-sm py-1.5 pr-2 pl-7 text-sm outline-none select-none focus:bg-primary/15 focus:text-primary data-highlighted:bg-primary/15 data-highlighted:text-primary focus:**:text-primary data-highlighted:**:text-primary data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span className="pointer-events-none absolute left-1.5 flex size-4 items-center justify-center [&_svg:not([class*='size-'])]:size-4">
<MenuPrimitive.RadioItemIndicator>
<CheckIcon />
</MenuPrimitive.RadioItemIndicator>
</span>
{children}
</MenuPrimitive.RadioItem>
);
}
function MenubarLabel({
className,
inset,
...props
}: MenuPrimitive.GroupLabel.Props & {
inset?: boolean;
}) {
return (
<MenuPrimitive.GroupLabel
data-slot="menubar-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-sm font-medium data-inset:pl-7",
className,
)}
{...props}
/>
);
}
function MenubarSeparator({
className,
...props
}: MenuPrimitive.Separator.Props) {
return (
<MenuPrimitive.Separator
data-slot="menubar-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
);
}
function MenubarShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="menubar-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/menubar-item:text-primary group-data-highlighted/menubar-item:text-primary",
className,
)}
{...props}
/>
);
}
// Submenu components using SubmenuRoot and SubmenuTrigger
function MenubarSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
return <MenuPrimitive.SubmenuRoot data-slot="menubar-sub" {...props} />;
}
function MenubarSubTrigger({
className,
inset,
children,
...props
}: MenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean;
}) {
return (
<MenuPrimitive.SubmenuTrigger
data-slot="menubar-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default select-none items-center gap-1.5 rounded-sm px-2 py-1.5 text-sm focus:bg-primary/15 focus:text-primary data-highlighted:bg-primary/15 data-highlighted:text-primary data-inset:pl-7 data-open:bg-primary/15 data-open:text-primary data-highlighted:text-primary [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto size-4" />
</MenuPrimitive.SubmenuTrigger>
);
}
function MenubarSubContent({
className,
align = "start",
alignOffset = -4,
sideOffset = 8,
...props
}: MenuPrimitive.Popup.Props & {
align?: MenuPrimitive.Positioner.Props["align"];
alignOffset?: MenuPrimitive.Positioner.Props["alignOffset"];
sideOffset?: MenuPrimitive.Positioner.Props["sideOffset"];
}) {
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
>
<MenuPrimitive.Popup
data-slot="menubar-sub-content"
className={cn(
"min-w-32 rounded border-2 bg-popover p-1 text-popover-foreground shadow-md duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className,
)}
{...props}
/>
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
);
}
export {
Menubar,
MenubarPortal,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarGroup,
MenubarSeparator,
MenubarLabel,
MenubarItem,
MenubarShortcut,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarSub,
MenubarSubTrigger,
MenubarSubContent,
};

3
pnpm-lock.yaml generated
View File

@@ -342,6 +342,9 @@ importers:
"@radix-ui/react-dialog":
specifier: ^1.1.19
version: 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
"@radix-ui/react-dropdown-menu":
specifier: ^2.1.20
version: 2.1.20(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
"@radix-ui/react-separator":
specifier: ^1.1.11
version: 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)