Files
crema-app-aifirst-template/app/components/scripts-dialog.tsx
jules 286e2daf95 feat: initial commit — crema-app-aifirst-template
Hybrid traditional + AI-first webapp scaffold. Sibling to crema-app-template,
adds the AI assistant surface, command bus, scripts dialog, and virtual
cursor.

What's pre-wired:
- 6 routes: Overview, Resources, Activity, Assistant, Library, Settings
- Collapsible rail + appbar + avatar dropdown shell (template code, not a lib)
- Mobile sheet at <md
- /assistant: streaming chat via @crema/llm-ui, mock fallback, model selector,
  token meter, retry probe, stop-while-streaming, persistent UI Control toggle
- /settings: editable LM Studio endpoint + context window + response cap, with
  test-connection button
- Markdown rendering for assistant replies; ```action``` blocks rendered as a
  small "Ran N actions" pill
- ⌘⇧P script runner dialog + Play icon in the appbar
- Two demo scripts in public/scripts/
- mightypix theme as default, scoped via <AppShell theme="mightypix">

Libs wired in tsconfig + app.css:
- @crema/action-bus (the bus, parser, runner, cursor, provider, ws, llm-bridge)
- @crema/llm-ui, @crema/chat-ui, @crema/aifirst-ui, @crema/notification-ui
- lib-theme-mightypix

Docs:
- README.md — pitch + quick start + structure
- docs/AI_FIRST.md — full system tour (data-action contract, bus, DSL, scripts,
  cursor, LLM integration)
- app/components/layout/THEME_CONTRACT.md — every CSS variable a theme must declare
- CLAUDE.md — orientation for an LLM working in the repo

Genericized from comfy-cloud (the original prototype):
- Brand defaults to "App" / Sparkles icon (override via app/lib/identity.ts)
- User defaults to a stub (swap useUser() for real auth)
- localStorage namespace is "crema.*" (was "comfy.*")

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 18:31:46 +10:00

156 lines
4.5 KiB
TypeScript

// Run a saved script from public/scripts/ or paste DSL ad-hoc.
// Triggered by the Play icon in the appbar or Cmd/Ctrl+Shift+P.
import { useEffect, useState } from "react"
import { Play } from "lucide-react"
import { Button } from "~/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog"
import { runScript, runScriptText } from "@crema/action-bus"
const KNOWN_SCRIPTS = [
{ name: "demo-tour", description: "Tour the rail" },
{ name: "demo-search", description: "Focus and fill search" },
]
export function useScriptsHotkey(open: () => void) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const mod = e.metaKey || e.ctrlKey
if (mod && e.shiftKey && (e.key === "p" || e.key === "P")) {
e.preventDefault()
open()
}
}
window.addEventListener("keydown", onKey)
return () => window.removeEventListener("keydown", onKey)
}, [open])
}
export function ScriptsDialog({
open,
onOpenChange,
}: {
open: boolean
onOpenChange: (v: boolean) => void
}) {
const [text, setText] = useState("")
const [status, setStatus] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const runByName = async (name: string) => {
setBusy(true)
setStatus(`Running ${name}`)
try {
onOpenChange(false)
await runScript(name)
setStatus(`Ran ${name}.`)
} catch (e) {
setStatus(`Error: ${e instanceof Error ? e.message : String(e)}`)
} finally {
setBusy(false)
}
}
const runText = async () => {
if (!text.trim()) return
setBusy(true)
setStatus("Running…")
try {
onOpenChange(false)
await runScriptText(text)
setStatus("Done.")
} catch (e) {
setStatus(`Error: ${e instanceof Error ? e.message : String(e)}`)
} finally {
setBusy(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Run a script</DialogTitle>
<DialogDescription>
Pick a saved script or paste DSL. Cmd/Ctrl + Shift + P toggles this.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3">
<div>
<div className="mb-1.5 text-xs font-medium text-muted-foreground">
Saved
</div>
<div className="flex flex-col gap-1">
{KNOWN_SCRIPTS.map((s) => (
<button
key={s.name}
type="button"
data-action={`run-script-${s.name}`}
onClick={() => runByName(s.name)}
disabled={busy}
className="flex items-center justify-between rounded-md border px-3 py-2 text-left text-sm transition-colors hover:bg-accent disabled:opacity-50"
>
<span>
<span className="font-mono text-xs">{s.name}</span>
<span className="ml-2 text-muted-foreground">
{s.description}
</span>
</span>
<Play className="size-4 text-muted-foreground" />
</button>
))}
</div>
</div>
<div>
<div className="mb-1.5 text-xs font-medium text-muted-foreground">
Paste DSL
</div>
<textarea
data-action="scripts-dsl"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder={"navigate /resources\nclick nav-resources"}
spellCheck={false}
rows={6}
className="w-full rounded-md border bg-background p-2 font-mono text-xs"
/>
</div>
{status && (
<div className="rounded-md border bg-muted px-3 py-2 text-xs text-muted-foreground">
{status}
</div>
)}
</div>
<div className="flex justify-end gap-2">
<Button
data-action="scripts-cancel"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={busy}
>
Close
</Button>
<Button
data-action="scripts-run"
onClick={runText}
disabled={busy || !text.trim()}
>
<Play className="size-4" /> Run
</Button>
</div>
</DialogContent>
</Dialog>
)
}