minor(builder): misc ui fixes

This commit is contained in:
2026-07-15 22:41:28 +05:30
parent d2506ec542
commit 44f3e29e62
6 changed files with 936 additions and 398 deletions

View File

@@ -4,6 +4,14 @@ 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";
@@ -104,9 +112,9 @@ export function EntitiesTab({
{/* Right panel: Edit selected entity details */}
{selectedEnt ? (
<div className="md:col-span-3 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">
{/* Column 1: ID, Location, Attributes */}
<div className="space-y-4">
<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>
@@ -122,23 +130,27 @@ export function EntitiesTab({
<div className="space-y-1.5">
<Label>Current Location</Label>
<select
className="bg-card border border-border/30 px-3 py-1.5 text-xs outline-none w-full rounded font-mono"
<Combobox
items={locationIds}
value={selectedEnt.locationId || ""}
onChange={(e) => {
onValueChange={(value) => {
const copy = [...entities];
copy[selectedEntIndex].locationId =
e.target.value || undefined;
copy[selectedEntIndex].locationId = value || undefined;
setEntities(copy);
}}
>
<option value="">-- No Location (floating) --</option>
{locationIds.map((id) => (
<option key={id} value={id}>
{getLocationDisplayNameById(id, locations)}
</option>
))}
</select>
<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">
@@ -191,360 +203,354 @@ export function EntitiesTab({
</div>
</div>
{/* Column 2: Aliases and Initial Memories */}
<div className="space-y-6 border-t lg:border-t-0 lg:border-l border-border/20 lg:pl-6 pt-4 lg:pt-0">
{/* Aliases Section */}
<div className="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>
)}
{/* 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>
{/* Initial Memories Section */}
<div className="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) => (
{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={mem.id}
className="border border-border/20 bg-secondary/5 p-3 rounded space-y-3 relative"
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];
copy[selectedEntIndex].initialMemories =
selectedEnt.initialMemories.filter(
(_, i) => i !== memIdx,
);
const updated = { ...selectedEnt.aliases };
delete updated[targetId];
copy[selectedEntIndex].aliases = updated;
setEntities(copy);
}}
className="absolute top-2 right-2 text-muted-foreground hover:text-destructive cursor-pointer"
className="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>
{/* 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>
) : (

View File

@@ -30,7 +30,7 @@ export function JsonTab({ compiledScenario, onCopySuccess }: JsonTabProps) {
</Button>
</div>
</div>
<pre className="flex-1 bg-black/40 border border-border/10 p-4 rounded overflow-auto font-mono text-xs text-emerald-400 select-text leading-relaxed">
<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

@@ -4,6 +4,14 @@ 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";
@@ -162,25 +170,30 @@ export function LocationsTab({
<div className="space-y-1.5">
<Label>Parent Location (Optional)</Label>
<select
className="bg-card border border-border/30 px-3 py-1.5 text-xs outline-none w-full rounded"
<Combobox
items={locationIds.filter((id) => id !== selectedLoc.id)}
value={selectedLoc.parentId || ""}
onChange={(e) => {
onValueChange={(value) => {
const copy = [...locations];
copy[selectedLocIndex].parentId =
e.target.value || undefined;
copy[selectedLocIndex].parentId = value || undefined;
setLocations(copy);
}}
>
<option value="">-- No parent --</option>
{locationIds
.filter((id) => id !== selectedLoc.id)
.map((id) => (
<option key={id} value={id}>
{getLocationDisplayNameById(id, locations)}
</option>
))}
</select>
<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 */}
@@ -257,27 +270,35 @@ export function LocationsTab({
<span className="text-[10px] font-semibold text-muted-foreground">
Target Location
</span>
<select
className="bg-card border border-border/30 px-2 py-1 text-xs outline-none w-full rounded"
<Combobox
items={locationIds.filter(
(id) => id !== selectedLoc.id,
)}
value={conn.targetId}
onChange={(e) =>
onValueChange={(value) =>
updateLocationConnection(
selectedLocIndex,
connIdx,
"targetId",
e.target.value,
value ?? "",
)
}
>
<option value="">-- Choose target --</option>
{locationIds
.filter((id) => id !== selectedLoc.id)
.map((id) => (
<option key={id} value={id}>
{getLocationDisplayNameById(id, locations)}
</option>
))}
</select>
<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">

View File

@@ -1,5 +1,6 @@
"use client";
import { useMemo } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
@@ -42,6 +43,39 @@ export function MetadataTab({
]);
};
// 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 */}
@@ -55,14 +89,9 @@ export function MetadataTab({
<Label htmlFor="sc-id">Scenario Template ID</Label>
<Input
id="sc-id"
placeholder="e.g. my-custom-scenario"
value={scenarioId}
onChange={(e) =>
setScenarioId(
e.target.value.toLowerCase().replace(/[^a-z0-9-_]/g, ""),
)
}
className="font-mono text-xs"
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.
@@ -70,16 +99,28 @@ export function MetadataTab({
</div>
<div className="space-y-1.5">
<Label htmlFor="sc-start">Start Time (ISO Date)</Label>
<Input
id="sc-start"
placeholder="e.g. 2026-07-06T12:00:00.000Z"
value={startTime}
onChange={(e) => setStartTime(e.target.value)}
className="font-mono text-xs"
/>
<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 timestamp.
Global clock starting date and time (stored in ISO UTC).
</span>
</div>
</div>

View File

@@ -0,0 +1,300 @@
"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 { Button } from "@/components/ui/button";
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 && (
<InputGroupButton
size="icon-xs"
variant="ghost"
render={<ComboboxTrigger />}
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={<Button 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,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,
};