diff --git a/src/frontend/spike.html b/src/frontend/spike.html new file mode 100644 index 0000000..a051f4e --- /dev/null +++ b/src/frontend/spike.html @@ -0,0 +1,13 @@ + + + + + + + BeautifulUI spike · PraisonAIUI #293 + + +
+ + + diff --git a/src/frontend/src/spike/README.md b/src/frontend/src/spike/README.md new file mode 100644 index 0000000..50af06d --- /dev/null +++ b/src/frontend/src/spike/README.md @@ -0,0 +1,112 @@ +# Spike #293 — BeautifulUI primitives in PraisonAIUI + +Time-boxed evaluation proving BeautifulUI's AI-native primitives can build inside +PraisonAIUI's React 19 + Vite + Tailwind v4 toolchain and render against **mock, +PraisonAIUI-shaped** agent event streams. + +**This folder is a spike. It is intentionally isolated from the shipped bundle:** +nothing here is imported by `src/main.tsx`, and none of it is synced into +`src/praisonaiui/templates/frontend`. Removing `src/frontend/src/spike/` and +`src/frontend/spike.html` fully reverts the spike. + +## How to run + +```bash +cd src/frontend +npm install +npm run dev # then open http://localhost:5173/spike.html +``` + +`SpikePage.tsx` drives the three primitives from `mockEvents.ts`, which uses the +real `SSEEvent` contract from `src/types.ts` (`run_content` deltas, +`reasoning_step` / `tool_call_started` / `tool_call_completed`, and an +`approval_request` payload shaped like a row from `GET /api/approvals`). + +## What was vendored (upstream pin) + +- Registry index: `https://www.beautifului.dev/r/registry.json` +- Component JSON: `https://www.beautifului.dev/r/.json` +- Upstream repo: `slev12397/beautiful-ui` @ **`44a274e598395ab61e7c96c26fda2758780253b7`** (2026-09-22) +- License: **MIT** (upstream repo) + +| Primitive | Registry item | Source | Registry deps | npm deps | +|---|---|---|---|---| +| StreamingText | `streaming-text` | `beautifui/primitives/StreamingText.tsx` (10.3 KB) | foundation | **none** (`react` only) | +| ThinkingState | `thinking-state` | `beautifui/primitives/ThinkingState.tsx` (11.3 KB) | foundation | **none** (`react` only) | +| ApprovalCard | `approval-card` | `beautifui/primitives/ApprovalCard.tsx` (16.5 KB) | foundation, button, glide-menu | **none** (`react` only) | +| Button (dep) | `button` | `beautifui/atoms/Button.tsx` (2.0 KB) | foundation | `class-variance-authority` *(already a PraisonAIUI dep)* | +| GlideMenu (dep) | `glide-menu` | `beautifui/primitives/GlideMenu.tsx` (1.9 KB) | foundation | **none** (`react` only) | +| foundation | `foundation` | `beautifui/foundation.css` (17.5 KB) | — | `shadow-plugin` *(see deviation)* | + +### npm packages pulled by the registry & licence check + +For the three chosen primitives (+ their two registry deps) the registry pulls +**zero new runtime npm packages**. The only npm dependency any of them declares is +`class-variance-authority` (via `button`), which PraisonAIUI already ships. + +- `liveline`, `glimm` — **not pulled.** These are referenced only by chart-style + components (InsightCards) that we did not adopt. No licence review needed for this spike. +- `@central-icons-react` — **avoided.** All three primitives render icons as + **inline SVG** (no icon package at all), so no `lucide-react` substitution was + even required. This is the cleanest possible outcome for the icon concern. + +## Deviations from a raw `shadcn add` + +1. **`shadow-plugin` removed.** `foundation.css` imports `shadow-plugin/unprefixed` + (an extra Tailwind plugin dependency). To keep the spike lightweight we replaced + it with a small local `@theme` block defining the `--shadow-*` scale. Visual + shadows are approximate but faithful. +2. **Unbalanced-brace fix (upstream bug).** The flattened `foundation.css` emits a + **stray extra `}`** around `.records-footer-hint` (a `@media` block that lost its + opening line in the registry export). This makes Tailwind v4 fail with + `Missing opening {`. We removed the stray brace. **This is a real upstream blocker + worth reporting to `beautiful-ui`.** +3. **Imports rewritten** from `@/components/*` to `@/spike/beautifui/*` so the spike + stays isolated and cannot collide with the existing shadcn `components/ui/button.tsx`. +4. **`"use client"` directives dropped** (Vite SPA, not RSC). + +## Build result + +`vite build` of the spike entry (`spike.html`) succeeds: + +``` +✓ 39 modules transformed +spike.css 97.82 kB │ gzip: 17.48 kB (Tailwind + foundation) +spike.js 247.94 kB │ gzip: 78.52 kB (React runtime + 3 primitives + 2 deps) +``` + +- **Bundle size:** the primitives are cheap — self-contained TSX with no extra npm + deps. The JS weight is dominated by the React runtime; the incremental cost of the + three primitives is on the order of ~40 KB raw / low-teens KB gzip. The CSS is + full Tailwind + the ~17 KB foundation token/keyframe layer. +- **Motion / `prefers-reduced-motion`:** foundation ships two + `@media (prefers-reduced-motion: reduce)` guards (global animation kill-switch + + a StreamText-specific one). Motion is handled responsibly out of the box. +- **Dark mode parity:** foundation defines its **own** token namespace (`--page`, + `--canvas`, `--ink`, `--accent`, …) with its own `.dark` block, *separate* from + PraisonAIUI's shadcn tokens (`--background`/`--foreground` via `hsl(var(--…))`). + Good news: **no token collision** with the dashboard. Trade-off: the primitives do + **not** automatically inherit `chat.css`/YAML-driven theme colours — they'd need a + token bridge (map `--ink`→`--foreground`, `--surface`→`--card`, `--accent`→`--primary`) + before they visually match the shipped chat surface in both themes. + +## Assessment + +**Fit score: 7/10** for a spike, with caveats for production migration. + +**Blockers / gaps:** +- Upstream `foundation.css` brace bug (fixed here; report upstream). +- No automatic theme inheritance — needs a token bridge to match `chat.css` dark/light. +- Primitives embed **demo content** (ice-cream sales copy, fake sources). They accept + props (`content`, `rows`, `questions`, `onSubmitted`, …) so they *can* be driven by + real events — proven in `SpikePage` — but the built-in defaults must be replaced. +- PraisonAIUI already has native equivalents: `chat/ThinkingSteps.tsx`, + `chat/ToolCallDisplay.tsx`, streaming in `chat/ChatMessages.tsx`. Adopting + BeautifulUI should be weighed against enhancing these rather than duplicating them. + +**Recommended primitive order for migration (lowest risk → highest):** +1. **StreamingText** — pure token-stream UX, `react`-only, maps 1:1 to `run_content`. +2. **ThinkingState** — reasoning/tool trace, `react`-only, maps to + `reasoning_step` + `tool_call_*`; overlaps existing `ThinkingSteps.tsx`. +3. **ApprovalCard** — highest value (HITL) but most surface: pulls `button` + + `glide-menu` and needs wiring to `POST /api/approvals/{id}/resolve`. diff --git a/src/frontend/src/spike/SpikePage.tsx b/src/frontend/src/spike/SpikePage.tsx new file mode 100644 index 0000000..4a8feee --- /dev/null +++ b/src/frontend/src/spike/SpikePage.tsx @@ -0,0 +1,90 @@ +/* Spike #293 — BeautifulUI primitives driven by PraisonAIUI-shaped mock events. + * + * Dev-only harness. Run with `npm run dev` and open /spike.html. This page is + * NOT imported by the shipped library entry (main.tsx) and is NOT synced into + * src/praisonaiui/templates/frontend — per the issue's non-goals it must not + * touch the production bundle. + */ +import { useEffect, useState } from "react"; +import StreamingText from "@/spike/beautifui/primitives/StreamingText"; +import ThinkingState from "@/spike/beautifui/primitives/ThinkingState"; +import ApprovalCard from "@/spike/beautifui/primitives/ApprovalCard"; +import { + streamingTimeline, + tokensFromDeltas, + rowsFromReasoning, + REASONING_STREAM, + APPROVAL_REQUEST, +} from "@/spike/mockEvents"; + +function Panel({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

