refactor(gui): Revamp the home page and refactor playview out

This commit is contained in:
2026-07-12 09:17:09 +05:30
parent c86c0295ef
commit 35518ea096
9 changed files with 578 additions and 407 deletions

BIN
apps/gui/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -0,0 +1,14 @@
"use client";
export default function BuilderPage() {
return (
<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>
</div>
);
}

View File

@@ -64,7 +64,7 @@
:root {
--radius: 0;
--background: #fff8f5;
--background: #f4e9dc;
--foreground: #201a16;
--card: #fcf3e8;
--card-foreground: #201a16;
@@ -96,7 +96,7 @@
}
.dark {
--background: #201a16;
--background: #181310;
--foreground: #fff8f5;
--card: #362f2a;
--card-foreground: #fff8f5;

View File

@@ -34,7 +34,6 @@ const spaceMono = Space_Mono({
const links = [
{ href: "/", label: "Home" },
{ href: "/play", label: "Play" },
{ href: "/config", label: "Config" },
];
@@ -45,21 +44,26 @@ export default function RootLayout({ children }: { children: ReactNode }) {
<html lang="en">
<body className={`${jersey25.variable} ${jetbrainsMono.variable} ${spaceMono.variable} min-h-dvh bg-background text-foreground font-sans`}>
<nav className="border-b border-dotted border-border/20 bg-secondary/30">
<div className="mx-auto max-w-[800px] px-10 py-3 flex items-center justify-center gap-8">
<Link href="/" className="font-head text-headline-sm text-primary no-underline tracking-wide hover:opacity-85">
Omnia
</Link>
<div className="mx-auto max-w-[800px] px-10 py-3 flex items-center justify-center">
<NavigationMenu viewport={false}>
<NavigationMenuList>
{links.map((link) => (
<NavigationMenuItem key={link.href}>
<NavigationMenuLink asChild active={pathname === link.href}>
<Link href={link.href} className="text-foreground no-underline font-medium text-sm">
{link.label}
</Link>
</NavigationMenuLink>
</NavigationMenuItem>
))}
{links.map((link) => {
const isActive = pathname === link.href || (link.href !== "/" && pathname?.startsWith(link.href));
return (
<NavigationMenuItem key={link.href}>
<NavigationMenuLink asChild active={isActive}>
<Link
href={link.href}
className={`text-foreground no-underline font-medium text-sm p-2 transition-all outline-none ${
isActive ? "bg-primary/15 text-primary" : "hover:bg-secondary hover:text-foreground"
}`}
>
{link.label}
</Link>
</NavigationMenuLink>
</NavigationMenuItem>
);
})}
</NavigationMenuList>
</NavigationMenu>
</div>

View File

@@ -1,35 +1,361 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { Card, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { useRouter } from "next/navigation";
import {
startSimulation,
listSavedSimulations,
getConfigStatus,
getScenarioEntities,
deleteSimulation,
listProviderInstances,
} from "@/app/play/actions";
import type { SimSnapshot } from "@/lib/simulation-types";
import type { ModelProviderInstance } from "@omnia/llm";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { Skeleton } from "@/components/ui/skeleton";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
export default function Home() {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [loadingData, setLoadingData] = useState(true);
const [error, setError] = useState("");
const [savedSessions, setSavedSessions] = useState<SimSnapshot[]>([]);
const [scenarios, setScenarios] = useState<{ path: string; name: string }[]>([]);
const [providerInstances, setProviderInstances] = useState<ModelProviderInstance[]>([]);
// Modal State
const [scenarioForModal, setScenarioForModal] = useState<{ path: string; name: string } | null>(null);
const [modalEntities, setModalEntities] = useState<{ id: string; name: string }[]>([]);
const [loadingEntities, setLoadingEntities] = useState(false);
const [selectedEntityForModal, setSelectedEntityForModal] = useState<string>("");
const loadSavedSessions = useCallback(async () => {
try {
const res = await listSavedSimulations();
if (res.ok) {
setSavedSessions(res.sessions);
}
} catch {
// ignore
}
}, []);
useEffect(() => {
let active = true;
async function loadAll() {
setLoadingData(true);
try {
const sessionsRes = await listSavedSimulations();
if (active && sessionsRes.ok) {
setSavedSessions(sessionsRes.sessions);
}
} catch {}
try {
const configStatus = await getConfigStatus();
if (active) {
setScenarios(configStatus.availableScenarios);
}
} catch {}
try {
const providersList = await listProviderInstances();
if (active) {
setProviderInstances(providersList);
}
} catch {}
if (active) {
setLoadingData(false);
}
}
loadAll();
return () => {
active = false;
};
}, []);
const handleResume = (id: string) => {
router.push(`/play?simId=${id}`);
};
const handleDelete = async (id: string, e: React.MouseEvent) => {
e.stopPropagation();
if (!confirm("Are you sure you want to delete this simulation session?")) return;
setLoading(true);
try {
const res = await deleteSimulation(id);
if (!res.ok) {
setError(res.error);
} else {
await loadSavedSessions();
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to delete session.");
} finally {
setLoading(false);
}
};
const handleScenarioClick = async (scenario: { path: string; name: string }) => {
setScenarioForModal(scenario);
setLoadingEntities(true);
setSelectedEntityForModal(""); // Reset selection to Spectator
try {
const res = await getScenarioEntities(scenario.path);
if (res.ok) {
setModalEntities(res.entities);
} else {
setModalEntities([]);
}
} catch {
setModalEntities([]);
} finally {
setLoadingEntities(false);
}
};
const handleStartFromModal = async () => {
if (!scenarioForModal) return;
setLoading(true);
setError("");
const targetScenario = scenarioForModal;
setScenarioForModal(null); // close modal
try {
const result = await startSimulation({
scenario: targetScenario.path,
playEntity: selectedEntityForModal || undefined,
});
if (!result.ok) {
setError(result.error);
setLoading(false);
return;
}
// Navigate to the separate play page with the newly created simulation ID
router.push(`/play?simId=${result.snapshot.id}`);
} catch (err) {
setError(
err instanceof Error
? err.message
: "Failed to start simulation.",
);
setLoading(false);
}
};
return (
<main className="mx-auto max-w-[800px] px-10 py-12">
<h1 className="mb-2 text-headline-lg text-primary">Omnia GUI</h1>
<p className="mb-8 text-body-md text-muted-foreground">
Configuration and gameplay interface for the Omnia simulation engine.
</p>
<div className="flex gap-6">
<Link href="/play" className="flex-1 no-underline text-foreground">
<Card className="transition-all hover:-translate-y-0.5 hover:shadow-[3px_3px_0_0_var(--border)] active:translate-y-0 active:shadow-[1px_1px_0_0_var(--border)]">
<CardHeader>
<CardTitle>Play</CardTitle>
<CardDescription>
Start a simulation and interact with NPCs
</CardDescription>
</CardHeader>
</Card>
</Link>
<Link href="/config" className="flex-1 no-underline text-foreground">
<Card className="transition-all hover:-translate-y-0.5 hover:shadow-[3px_3px_0_0_var(--border)] active:translate-y-0 active:shadow-[1px_1px_0_0_var(--border)]">
<CardHeader>
<CardTitle>Config</CardTitle>
<CardDescription>
Check environment, API keys, and available scenarios
</CardDescription>
</CardHeader>
</Card>
</Link>
<div className="mx-auto max-w-[800px] px-10 py-12">
<div className="animate-fade-in">
{/* Centered Big Logo */}
<div className="flex flex-col items-center justify-center mb-10 pt-4">
<img src="/logo.png" alt="Omnia Logo" className="h-28 object-contain mb-3" />
<p className="text-body-md text-muted-foreground font-mono uppercase tracking-widest">Simulation Engine</p>
</div>
{error && (
<div className="mb-6 border border-destructive bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
)}
{/* Simulations Section */}
<section className="mb-10">
<h2 className="text-headline-lg text-primary mb-6 animate-fade-in">Simulations</h2>
{loadingData ? (
<div className="flex overflow-x-auto gap-6 pb-4 scrollbar-thin scrollbar-thumb-border/20">
{Array.from({ length: 3 }).map((_, index) => (
<div
key={index}
className="flex-shrink-0 w-72 border border-border/30 bg-card p-5 shadow-sm transition-all flex flex-col justify-between h-[148px]"
>
<div className="space-y-3">
<Skeleton className="h-5 w-2/3" />
<Skeleton className="h-4 w-1/2" />
<Skeleton className="h-3 w-1/3" />
</div>
<div className="flex justify-between items-center mt-4">
<Skeleton className="h-4 w-16" />
</div>
</div>
))}
</div>
) : savedSessions.length === 0 ? (
<p className="text-sm italic text-muted-foreground">No saved simulations found. Start a new one below!</p>
) : (
<div className="flex overflow-x-auto gap-6 pb-4 scrollbar-thin scrollbar-thumb-border/20">
{savedSessions.map((s) => (
<div
key={s.id}
onClick={() => handleResume(s.id)}
className="flex-shrink-0 w-72 border border-border/30 bg-card p-5 cursor-pointer shadow-sm hover:-translate-y-0.5 hover:shadow-md active:translate-y-0 active:shadow-sm transition-all relative group"
>
<div className="flex flex-col gap-1 text-sm mb-4">
<strong className="text-body-md text-foreground block">{s.scenarioName}</strong>
<span className="text-xs text-muted-foreground">
Turn {s.turn} &middot; {s.entities.length} entities &middot; {s.status}
</span>
<span className="text-xs text-muted-foreground/60 font-mono">
ID: {s.id.substring(0, 8)}...
</span>
</div>
<div className="flex justify-between items-center mt-4">
<span className="text-xs text-primary font-mono uppercase tracking-wider">Resume</span>
<Button
size="sm"
variant="destructive"
onClick={(e) => handleDelete(s.id, e)}
disabled={loading}
title="Delete Session"
className="opacity-0 group-hover:opacity-100 transition-opacity"
>
Delete
</Button>
</div>
</div>
))}
</div>
)}
</section>
{/* Scenarios Section */}
<section className="mb-10">
<h2 className="text-headline-lg text-primary mb-6 animate-fade-in">Scenarios</h2>
{loadingData ? (
<div className="flex overflow-x-auto gap-6 pb-4 scrollbar-thin scrollbar-thumb-border/20">
<Link href="/builder" className="no-underline flex-shrink-0">
<div className="w-64 border border-border/30 bg-card p-5 cursor-pointer shadow-sm hover:-translate-y-0.5 hover:shadow-md active:translate-y-0 active:shadow-sm transition-all flex flex-col justify-between h-full min-h-[148px]">
<div>
<strong className="text-body-md text-foreground block mb-1">Build a scenario</strong>
<span className="text-xs text-muted-foreground">Create a custom simulation starting point</span>
</div>
<div className="flex items-center gap-2 mt-4 text-xs font-mono uppercase tracking-wider text-primary">
<span className="flex items-center justify-center size-5 border border-primary text-primary font-bold text-sm bg-primary/10">+</span>
<span>Create New</span>
</div>
</div>
</Link>
{Array.from({ length: 3 }).map((_, index) => (
<div
key={index}
className="flex-shrink-0 w-64 border border-border/30 bg-card p-5 shadow-sm flex flex-col justify-between h-[148px]"
>
<div className="space-y-3">
<Skeleton className="h-5 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
<div className="mt-4">
<Skeleton className="h-4 w-20" />
</div>
</div>
))}
</div>
) : (
<div className="flex overflow-x-auto gap-6 pb-4 scrollbar-thin scrollbar-thumb-border/20">
<Link href="/builder" className="no-underline flex-shrink-0">
<div className="w-64 border border-border/30 bg-card p-5 cursor-pointer shadow-sm hover:-translate-y-0.5 hover:shadow-md active:translate-y-0 active:shadow-sm transition-all flex flex-col justify-between h-full min-h-[148px]">
<div>
<strong className="text-body-md text-foreground block mb-1">Build a scenario</strong>
<span className="text-xs text-muted-foreground">Create a custom simulation starting point</span>
</div>
<div className="flex items-center gap-2 mt-4 text-xs font-mono uppercase tracking-wider text-primary">
<span className="flex items-center justify-center size-5 border border-primary text-primary font-bold text-sm bg-primary/10">+</span>
<span>Create New</span>
</div>
</div>
</Link>
{scenarios.map((s) => (
<div
key={s.path}
onClick={() => handleScenarioClick(s)}
className="flex-shrink-0 w-64 border border-border/30 bg-card p-5 cursor-pointer shadow-sm hover:-translate-y-0.5 hover:shadow-md active:translate-y-0 active:shadow-sm transition-all"
>
<strong className="text-body-md text-foreground block mb-2">{s.name}</strong>
<code className="text-xs text-primary font-mono block truncate mb-1">{s.path}</code>
<span className="text-xs text-muted-foreground font-mono uppercase tracking-wider block mt-4">Click to Start</span>
</div>
))}
</div>
)}
</section>
{/* Start Scenario Selection Modal */}
{scenarioForModal && (
<Dialog open={!!scenarioForModal} onOpenChange={(open) => !open && setScenarioForModal(null)}>
<DialogContent className="max-w-[400px]">
<DialogHeader className="border-b border-dotted border-border/20 pb-4 mb-2">
<DialogTitle>Start Scenario</DialogTitle>
<DialogDescription>
Choose how you want to interact with <strong>{scenarioForModal.name}</strong>.
</DialogDescription>
</DialogHeader>
{loadingEntities ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground py-4 justify-center">
<Spinner />
<span>Loading scenario entities...</span>
</div>
) : (
<div className="flex flex-col gap-4 py-4">
<div className="flex flex-col gap-2">
<label className="text-xs font-semibold uppercase tracking-wider text-muted-foreground font-mono">
Simulation Mode / Play as
</label>
<Select
value={selectedEntityForModal}
onValueChange={(val) => setSelectedEntityForModal(val || "")}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="-- Run Fully Autonomously --" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="">-- Run Fully Autonomously --</SelectItem>
{modalEntities.map((ent) => (
<SelectItem key={ent.id} value={ent.id}>
Play as {ent.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
</div>
)}
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => setScenarioForModal(null)}>
Cancel
</Button>
<Button onClick={handleStartFromModal} disabled={loading || loadingEntities}>
{loading ? "Starting..." : "Launch"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</div>
</main>
</div>
);
}

View File

@@ -1,5 +1,16 @@
"use client";
import { PlayView } from "@/components/play/PlayView";
import { Suspense } from "react";
export default function PlayPage() {
return <PlayView />;
return (
<Suspense fallback={
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin text-primary">Loading...</div>
</div>
}>
<PlayView />
</Suspense>
);
}

View File

@@ -1,32 +1,18 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import {
startSimulation,
stepSimulation,
submitPlayerAction,
listSavedSimulations,
resumeSimulation,
getConfigStatus,
getScenarioEntities,
deleteSimulation,
listProviderInstances,
} from "@/app/play/actions";
import type { SimSnapshot } from "@/lib/simulation-types";
import type { ModelProviderInstance } from "@omnia/llm";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Spinner } from "@/components/ui/spinner";
import { PromptModal } from "./PromptModal";
import { cn } from "@/lib/utils";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
function IntentTag({
intent,
@@ -66,7 +52,6 @@ function IntentTag({
);
}
function formatSimTime(isoString: string) {
try {
const d = new Date(isoString);
@@ -131,12 +116,17 @@ function LogEntryCard({
}
export function PlayView() {
const router = useRouter();
const searchParams = useSearchParams();
const simId = searchParams.get("simId");
const [snapshot, setSnapshot] = useState<SimSnapshot | null>(null);
const [loading, setLoading] = useState(false);
const [loading, setLoading] = useState(true);
const [playerInput, setPlayerInput] = useState("");
const [error, setError] = useState("");
const [statusText, setStatusText] = useState("");
const [selectedEntryForModal, setSelectedEntryForModal] = useState<SimSnapshot["log"][number] | null>(null);
const logEndRef = useRef<HTMLDivElement>(null);
const steppingRef = useRef(false);
const pauseRequestedRef = useRef(false);
@@ -203,26 +193,7 @@ export function PlayView() {
[snapshot],
);
const [savedSessions, setSavedSessions] = useState<SimSnapshot[]>([]);
const loadSavedSessions = useCallback(async () => {
try {
const res = await listSavedSimulations();
if (res.ok) {
setSavedSessions(res.sessions);
}
} catch {
// ignore
}
}, []);
useEffect(() => {
if (!snapshot) {
loadSavedSessions();
}
}, [snapshot, loadSavedSessions]);
const handleResume = async (id: string) => {
const handleResume = useCallback(async (id: string) => {
setLoading(true);
setError("");
try {
@@ -242,114 +213,16 @@ export function PlayView() {
setError(err instanceof Error ? err.message : "Failed to resume session.");
setLoading(false);
}
};
}, [runSteps]);
const handleDelete = async (id: string, e: React.MouseEvent) => {
e.stopPropagation();
if (!confirm("Are you sure you want to delete this simulation session?")) return;
setLoading(true);
try {
const res = await deleteSimulation(id);
if (!res.ok) {
setError(res.error);
} else {
await loadSavedSessions();
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to delete session.");
} finally {
setLoading(false);
}
};
const [scenarios, setScenarios] = useState<{ path: string; name: string }[]>([]);
const [selectedScenario, setSelectedScenario] = useState("");
const [availableEntities, setAvailableEntities] = useState<{ id: string; name: string }[]>([]);
const [selectedEntity, setSelectedEntity] = useState("");
const [providerInstances, setProviderInstances] = useState<ModelProviderInstance[]>([]);
// Load scenarios and provider instances on mount
// Load simulation on mount
useEffect(() => {
async function loadScenariosAndProviders() {
try {
const configStatus = await getConfigStatus();
setScenarios(configStatus.availableScenarios);
if (configStatus.availableScenarios.length > 0) {
setSelectedScenario(configStatus.availableScenarios[0].path);
}
} catch {
// ignore
}
try {
const providersList = await listProviderInstances();
setProviderInstances(providersList);
} catch {
// ignore
}
}
loadScenariosAndProviders();
}, [snapshot]);
// Fetch entities when selectedScenario changes
useEffect(() => {
if (!selectedScenario) {
setAvailableEntities([]);
setSelectedEntity("");
if (!simId) {
router.replace("/");
return;
}
async function loadEntities() {
try {
const res = await getScenarioEntities(selectedScenario);
if (res.ok) {
setAvailableEntities(res.entities);
if (res.entities.length > 0) {
setSelectedEntity(res.entities[0].id);
} else {
setSelectedEntity("");
}
}
} catch {
// ignore
}
}
loadEntities();
}, [selectedScenario]);
const handleStart = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setLoading(true);
setError("");
try {
const form = new FormData(e.currentTarget);
const result = await startSimulation({
scenario: (form.get("scenario") as string) || undefined,
playEntity: (form.get("playEntity") as string) || undefined,
});
if (!result.ok) {
setError(result.error);
setLoading(false);
return;
}
setSnapshot(result.snapshot);
if (result.snapshot.status === "running") {
await runSteps(result.snapshot.id);
} else {
setLoading(false);
}
} catch (err) {
setError(
err instanceof Error
? err.message
: "Failed to start simulation.",
);
setLoading(false);
}
};
handleResume(simId);
}, [simId, handleResume, router]);
const handleSubmitAction = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
@@ -402,235 +275,162 @@ export function PlayView() {
}
};
if (!snapshot && loading) {
return (
<div className="flex flex-col items-center justify-center min-h-[400px] gap-3">
<Spinner />
<span className="text-sm text-muted-foreground font-mono">Initializing simulation...</span>
</div>
);
}
if (!snapshot && error) {
return (
<div className="mx-auto max-w-[800px] px-10 py-12">
<div className="border border-destructive bg-destructive/10 px-4 py-3 text-sm text-destructive mb-6">
{error}
</div>
<Button onClick={() => router.push("/")}>Back to Dashboard</Button>
</div>
);
}
if (!snapshot) return null;
return (
<div className="mx-auto max-w-[800px] px-10 py-12">
<h1 className="text-headline-lg text-primary mb-6 animate-fade-in">Omnia Play</h1>
{!snapshot && (
<div className="grid grid-cols-1 md:grid-cols-[1.2fr_1fr] gap-8 mt-4">
<div className="border border-border/30 bg-card p-6 shadow-[2px_2px_0_0_var(--border)]">
<h2 className="text-headline-md text-foreground mb-5 pb-2 border-b border-dotted border-border/20">Start New Simulation</h2>
<form onSubmit={handleStart} className="flex flex-col gap-4">
{error && (
<div className="rounded border-2 border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
)}
<div className="flex flex-col gap-1">
<label htmlFor="scenario" className="text-sm font-medium">Scenario</label>
<Select
value={selectedScenario}
onValueChange={(val) => setSelectedScenario(val || "")}
<div className="mx-auto max-w-[800px] px-10 py-12 animate-fade-in">
<div className="mb-4">
<div className="flex justify-between items-center mb-1">
<h2 className="text-headline-md text-primary">{snapshot.scenarioName}</h2>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
pauseRequestedRef.current = true;
router.push("/");
}}
>
Dashboard
</Button>
{snapshot.status !== "done" && snapshot.status !== "error" && (
<>
{snapshot.status === "running" && (
loading ? (
<Button
variant="secondary"
size="sm"
onClick={() => {
pauseRequestedRef.current = true;
}}
>
Pause
</Button>
) : (
<Button
variant="secondary"
size="sm"
onClick={() => runSteps(snapshot.id)}
>
Resume
</Button>
)
)}
<Button
variant="destructive"
size="sm"
onClick={() => {
pauseRequestedRef.current = true;
router.push("/");
}}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{scenarios.map((s) => (
<SelectItem key={s.path} value={s.path}>
{s.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="playEntity" className="text-sm font-medium">
Play as (Entity)
</label>
<Select
value={selectedEntity}
onValueChange={(val) => setSelectedEntity(val || "")}
disabled={availableEntities.length === 0}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="-- Spectator (Observer) --" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="">-- Spectator (Observer) --</SelectItem>
{availableEntities.map((ent) => (
<SelectItem key={ent.id} value={ent.id}>
{ent.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<Button type="submit" disabled={loading || providerInstances.length === 0}>
{loading ? "Starting..." : "Start Simulation"}
</Button>
</form>
</div>
<div className="border border-border/30 bg-card p-6 shadow-[2px_2px_0_0_var(--border)]">
<h2 className="text-headline-md text-foreground mb-5 pb-2 border-b border-dotted border-border/20">Resume Simulation</h2>
{savedSessions.length === 0 ? (
<p className="text-sm italic text-muted-foreground">No saved sessions found. Start a new one!</p>
) : (
<div className="flex flex-col gap-3 max-h-[400px] overflow-y-auto pr-1">
{savedSessions.map((s) => (
<div key={s.id} className="border border-border/30 bg-secondary/40 p-3 flex justify-between items-center gap-4 shadow-[1px_1px_0_0_var(--border)]">
<div className="flex flex-col gap-0.5 text-sm">
<strong className="text-sm text-foreground">{s.scenarioName}</strong>
<span className="text-xs text-muted-foreground">
Turn {s.turn} &middot; {s.entities.length} entities &middot; {s.status}
</span>
<span className="text-xs text-muted-foreground/60">
Session ID: <code className="font-mono text-xs">{s.id}</code>
</span>
</div>
<div className="flex gap-2 items-center">
<Button size="sm" onClick={() => handleResume(s.id)} disabled={loading || providerInstances.length === 0}>
Resume
</Button>
<Button
size="sm"
variant="destructive"
onClick={(e) => handleDelete(s.id, e)}
disabled={loading}
title="Delete Session"
>
Delete
</Button>
</div>
</div>
))}
</div>
Stop
</Button>
</>
)}
</div>
</div>
<p className="text-sm text-muted-foreground">{snapshot.scenarioDescription}</p>
<p className="text-sm font-medium text-primary mt-1">
{loading && "⏳ "}
{statusMessage()}
</p>
</div>
<div className="flex flex-col gap-4 mb-6 max-h-[55vh] overflow-y-auto border border-border/20 bg-secondary/30 p-4 shadow-[inset_1px_1px_4px_rgba(0,0,0,0.05)]">
{(() => {
const playerEntity = snapshot.entities.find((e) => e.isPlayer);
return snapshot.log.map((entry, i) => (
<LogEntryCard
key={i}
entry={entry}
onShowPrompt={setSelectedEntryForModal}
isPlayerCard={entry.entityId === playerEntity?.id}
/>
));
})()}
{loading && (
<div className="flex items-center gap-2 text-sm italic text-muted-foreground p-2 font-mono">
<Spinner />
{statusText || "Processing..."}
</div>
)}
<div ref={logEndRef} />
</div>
{snapshot.status === "waiting_player" && snapshot.waitingEntity && (
<div className="border border-border/30 bg-card p-4 shadow-[2px_2px_0_0_var(--border)]">
<details className="mb-3">
<summary className="cursor-pointer text-sm font-medium font-head text-primary">
<strong>
Your context as {snapshot.waitingEntity.name}
</strong>
</summary>
<pre className="text-xs whitespace-pre-wrap bg-input border border-border/20 p-2 max-h-[200px] overflow-y-auto mt-2 font-mono">
{snapshot.waitingEntity.userContext}
</pre>
</details>
<form onSubmit={handleSubmitAction} className="flex flex-col gap-2">
<Textarea
value={playerInput}
onChange={(e) => setPlayerInput(e.target.value)}
placeholder="Describe what your character does, says, or thinks..."
rows={3}
disabled={loading}
/>
<Button
type="submit"
disabled={loading || !playerInput.trim()}
>
{loading ? "Processing..." : "Submit Action"}
</Button>
</form>
</div>
)}
{snapshot && (
<>
<div className="mb-4">
<div className="flex justify-between items-center mb-1">
<h2 className="text-headline-md text-primary">{snapshot.scenarioName}</h2>
{snapshot.status !== "done" && snapshot.status !== "error" && (
<div className="flex gap-2">
{snapshot.status === "running" && (
loading ? (
<Button
variant="secondary"
size="sm"
onClick={() => {
pauseRequestedRef.current = true;
}}
>
Pause
</Button>
) : (
<Button
variant="secondary"
size="sm"
onClick={() => runSteps(snapshot.id)}
>
Resume
</Button>
)
)}
<Button
variant="destructive"
size="sm"
onClick={() => {
setSnapshot(null);
setError("");
}}
>
Stop
</Button>
</div>
)}
</div>
<p className="text-sm text-muted-foreground">{snapshot.scenarioDescription}</p>
<p className="text-sm font-medium text-primary mt-1">
{loading && "⏳ "}
{statusMessage()}
</p>
</div>
{(snapshot.status === "done" || snapshot.status === "error") && (
<Button
onClick={() => {
router.push("/");
}}
className="mt-4"
>
{snapshot.status === "error" ? "Back to Dashboard" : "New Simulation"}
</Button>
)}
<div className="flex flex-col gap-4 mb-6 max-h-[55vh] overflow-y-auto border border-border/20 bg-secondary/30 p-4 shadow-[inset_1px_1px_4px_rgba(0,0,0,0.05)]">
{(() => {
const playerEntity = snapshot.entities.find((e) => e.isPlayer);
return snapshot.log.map((entry, i) => (
<LogEntryCard
key={i}
entry={entry}
onShowPrompt={setSelectedEntryForModal}
isPlayerCard={entry.entityId === playerEntity?.id}
/>
));
})()}
{loading && (
<div className="flex items-center gap-2 text-sm italic text-muted-foreground p-2 font-mono">
<Spinner />
{statusText || "Processing..."}
</div>
)}
<div ref={logEndRef} />
</div>
{error && !loading && (
<div className="border border-destructive bg-destructive/10 px-3 py-2 text-sm text-destructive mt-4">
{error}
</div>
)}
{snapshot.status === "waiting_player" && snapshot.waitingEntity && (
<div className="border border-border/30 bg-card p-4 shadow-[2px_2px_0_0_var(--border)]">
<details className="mb-3">
<summary className="cursor-pointer text-sm font-medium font-head text-primary">
<strong>
Your context as {snapshot.waitingEntity.name}
</strong>
</summary>
<pre className="text-xs whitespace-pre-wrap bg-input border border-border/20 p-2 max-h-[200px] overflow-y-auto mt-2 font-mono">
{snapshot.waitingEntity.userContext}
</pre>
</details>
<form onSubmit={handleSubmitAction} className="flex flex-col gap-2">
<Textarea
value={playerInput}
onChange={(e) => setPlayerInput(e.target.value)}
placeholder="Describe what your character does, says, or thinks..."
rows={3}
disabled={loading}
/>
<Button
type="submit"
disabled={loading || !playerInput.trim()}
>
{loading ? "Processing..." : "Submit Action"}
</Button>
</form>
</div>
)}
{(snapshot.status === "done" || snapshot.status === "error") && (
<Button
onClick={() => {
setSnapshot(null);
setError("");
}}
className="mt-4"
>
{snapshot.status === "error" ? "Try Again" : "New Simulation"}
</Button>
)}
{error && !loading && (
<div className="border border-destructive bg-destructive/10 px-3 py-2 text-sm text-destructive mt-4">
{error}
</div>
)}
{selectedEntryForModal && (
<PromptModal
entry={selectedEntryForModal}
onClose={() => setSelectedEntryForModal(null)}
/>
)}
</>
{selectedEntryForModal && (
<PromptModal
entry={selectedEntryForModal}
onClose={() => setSelectedEntryForModal(null)}
/>
)}
</div>
);

View File

@@ -128,7 +128,7 @@ function NavigationMenuLink({
<NavigationMenuPrimitive.Link
data-slot="navigation-menu-link"
className={cn(
"flex items-center gap-2 p-2 text-sm transition-all outline-none hover:bg-secondary hover:text-foreground focus:bg-secondary focus:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary in-data-[slot=navigation-menu-content]:rounded-sm data-active:bg-primary/15 data-active:text-primary [&_svg:not([class*='size-'])]:size-4",
"flex items-center gap-2 p-2 text-sm transition-all outline-none hover:bg-secondary hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary in-data-[slot=navigation-menu-content]:rounded-sm data-active:bg-primary/15 data-active:text-primary [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}

View File

@@ -0,0 +1,16 @@
import { cn } from "@/lib/utils"
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-muted/60", className)}
{...props}
/>
)
}
export { Skeleton }