PR #457: add Echo Studio dashboard builder scaffold (waylonkenning); closes #447; dropped e2e spec (no @playwright/test in CI, matches existing e2e exclusion)
This commit is contained in:
@@ -84,6 +84,13 @@ export const MOBILE_HAMBURGER_NAV_ITEMS = [
|
||||
to: '/swarm',
|
||||
match: (p: string) => p === '/swarm' || p.startsWith('/swarm2'),
|
||||
},
|
||||
{
|
||||
id: 'echo-studio',
|
||||
label: 'Echo Studio',
|
||||
icon: Rocket01Icon,
|
||||
to: '/echo-studio',
|
||||
match: (p: string) => p.startsWith('/echo-studio'),
|
||||
},
|
||||
|
||||
{
|
||||
id: 'memory',
|
||||
|
||||
@@ -99,6 +99,7 @@ export function WorkspaceShell({ children }: WorkspaceShellProps) {
|
||||
if (path.startsWith('/terminal')) return 3
|
||||
if (path.startsWith('/jobs')) return 4
|
||||
if (path === '/swarm' || path.startsWith('/swarm2')) return 5
|
||||
if (path.startsWith('/echo-studio')) return 5
|
||||
if (path.startsWith('/memory')) return 6
|
||||
if (path.startsWith('/skills')) return 7
|
||||
if (path.startsWith('/mcp')) return 8
|
||||
@@ -173,6 +174,7 @@ export function WorkspaceShell({ children }: WorkspaceShellProps) {
|
||||
if (pathname.startsWith('/conductor')) return 'Conductor'
|
||||
if (pathname.startsWith('/operations')) return 'Operations'
|
||||
if (pathname.startsWith('/swarm2') || pathname === '/swarm') return 'Swarm'
|
||||
if (pathname.startsWith('/echo-studio')) return 'Echo Studio'
|
||||
if (pathname.startsWith('/memory')) return 'Memory'
|
||||
if (pathname.startsWith('/skills')) return 'Skills'
|
||||
if (pathname.startsWith('/mcp')) return 'MCP'
|
||||
|
||||
@@ -26,6 +26,7 @@ import { Route as McpRouteImport } from './routes/mcp'
|
||||
import { Route as JobsRouteImport } from './routes/jobs'
|
||||
import { Route as HermesWorldRouteImport } from './routes/hermes-world'
|
||||
import { Route as FilesRouteImport } from './routes/files'
|
||||
import { Route as EchoStudioRouteImport } from './routes/echo-studio'
|
||||
import { Route as EarlyAccessRouteImport } from './routes/early-access'
|
||||
import { Route as DashboardRouteImport } from './routes/dashboard'
|
||||
import { Route as ConductorRouteImport } from './routes/conductor'
|
||||
@@ -253,6 +254,11 @@ const FilesRoute = FilesRouteImport.update({
|
||||
path: '/files',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const EchoStudioRoute = EchoStudioRouteImport.update({
|
||||
id: '/echo-studio',
|
||||
path: '/echo-studio',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const EarlyAccessRoute = EarlyAccessRouteImport.update({
|
||||
id: '/early-access',
|
||||
path: '/early-access',
|
||||
@@ -974,6 +980,7 @@ export interface FileRoutesByFullPath {
|
||||
'/conductor': typeof ConductorRoute
|
||||
'/dashboard': typeof DashboardRoute
|
||||
'/early-access': typeof EarlyAccessRoute
|
||||
'/echo-studio': typeof EchoStudioRoute
|
||||
'/files': typeof FilesRoute
|
||||
'/hermes-world': typeof HermesWorldRoute
|
||||
'/jobs': typeof JobsRoute
|
||||
@@ -1134,6 +1141,7 @@ export interface FileRoutesByTo {
|
||||
'/conductor': typeof ConductorRoute
|
||||
'/dashboard': typeof DashboardRoute
|
||||
'/early-access': typeof EarlyAccessRoute
|
||||
'/echo-studio': typeof EchoStudioRoute
|
||||
'/files': typeof FilesRoute
|
||||
'/hermes-world': typeof HermesWorldRoute
|
||||
'/jobs': typeof JobsRoute
|
||||
@@ -1294,6 +1302,7 @@ export interface FileRoutesById {
|
||||
'/conductor': typeof ConductorRoute
|
||||
'/dashboard': typeof DashboardRoute
|
||||
'/early-access': typeof EarlyAccessRoute
|
||||
'/echo-studio': typeof EchoStudioRoute
|
||||
'/files': typeof FilesRoute
|
||||
'/hermes-world': typeof HermesWorldRoute
|
||||
'/jobs': typeof JobsRoute
|
||||
@@ -1456,6 +1465,7 @@ export interface FileRouteTypes {
|
||||
| '/conductor'
|
||||
| '/dashboard'
|
||||
| '/early-access'
|
||||
| '/echo-studio'
|
||||
| '/files'
|
||||
| '/hermes-world'
|
||||
| '/jobs'
|
||||
@@ -1616,6 +1626,7 @@ export interface FileRouteTypes {
|
||||
| '/conductor'
|
||||
| '/dashboard'
|
||||
| '/early-access'
|
||||
| '/echo-studio'
|
||||
| '/files'
|
||||
| '/hermes-world'
|
||||
| '/jobs'
|
||||
@@ -1775,6 +1786,7 @@ export interface FileRouteTypes {
|
||||
| '/conductor'
|
||||
| '/dashboard'
|
||||
| '/early-access'
|
||||
| '/echo-studio'
|
||||
| '/files'
|
||||
| '/hermes-world'
|
||||
| '/jobs'
|
||||
@@ -1936,6 +1948,7 @@ export interface RootRouteChildren {
|
||||
ConductorRoute: typeof ConductorRoute
|
||||
DashboardRoute: typeof DashboardRoute
|
||||
EarlyAccessRoute: typeof EarlyAccessRoute
|
||||
EchoStudioRoute: typeof EchoStudioRoute
|
||||
FilesRoute: typeof FilesRoute
|
||||
HermesWorldRoute: typeof HermesWorldRoute
|
||||
JobsRoute: typeof JobsRoute
|
||||
@@ -2181,6 +2194,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof FilesRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/echo-studio': {
|
||||
id: '/echo-studio'
|
||||
path: '/echo-studio'
|
||||
fullPath: '/echo-studio'
|
||||
preLoaderRoute: typeof EchoStudioRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/early-access': {
|
||||
id: '/early-access'
|
||||
path: '/early-access'
|
||||
@@ -3388,6 +3408,7 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
ConductorRoute: ConductorRoute,
|
||||
DashboardRoute: DashboardRoute,
|
||||
EarlyAccessRoute: EarlyAccessRoute,
|
||||
EchoStudioRoute: EchoStudioRoute,
|
||||
FilesRoute: FilesRoute,
|
||||
HermesWorldRoute: HermesWorldRoute,
|
||||
JobsRoute: JobsRoute,
|
||||
|
||||
30
src/routes/echo-studio.tsx
Normal file
30
src/routes/echo-studio.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { usePageTitle } from '@/hooks/use-page-title'
|
||||
import { EchoStudioScreen } from '@/screens/echo-studio/echo-studio-screen'
|
||||
|
||||
export const Route = createFileRoute('/echo-studio')({
|
||||
ssr: false,
|
||||
component: function EchoStudioRoute() {
|
||||
usePageTitle('Echo Studio')
|
||||
return <EchoStudioScreen />
|
||||
},
|
||||
errorComponent: function EchoStudioError({ error }) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center bg-primary-50 p-6 text-center">
|
||||
<h2 className="mb-3 text-xl font-semibold text-primary-900">
|
||||
Failed to Load Echo Studio
|
||||
</h2>
|
||||
<p className="mb-4 max-w-md text-sm text-primary-600">
|
||||
{error instanceof Error ? error.message : 'An unexpected error occurred'}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.location.reload()}
|
||||
className="rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600"
|
||||
>
|
||||
Reload Page
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -845,6 +845,13 @@ function ChatSidebarComponent({
|
||||
label: 'Swarm',
|
||||
active: isSwarmActive,
|
||||
},
|
||||
{
|
||||
kind: 'link',
|
||||
to: '/echo-studio',
|
||||
icon: DashboardSquare01Icon,
|
||||
label: 'Echo Studio',
|
||||
active: pathname.startsWith('/echo-studio'),
|
||||
},
|
||||
|
||||
]
|
||||
|
||||
|
||||
235
src/screens/echo-studio/echo-studio-screen.tsx
Normal file
235
src/screens/echo-studio/echo-studio-screen.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type Tab = 'create' | 'manage' | 'theme'
|
||||
|
||||
const QUICK_TEMPLATES = [
|
||||
{ id: 'analytics', label: 'Analytics Dashboard', icon: '</>' },
|
||||
{ id: 'system', label: 'System Monitor', icon: '☰' },
|
||||
{ id: 'chat', label: 'Chat Analytics', icon: '💬' },
|
||||
]
|
||||
|
||||
const DEFAULT_PROMPT =
|
||||
"Describe the UI you want: charts, tables, KPIs, filters, real-time updates... Example: 'A dashboard with a line graph showing tool usage over time, a period selector (week/month), top 3 KPI cards for most used tools, a live counter for active calls, and a detailed table below with project, tool name, count, date, and status columns.'"
|
||||
|
||||
export function EchoStudioScreen() {
|
||||
const [tab, setTab] = useState<Tab>('create')
|
||||
const [pageId, setPageId] = useState('')
|
||||
const [pageTitle, setPageTitle] = useState('')
|
||||
const [prompt, setPrompt] = useState('')
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [screensCreated, setScreensCreated] = useState(0)
|
||||
const [widgetsActive, setWidgetsActive] = useState(0)
|
||||
const [apiEndpoints, setApiEndpoints] = useState(0)
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!pageId.trim() || !pageTitle.trim() || !prompt.trim()) return
|
||||
setCreating(true)
|
||||
// Simulate creation — in production this would call the backend
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000))
|
||||
setScreensCreated((c) => c + 1)
|
||||
setApiEndpoints((c) => c + 1)
|
||||
setPageId('')
|
||||
setPageTitle('')
|
||||
setPrompt('')
|
||||
setCreating(false)
|
||||
}
|
||||
|
||||
const handleTemplate = (id: string) => {
|
||||
const templates: Record<string, { id: string; title: string; prompt: string }> = {
|
||||
analytics: {
|
||||
id: 'tool-analytics',
|
||||
title: 'Tool Analytics',
|
||||
prompt:
|
||||
'A dashboard with a line graph showing tool usage over time, a period selector (week/month), top 3 KPI cards for most used tools, a live counter for active calls, and a detailed table below with project, tool name, count, date, and status columns.',
|
||||
},
|
||||
system: {
|
||||
id: 'system-monitor',
|
||||
title: 'System Monitor',
|
||||
prompt:
|
||||
'A system monitoring dashboard with CPU/RAM/Disk gauges, a real-time process list, uptime counter, and alert history table. Include a dark theme and auto-refresh every 30 seconds.',
|
||||
},
|
||||
chat: {
|
||||
id: 'chat-analytics',
|
||||
title: 'Chat Analytics',
|
||||
prompt:
|
||||
'A chat analytics dashboard showing messages per day as a bar chart, top users table, average response time trend, sentiment breakdown pie chart, and a searchable message log.',
|
||||
},
|
||||
}
|
||||
const t = templates[id]
|
||||
if (t) {
|
||||
setPageId(t.id)
|
||||
setPageTitle(t.title)
|
||||
setPrompt(t.prompt)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-full overflow-y-auto bg-surface text-ink">
|
||||
<div className="mx-auto w-full max-w-[1200px] px-4 py-6 sm:px-6 lg:px-8">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Echo Studio</h1>
|
||||
<p className="mt-1 text-sm text-primary-500">
|
||||
Describe what you want. I'll build the full page with backend API.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="mb-6 flex gap-1 rounded-lg border border-primary-200 bg-primary-50/85 p-1 backdrop-blur-xl">
|
||||
{(['create', 'manage', 'theme'] as Tab[]).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
className={cn(
|
||||
'flex-1 rounded-md px-4 py-2 text-sm font-medium capitalize transition-colors',
|
||||
tab === t
|
||||
? 'bg-primary-100 text-ink shadow-sm dark:bg-neutral-800'
|
||||
: 'text-primary-500 hover:text-ink',
|
||||
)}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Create Tab */}
|
||||
{tab === 'create' && (
|
||||
<div className="space-y-6">
|
||||
{/* Form */}
|
||||
<div className="rounded-2xl border border-primary-200 bg-primary-50/50 p-6">
|
||||
<div className="space-y-5">
|
||||
{/* Page ID */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-[11px] font-semibold uppercase tracking-[0.12em] text-primary-500">
|
||||
Page ID (URL Slug)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={pageId}
|
||||
onChange={(e) => setPageId(e.target.value)}
|
||||
placeholder="e.g. tool-analytics"
|
||||
className="w-full rounded-xl border border-primary-200 bg-white px-4 py-2.5 text-sm text-ink outline-none transition-colors placeholder:text-primary-400 focus:border-accent-500 dark:bg-neutral-900"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Page Title */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-[11px] font-semibold uppercase tracking-[0.12em] text-primary-500">
|
||||
Page Title
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={pageTitle}
|
||||
onChange={(e) => setPageTitle(e.target.value)}
|
||||
placeholder="e.g. Tool Analytics"
|
||||
className="w-full rounded-xl border border-primary-200 bg-white px-4 py-2.5 text-sm text-ink outline-none transition-colors placeholder:text-primary-400 focus:border-accent-500 dark:bg-neutral-900"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Prompt */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-[11px] font-semibold uppercase tracking-[0.12em] text-primary-500">
|
||||
What should this page do?
|
||||
</label>
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder={DEFAULT_PROMPT}
|
||||
rows={5}
|
||||
className="w-full resize-y rounded-xl border border-primary-200 bg-white px-4 py-2.5 text-sm text-ink outline-none transition-colors placeholder:text-primary-400 focus:border-accent-500 dark:bg-neutral-900"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Create Button */}
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCreate}
|
||||
disabled={!pageId.trim() || !pageTitle.trim() || !prompt.trim() || creating}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 rounded-xl px-5 py-2.5 text-sm font-semibold text-white transition-all',
|
||||
creating || !pageId.trim() || !pageTitle.trim() || !prompt.trim()
|
||||
? 'cursor-not-allowed bg-primary-300 opacity-60'
|
||||
: 'bg-accent-500 hover:bg-accent-600 active:scale-[0.98]',
|
||||
)}
|
||||
>
|
||||
{creating ? (
|
||||
<>
|
||||
<span className="inline-block size-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>✨</span>
|
||||
Create Full Page + API
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Templates */}
|
||||
<div>
|
||||
<h2 className="mb-3 text-[11px] font-semibold uppercase tracking-[0.12em] text-primary-500">
|
||||
Quick Templates
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{QUICK_TEMPLATES.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
onClick={() => handleTemplate(t.id)}
|
||||
className="inline-flex items-center gap-2 rounded-xl border border-primary-200 bg-primary-50/50 px-4 py-2.5 text-sm font-medium text-ink transition-colors hover:border-accent-500 hover:bg-accent-50/50 dark:hover:bg-accent-900/20"
|
||||
>
|
||||
<span className="text-base">{t.icon}</span>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<StatCard label="Screens Created" value={screensCreated} />
|
||||
<StatCard label="Widgets Active" value={widgetsActive} />
|
||||
<StatCard label="API Endpoints" value={apiEndpoints} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Manage Tab */}
|
||||
{tab === 'manage' && (
|
||||
<div className="rounded-2xl border border-primary-200 bg-primary-50/50 p-8 text-center">
|
||||
<p className="text-lg text-primary-500">No screens created yet.</p>
|
||||
<p className="mt-1 text-sm text-primary-400">
|
||||
Use the Create tab to build your first dashboard.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Theme Tab */}
|
||||
{tab === 'theme' && (
|
||||
<div className="rounded-2xl border border-primary-200 bg-primary-50/50 p-8 text-center">
|
||||
<p className="text-lg text-primary-500">Theme customization coming soon.</p>
|
||||
<p className="mt-1 text-sm text-primary-400">
|
||||
Choose from light, dark, and custom color schemes for your dashboards.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatCard({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-primary-200 bg-primary-50/50 p-4">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-primary-500">
|
||||
{label}
|
||||
</p>
|
||||
<p className="mt-1 text-2xl font-semibold tracking-tight text-ink">{value}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user