{title}

+ {children} +
+ ); +} + +export default function SpikePage() { + const [dark, setDark] = useState(false); + const [log, setLog] = useState([]); + + useEffect(() => { + document.documentElement.classList.toggle("dark", dark); + }, [dark]); + + // Emulate the SSE timeline so we exercise real delta pacing (logs only — + // the primitives below are fed the fully-resolved token/row arrays). + useEffect(() => { + const frames = streamingTimeline(); + const timers = frames.map((f) => + setTimeout(() => setLog((l) => [...l.slice(-4), `${f.event.type}: ${f.event.token ?? ""}`]), f.at), + ); + return () => timers.forEach(clearTimeout); + }, []); + + const tokens = tokensFromDeltas(streamingTimeline()); + const rows = rowsFromReasoning(REASONING_STREAM); + + return ( +
+
+
+

BeautifulUI spike

+

Issue #293 · mock PraisonAIUI event streams

+
+ +
+ + + + + + + + + + + + // In production this maps to POST /api/approvals/{id}/resolve. + setLog((l) => [ + ...l.slice(-4), + `resolve ${APPROVAL_REQUEST.id}: ${JSON.stringify(answers)}`, + ]) + } + /> + + + +
+          {log.join("\n") || "…"}
+        
+
+
+ ); +} diff --git a/src/frontend/src/spike/beautifui/atoms/Button.tsx b/src/frontend/src/spike/beautifui/atoms/Button.tsx new file mode 100644 index 0000000..64a5665 --- /dev/null +++ b/src/frontend/src/spike/beautifui/atoms/Button.tsx @@ -0,0 +1,48 @@ +/* Vendored from BeautifulUI registry: https://www.beautifului.dev/r/button.json + * Spike #293 — DO NOT ship in templates/frontend. Imports rewritten to @/spike/beautifui/*. + * License: MIT (upstream https://github.com/slev12397/beautiful-ui). */ +import type { ButtonHTMLAttributes } from "react"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "@/lib/utils"; + +const filledShadow = "shadow-[inset_0_1px_0_rgba(255,255,255,0.14)]"; + +/* Pill-shaped by default — the app's core button style. Explicit symmetric + * padding (not a fixed height) so the top/bottom spacing is always equal. */ +export const buttonVariants = cva( + `inline-flex items-center justify-center font-medium select-none + transition-[transform,background-color,opacity] duration-150 ease-out + active:scale-[0.96] disabled:opacity-50 disabled:pointer-events-none`, + { + variants: { + variant: { + primary: `bg-ink text-canvas hover:opacity-90 dark:bg-ink dark:text-canvas ${filledShadow}`, + secondary: "bg-surface text-ink shadow-btn hover:bg-inset aria-expanded:bg-hover", + ghost: "bg-hover-2 text-ink hover:bg-line-strong", + accent: `bg-accent text-white hover:bg-accent-ink ${filledShadow}`, + success: `bg-green text-white hover:brightness-95 ${filledShadow}`, + /* transparent until hovered — for dense toolbars/action rows */ + quiet: "text-ink hover:bg-hover", + }, + size: { + /* compact toolbar pill — fixed height, lighter weight */ + xs: "h-7 rounded-full px-2.5 text-[12px] font-normal leading-none gap-1", + /* canonical action pill — 27px tall, roomy sides */ + sm: "h-[27px] px-3 text-[13px] leading-none rounded-full gap-1.5", + md: "px-4 py-[9px] text-sm leading-none rounded-full gap-2", + }, + }, + defaultVariants: { variant: "secondary", size: "md" }, + }, +); + +export type ButtonVariant = NonNullable["variant"]>; + +export function Button({ + variant, + size, + className, + ...props +}: ButtonHTMLAttributes & VariantProps) { + return + ); + } + + if (sent) { + return ( +
+ + + + + {t.sentMessage} + + {resettable && ( + + )} +
+ ); + } + + return ( +
+
+ +
+ {/* the question itself is the heading */} +
+
+ {questions.map((question, qIdx) => { + const active = qIdx === qi; + // Before the first measure, mount only the active question so the + // card opens at its real height instead of flashing to full height. + if (!ready && !active) return null; + const picked = answers[qIdx] ?? []; + const questionStyle: CSSProperties = { + opacity: active ? 1 : 0, + transition: animate ? `opacity ${SLIDE}` : undefined, + pointerEvents: active ? undefined : "none", + }; + return ( +
{ questionRefs.current[qIdx] = el; }} + aria-hidden={active ? undefined : true} + style={questionStyle} + > +
{question.q}
+ + {question.options.map((option, i) => { + const on = picked.includes(i); + return ( + + ); + })} + + +
+ ); + })} +
+
+
+ + {/* footer — step nav (rolling counter) + pill actions */} +
+
+ + + + + +
+ +
+ + +
+
+
+
+ ); +} diff --git a/src/frontend/src/spike/beautifui/primitives/GlideMenu.tsx b/src/frontend/src/spike/beautifui/primitives/GlideMenu.tsx new file mode 100644 index 0000000..4c6bc3f --- /dev/null +++ b/src/frontend/src/spike/beautifui/primitives/GlideMenu.tsx @@ -0,0 +1,60 @@ +/* Vendored from BeautifulUI registry: https://www.beautifului.dev/r/glide-menu.json + * Spike #293 — DO NOT ship in templates/frontend. Imports rewritten to @/spike/beautifui/*. + * License: MIT (upstream https://github.com/slev12397/beautiful-ui). */ +import { useRef, useState, type ReactNode } from "react"; + +type GlideMenuProps = { + children: ReactNode; + className?: string; + highlightClassName?: string; + rowSelector?: string; +}; + +/** A single hover layer that glides between interactive menu rows. */ +export default function GlideMenu({ + children, + className = "", + highlightClassName = "inset-x-0 rounded-[8px] bg-hover", + rowSelector = "[data-menu-row]", +}: GlideMenuProps) { + const ref = useRef(null); + const [box, setBox] = useState<{ top: number; height: number } | null>(null); + const [visible, setVisible] = useState(false); + + const moveTo = (target: EventTarget | null) => { + const container = ref.current; + if (!(target instanceof Element) || !container) return; + const row = target.closest(rowSelector); + if (!(row instanceof HTMLElement) || !container.contains(row)) return; + const containerRect = container.getBoundingClientRect(); + const rowRect = row.getBoundingClientRect(); + setBox({ top: rowRect.top - containerRect.top, height: rowRect.height }); + setVisible(true); + }; + + return ( +
moveTo(event.target)} + onMouseLeave={() => setVisible(false)} + onFocusCapture={(event) => moveTo(event.target)} + onBlurCapture={(event) => { + if (!ref.current?.contains(event.relatedTarget as Node | null)) setVisible(false); + }} + className={`group/glide-menu relative ${className}`} + > + + {children} +
+ ); +} diff --git a/src/frontend/src/spike/beautifui/primitives/StreamingText.tsx b/src/frontend/src/spike/beautifui/primitives/StreamingText.tsx new file mode 100644 index 0000000..4f0c4e3 --- /dev/null +++ b/src/frontend/src/spike/beautifui/primitives/StreamingText.tsx @@ -0,0 +1,253 @@ +/* Vendored from BeautifulUI registry: https://www.beautifului.dev/r/streaming-text.json + * Spike #293 — DO NOT ship in templates/frontend. Imports rewritten to @/spike/beautifui/*. + * License: MIT (upstream https://github.com/slev12397/beautiful-ui). */ +import { useEffect, useState } from "react"; + +/* ───────────────────────────────────────────────────────── + * STREAMING TEXT + * Words resolve out of blur, inline citations appear in + * context, then actions and follow-up prompts become usable. + * ───────────────────────────────────────────────────────── */ + +const WORD_MS = 55; +const HOLD_MS = 3400; + +/* one streamed word, or a `cite` placeholder that renders an inline source chip */ +export type StreamingToken = { text: string; cite?: boolean }; + +const TOKENS: StreamingToken[] = [ + ..."Pistachio is your fastest-growing flavor — sales are up 23% this month and margins beat vanilla by 8 points." + .split(" ") + .map((text) => ({ text })), + { text: "", cite: true }, + ..."Stone-fruit flavors are trending in the same range." + .split(" ") + .map((text) => ({ text })), +]; + +const FOLLOW_UPS = [ + "Which flavors sell best in winter", + "Compare gelato and soft serve margins", +]; + +const SOURCE_IMAGES = { + scoop: + "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%231f7a5f'/%3E%3Cpath d='M20 36c0 7 5.4 12 12 12s12-5 12-12H20Z' fill='%23fff'/%3E%3Ccircle cx='32' cy='25' r='11' fill='%23bff3dd'/%3E%3Cpath d='M24 24c4-7 13-7 17 0' fill='none' stroke='%231f7a5f' stroke-width='4' stroke-linecap='round'/%3E%3C/svg%3E", + trends: + "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%232f6fec'/%3E%3Cpath d='M15 43 27 31l8 7 14-18' fill='none' stroke='%23fff' stroke-width='7' stroke-linecap='round' stroke-linejoin='round'/%3E%3Ccircle cx='49' cy='20' r='5' fill='%23bfe0ff'/%3E%3C/svg%3E", + market: + "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%23e56d24'/%3E%3Cpath d='M17 45V25h8v20h-8Zm11 0V16h8v29h-8Zm11 0V30h8v15h-8Z' fill='%23fff'/%3E%3Cpath d='M16 49h32' stroke='%23ffd6b8' stroke-width='4' stroke-linecap='round'/%3E%3C/svg%3E", +}; + +/* one cited source rendered as an inline chip and in the sources list */ +export type StreamingSource = { name: string; domain: string; href: string; image: string }; + +const SOURCES: StreamingSource[] = [ + { name: "Scoop Data", domain: "scoopdata.io", href: "https://scoopdata.io/", image: SOURCE_IMAGES.scoop }, + { name: "Trends Index", domain: "trends.google.com", href: "https://trends.google.com/trends/", image: SOURCE_IMAGES.trends }, + { name: "Market Basket", domain: "marketbasket.io", href: "https://marketbasket.io/", image: SOURCE_IMAGES.market }, +]; + +function sourceImage(source: StreamingSource) { + return source.image; +} + +function SourceChip({ source }: { source?: StreamingSource }) { + if (!source) return null; + return ( + + + {source.domain} + + ); +} + +const ACTION_ICONS: React.ReactNode[] = [ + , + , + , + , +]; + +export type StreamingLabels = { + /** label on the collapsed sources toggle */ + sources: string; + /** heading above the follow-up prompts */ + followUps: string; +}; + +const DEFAULT_LABELS: StreamingLabels = { + sources: "10 sources", + followUps: "Follow-ups", +}; + +export default function StreamingText({ + content = TOKENS, + sources = SOURCES, + followUps = FOLLOW_UPS, + labels, + loop = true, + fill = false, + onDone, + onFollowUp, +}: { + variant?: string; + /** the streamed tokens; `cite` tokens render an inline source chip */ + content?: StreamingToken[]; + /** cited sources shown in the chip, avatar stack, and expanded list */ + sources?: StreamingSource[]; + /** follow-up prompt suggestions shown once the stream completes */ + followUps?: string[]; + /** prominent copy strings */ + labels?: Partial; + /** restart the stream after a hold; turn off when embedding in a real thread */ + loop?: boolean; + /** fill the parent width instead of the gallery's fixed measure */ + fill?: boolean; + onDone?: () => void; + /** fired when a follow-up prompt is chosen */ + onFollowUp?: (text: string, index: number) => void; +} = {}) { + const l = { ...DEFAULT_LABELS, ...labels }; + const [count, setCount] = useState(0); + const [sourcesOpen, setSourcesOpen] = useState(false); + const done = count >= content.length; + + useEffect(() => { + if (done && !loop) { + onDone?.(); + return; + } + const t = setTimeout( + () => setCount((c) => (c >= content.length ? 0 : c + 1)), + done ? HOLD_MS : WORD_MS, + ); + return () => clearTimeout(t); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [count, done, loop]); + + return ( +
+

+ {content.slice(0, count).map((token, i) => + token.cite ? ( + + ) : ( + + {token.text}{" "} + + ), + )} + {!done && ( + + )} +

+ + {/* action icons row */} +
+ {ACTION_ICONS.map((icon, i) => ( + + ))} + +
+ +
+
+
+ {sources.map((source) => ( + + + {source.name} + {source.domain} + + ))} +
+
+
+ + {/* follow-ups */} +
+

{l.followUps}

+
+ {followUps.map((text, i) => ( + + ))} +
+
+
+ ); +} diff --git a/src/frontend/src/spike/beautifui/primitives/ThinkingState.tsx b/src/frontend/src/spike/beautifui/primitives/ThinkingState.tsx new file mode 100644 index 0000000..da324c8 --- /dev/null +++ b/src/frontend/src/spike/beautifui/primitives/ThinkingState.tsx @@ -0,0 +1,302 @@ +/* Vendored from BeautifulUI registry: https://www.beautifului.dev/r/thinking-state.json + * Spike #293 — DO NOT ship in templates/frontend. Imports rewritten to @/spike/beautifui/*. + * License: MIT (upstream https://github.com/slev12397/beautiful-ui). */ +import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react"; + +/* ───────────────────────────────────────────────────────── + * THINKING — expandable agent trace, four variants + * + * Steps step list with spinner → muted checks + * Reasoning prose reasoning that expands, then settles + * Search web-search trace: query + sources read + * Coding tool trace: files read, edits, commands + * + * The trace runs once, settles, and remains expandable. + * ───────────────────────────────────────────────────────── */ + +const STAGES = [800, 600, 1800, 2600, 1600]; + +function useSequence(steps: number[]) { + const [stage, setStage] = useState(0); + useEffect(() => { + if (stage >= steps.length - 1) return; + const t = setTimeout(() => setStage((s) => s + 1), steps[stage]); + return () => clearTimeout(t); + }, [stage, steps]); + return stage; +} + +type Row = { + primary: string; + secondary?: string; + mono?: boolean; + add?: number; + del?: number; + href?: string; +}; + +const VARIANTS: Record< + string, + { active: string; done: string; rows: Row[]; query?: string } +> = { + Steps: { + active: "Thinking", + done: "Thought for 4 seconds", + rows: [ + { primary: "Reading flavor briefs" }, + { primary: "Scanning supplier lists" }, + { primary: "Comparing tasting notes", secondary: "6 flavors" }, + { primary: "Writing the scoop report" }, + ], + }, + Reasoning: { + active: "Thinking", + done: "Thought for 4 seconds", + rows: [ + { primary: "Summer demand spikes for stone-fruit flavors — peach and apricot lead." }, + { primary: "I should check cone inventory before promoting a waffle-bowl special." }, + ], + }, + Search: { + active: "Searching the web", + done: "Searched the web", + query: "best waffle cone supplier", + rows: [ + { primary: "Joy Cone", secondary: "joycone.com", href: "https://joycone.com/fs_products/waffle-cones/" }, + { primary: "WebstaurantStore", secondary: "webstaurantstore.com", href: "https://www.webstaurantstore.com/ice-cream-shop-supplies.html" }, + { primary: "The Konery", secondary: "thekonery.com", href: "https://www.thekonery.com/" }, + ], + }, + Coding: { + active: "Running tools", + done: "Ran 3 tools", + rows: [ + { primary: "Read", secondary: "flavors.ts", mono: true }, + { primary: "Edit", secondary: "ChurnSchedule.tsx", mono: true, add: 74, del: 41 }, + { primary: "Run", secondary: "npm run freeze", mono: true }, + ], + }, +}; + +function Dot({ tone }: { tone: string }) { + return ( + + + + + + + ); +} + +const TONES = ["bg-accent", "bg-orange", "bg-green"]; + +export default function ThinkingState({ + variant = "Steps", + onSettled, + rows, + active, + done, + icon, +}: { + variant?: string; + onSettled?: () => void; + /** override the built-in trace content (keeps the primitive reusable) */ + rows?: Row[]; + active?: string; + done?: string; + /** override the header glyph (defaults to the sparkle) */ + icon?: ReactNode; +}) { + const stage = useSequence(STAGES); + const [manualExpanded, setManualExpanded] = useState(null); + const [selectedTool, setSelectedTool] = useState(null); + const base = VARIANTS[variant] ?? VARIANTS.Steps; + const v = { + ...base, + rows: rows ?? base.rows, + active: active ?? base.active, + done: done ?? base.done, + }; + const autoExpanded = stage >= 1 && stage < 4; + const expanded = manualExpanded ?? autoExpanded; + const working = stage < 3; + const visible = stage < 2 ? 0 : stage === 2 ? Math.min(2, v.rows.length) : v.rows.length; + const traceRef = useRef(null); + const [lineHeight, setLineHeight] = useState(0); + useLayoutEffect(() => { + if (traceRef.current) setLineHeight(traceRef.current.offsetHeight); + }, [visible, expanded, variant, stage]); + + /* let embedders sequence content after the trace settles */ + const settledRef = useRef(false); + useEffect(() => { + if (working || settledRef.current) return; + settledRef.current = true; + onSettled?.(); + }, [working, onSettled]); + + return ( +
+ {/* header — shared across variants */} + + + {/* expandable trace */} +
+
+
+ +
+ {v.query && ( +
+ + + + + {v.query} +
+ )} + {v.rows.slice(0, visible).map((row, i) => { + const content = ( + <> + {variant === "Search" && } + {variant === "Steps" && ( + i < visible - 1 || !working ? ( + + + + ) : ( + + ) + )} + + {row.primary} + + {row.secondary && ( + + {row.secondary} + + )} + {row.add !== undefined && ( + + +{row.add}{" "} + −{row.del} + + )} + + ); + const rowClass = "flex min-h-7 w-full items-center gap-2 rounded-[6px] px-1.5 py-0.5 text-left"; + const animation = { animation: `fade-up 320ms cubic-bezier(0.23,1,0.32,1) ${i * 120}ms both` }; + + if (variant === "Search") { + return ( + + {content} + + ); + } + + if (variant === "Coding") { + const selected = selectedTool === row.primary; + return ( + + ); + } + + return ( +
+ {content} +
+ ); + })} + {variant === "Search" && stage >= 3 && ( + + +7 more + + )} +
+
+
+
+
+ ); +} diff --git a/src/frontend/src/spike/main.tsx b/src/frontend/src/spike/main.tsx new file mode 100644 index 0000000..f5bb13f --- /dev/null +++ b/src/frontend/src/spike/main.tsx @@ -0,0 +1,11 @@ +/* Spike #293 — dev-only entry. Not part of the production bundle. */ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import "@/spike/beautifui/foundation.css"; +import SpikePage from "@/spike/SpikePage"; + +createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/src/frontend/src/spike/mockEvents.ts b/src/frontend/src/spike/mockEvents.ts new file mode 100644 index 0000000..fc760fc --- /dev/null +++ b/src/frontend/src/spike/mockEvents.ts @@ -0,0 +1,91 @@ +/* Spike #293 — PraisonAIUI-shaped mock event stream. + * + * These events use the real `SSEEvent` contract from `@/types` so the spike + * proves the BeautifulUI primitives can render against the *actual* event + * vocabulary the chat surface emits (see src/praisonaiui/templates/frontend/ + * plugins/views/chat.js and RunEventType in src/frontend/src/types.ts). + * + * Nothing here talks to a live socket — that is an explicit non-goal of the + * spike. The timeline below is replayed by SpikePage with setTimeout to + * emulate SSE deltas / tool lifecycle / a HITL approval request. + */ +import type { SSEEvent } from "@/types"; + +/** A scripted SSE frame plus the delay (ms) before it fires. */ +export type MockFrame = { at: number; event: SSEEvent }; + +/** approval_request payload shaped like a row from GET /api/approvals. */ +export type ApprovalRequest = { + id: string; + tool: string; + args: Record; + questions: { q: string; type: "radio" | "check"; options: string[] }[]; +}; + +/* SSE-style text deltas — streamed word-by-word as `run_content`. */ +const ANSWER = + "Pistachio is your fastest-growing flavor — sales are up 23% this month " + + "and margins beat vanilla by 8 points. Stone-fruit flavors trend in the same range."; + +function contentDeltas(startAt: number, stepMs = 55): MockFrame[] { + const words = ANSWER.split(" "); + return words.map((w, i) => ({ + at: startAt + i * stepMs, + event: { type: "run_content", token: w + " ", content: w + " " } as SSEEvent, + })); +} + +const LAST_DELTA_AT = 300 + ANSWER.split(" ").length * 55; + +/* Reasoning + tool lifecycle, mapped to ThinkingState rows. */ +export const REASONING_STREAM: SSEEvent[] = [ + { type: "reasoning_started" }, + { type: "reasoning_step", step: "Reading flavor briefs", step_number: 1 }, + { type: "tool_call_started", name: "sql.query", tool_call_id: "t1", args: { table: "sales" } }, + { type: "tool_call_completed", name: "sql.query", tool_call_id: "t1", formatted_result: "6 flavors" }, + { type: "reasoning_step", step: "Comparing tasting notes", step_number: 2 }, + { type: "tool_call_started", name: "trends.lookup", tool_call_id: "t2", args: { window: "30d" } }, + { type: "tool_call_completed", name: "trends.lookup", tool_call_id: "t2", formatted_result: "+23%" }, + { type: "reasoning_completed" }, +]; + +/* approval_request payload — the /api/approvals shape a HITL gate would push. */ +export const APPROVAL_REQUEST: ApprovalRequest = { + id: "appr_9f2", + tool: "menu.publish", + args: { flavors: 3, season: "winter" }, + questions: [ + { q: "How many flavors should we launch?", type: "radio", options: ["Three (core line)", "Five (full case)", "Just one hero"] }, + { q: "Which mix-ins should we stock?", type: "check", options: ["Chocolate chips", "Waffle bits", "Sprinkles"] }, + ], +}; + +/** Full scripted timeline for the streaming panel. */ +export function streamingTimeline(): MockFrame[] { + return [ + { at: 0, event: { type: "run_started" } as SSEEvent }, + ...contentDeltas(300), + { at: LAST_DELTA_AT + 100, event: { type: "run_completed" } as SSEEvent }, + ]; +} + +/* ── Adapters: PraisonAIUI events → BeautifulUI primitive props ── */ + +/** run_content deltas → StreamingText tokens (`cite` marks a source chip slot). */ +export function tokensFromDeltas(frames: MockFrame[]): { text: string; cite?: boolean }[] { + return frames + .filter((f) => f.event.type === "run_content") + .map((f) => ({ text: (f.event.token ?? f.event.content ?? "").trim() })); +} + +/** reasoning_step / tool_call_* → ThinkingState rows. */ +export function rowsFromReasoning(events: SSEEvent[]): { primary: string; secondary?: string; mono?: boolean }[] { + const rows: { primary: string; secondary?: string; mono?: boolean }[] = []; + for (const e of events) { + if (e.type === "reasoning_step" && e.step) rows.push({ primary: e.step }); + else if (e.type === "tool_call_started" && e.name) rows.push({ primary: e.name, mono: true }); + else if (e.type === "tool_call_completed" && e.name) + rows.push({ primary: e.name, secondary: e.formatted_result, mono: true }); + } + return rows; +}