initial agent-mgr: app builder platform MVP

Go API server + Preact UI + Claude Code adapter.
- App-centric model (ideas, not repos)
- AgentProvider interface for multi-agent support
- K8s pod lifecycle for sandboxed agent sessions
- Gitea integration (create repos, push branches)
- WebSocket streaming for live session output
- Woodpecker CI/CD pipelines (kaniko build + kubectl deploy)
This commit is contained in:
Steven Hooker
2026-02-18 15:56:32 +01:00
commit e5b07cc1d8
39 changed files with 3120 additions and 0 deletions

24
ui/dist/index.html vendored Normal file
View File

@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>asp.now</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0a0a0a; --surface: #141414; --surface-2: #1e1e1e;
--border: #2a2a2a; --text: #e5e5e5; --text-dim: #888;
--accent: #6366f1; --accent-hover: #818cf8;
--green: #22c55e; --yellow: #eab308; --red: #ef4444;
}
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: var(--bg); color: var(--text); line-height: 1.5; }
a { color: var(--accent); text-decoration: none; }
a:hover { color: var(--accent-hover); }
</style>
</head>
<body>
<div id="app"></div>
<script src="/index.js"></script>
</body>
</html>

17
ui/embed.go Normal file
View File

@@ -0,0 +1,17 @@
package ui
import (
"embed"
"io/fs"
)
//go:embed dist/*
var distFS embed.FS
func StaticFS() fs.FS {
sub, err := fs.Sub(distFS, "dist")
if err != nil {
return nil
}
return sub
}

16
ui/package.json Normal file
View File

@@ -0,0 +1,16 @@
{
"name": "agent-mgr-ui",
"private": true,
"scripts": {
"dev": "esbuild src/index.tsx --bundle --outdir=dist --servedir=dist --loader:.tsx=tsx --loader:.ts=ts --jsx=automatic --jsx-import-source=preact --define:process.env.NODE_ENV=\\\"development\\\"",
"build": "esbuild src/index.tsx --bundle --outdir=dist --minify --loader:.tsx=tsx --loader:.ts=ts --jsx=automatic --jsx-import-source=preact --define:process.env.NODE_ENV=\\\"production\\\""
},
"dependencies": {
"preact": "^10.25.0",
"preact-router": "^4.1.2"
},
"devDependencies": {
"esbuild": "^0.24.0",
"typescript": "^5.7.0"
}
}

97
ui/src/api.ts Normal file
View File

@@ -0,0 +1,97 @@
const BASE = '/api/v1';
async function request<T>(path: string, opts?: RequestInit): Promise<T> {
const res = await fetch(BASE + path, {
headers: { 'Content-Type': 'application/json' },
...opts,
});
if (!res.ok) {
const body = await res.text();
throw new Error(`${res.status}: ${body}`);
}
if (res.status === 204) return {} as T;
return res.json();
}
export interface App {
id: string;
name: string;
description: string;
status: string;
repo_owner: string;
repo_name: string;
preview_url: string;
created_at: string;
updated_at: string;
}
export interface Session {
id: string;
app_id: string;
provider: string;
status: string;
prompt: string;
branch: string;
pod_name: string;
created_at: string;
updated_at: string;
}
export interface Provider {
name: string;
display_name: string;
description: string;
capabilities: { name: string; description: string }[];
}
export const api = {
createApp(name: string, description: string) {
return request<{ app: App; session: Session }>('/apps', {
method: 'POST',
body: JSON.stringify({ name, description }),
});
},
listApps() {
return request<{ apps: App[] }>('/apps');
},
getApp(id: string) {
return request<{ app: App; sessions: Session[] }>(`/apps/${id}`);
},
deleteApp(id: string) {
return request<{}>(`/apps/${id}`, { method: 'DELETE' });
},
createSession(appId: string, prompt: string) {
return request<{ session: Session }>(`/apps/${appId}/sessions`, {
method: 'POST',
body: JSON.stringify({ prompt }),
});
},
getSession(id: string) {
return request<{ session: Session }>(`/sessions/${id}`);
},
stopSession(id: string) {
return request<{}>(`/sessions/${id}/stop`, { method: 'POST' });
},
sendMessage(id: string, message: string) {
return request<{}>(`/sessions/${id}/message`, {
method: 'POST',
body: JSON.stringify({ message }),
});
},
listProviders() {
return request<{ providers: Provider[] }>('/providers');
},
sessionWsUrl(id: string): string {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
return `${proto}//${location.host}${BASE}/sessions/${id}/ws`;
},
};

View File

@@ -0,0 +1,77 @@
import { App } from '../api';
import { route } from 'preact-router';
interface Props {
app: App;
}
const statusColors: Record<string, string> = {
draft: 'var(--text-dim)',
building: 'var(--yellow)',
live: 'var(--green)',
stopped: 'var(--text-dim)',
failed: 'var(--red)',
};
const statusLabels: Record<string, string> = {
draft: 'Draft',
building: 'Building...',
live: 'Live',
stopped: 'Stopped',
failed: 'Failed',
};
export function AppCard({ app }: Props) {
const color = statusColors[app.status] || 'var(--text-dim)';
return (
<div
onClick={() => route(app.status === 'building' ? `/apps/${app.id}/live` : `/apps/${app.id}`)}
style={styles.card}
onMouseEnter={(e) => (e.currentTarget.style.borderColor = 'var(--accent)')}
onMouseLeave={(e) => (e.currentTarget.style.borderColor = 'var(--border)')}
>
<div style={styles.icon}>
{app.name.slice(0, 2).toUpperCase()}
</div>
<div style={styles.name}>{app.name}</div>
<div style={{ ...styles.status, color }}>
{app.status === 'building' || app.status === 'live' ? '\u25CF ' : '\u25CB '}
{statusLabels[app.status] || app.status}
</div>
</div>
);
}
const styles = {
card: {
padding: '20px',
borderRadius: '12px',
border: '1px solid var(--border)',
background: 'var(--surface)',
cursor: 'pointer',
transition: 'border-color 0.15s',
display: 'flex',
flexDirection: 'column' as const,
gap: '8px',
},
icon: {
width: '40px',
height: '40px',
borderRadius: '8px',
background: 'var(--surface-2)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '14px',
fontWeight: '700',
color: 'var(--accent)',
},
name: {
fontSize: '15px',
fontWeight: '600',
},
status: {
fontSize: '13px',
},
};

121
ui/src/components/chat.tsx Normal file
View File

@@ -0,0 +1,121 @@
import { useState, useEffect, useRef } from 'preact/hooks';
interface Message {
from: 'agent' | 'user';
text: string;
}
interface Props {
messages: Message[];
onSend: (msg: string) => void;
disabled?: boolean;
}
export function Chat({ messages, onSend, disabled }: Props) {
const [input, setInput] = useState('');
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages.length]);
const handleSend = (e: Event) => {
e.preventDefault();
if (input.trim() && !disabled) {
onSend(input.trim());
setInput('');
}
};
return (
<div style={styles.container}>
<div style={styles.messages}>
{messages.map((m, i) => (
<div key={i} style={{
...styles.message,
...(m.from === 'user' ? styles.userMessage : styles.agentMessage),
}}>
{m.text}
</div>
))}
<div ref={bottomRef} />
</div>
<form onSubmit={handleSend} style={styles.inputRow}>
<input
type="text"
value={input}
onInput={(e) => setInput((e.target as HTMLInputElement).value)}
placeholder={disabled ? 'Session ended' : 'Send a follow-up...'}
disabled={disabled}
style={styles.input}
/>
<button type="submit" disabled={!input.trim() || disabled} style={{
...styles.sendButton,
opacity: (!input.trim() || disabled) ? 0.5 : 1,
}}>
Send
</button>
</form>
</div>
);
}
const styles = {
container: {
display: 'flex',
flexDirection: 'column' as const,
border: '1px solid var(--border)',
borderRadius: '12px',
background: 'var(--surface)',
overflow: 'hidden',
},
messages: {
flex: 1,
padding: '16px',
maxHeight: '300px',
overflowY: 'auto' as const,
display: 'flex',
flexDirection: 'column' as const,
gap: '8px',
},
message: {
padding: '10px 14px',
borderRadius: '10px',
fontSize: '14px',
lineHeight: '1.5',
maxWidth: '85%',
},
agentMessage: {
background: 'var(--surface-2)',
alignSelf: 'flex-start',
color: 'var(--text)',
},
userMessage: {
background: 'var(--accent)',
alignSelf: 'flex-end',
color: 'white',
},
inputRow: {
display: 'flex',
borderTop: '1px solid var(--border)',
},
input: {
flex: 1,
padding: '12px 16px',
border: 'none',
background: 'transparent',
color: 'var(--text)',
fontSize: '14px',
outline: 'none',
fontFamily: 'inherit',
},
sendButton: {
padding: '12px 20px',
border: 'none',
background: 'var(--accent)',
color: 'white',
fontSize: '14px',
fontWeight: '600',
cursor: 'pointer',
},
};

44
ui/src/components/nav.tsx Normal file
View File

@@ -0,0 +1,44 @@
interface Props {
title?: string;
back?: string;
}
export function Nav({ title, back }: Props) {
return (
<nav style={styles.nav}>
<div style={styles.inner}>
{back ? (
<a href={back} style={styles.backLink}>{'\u2190'} {title || 'Back'}</a>
) : (
<a href="/" style={styles.logo}>asp.now</a>
)}
</div>
</nav>
);
}
const styles = {
nav: {
borderBottom: '1px solid var(--border)',
background: 'var(--surface)',
},
inner: {
maxWidth: '960px',
margin: '0 auto',
padding: '14px 24px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
},
logo: {
fontSize: '18px',
fontWeight: '700',
color: 'var(--text)',
textDecoration: 'none',
},
backLink: {
fontSize: '15px',
color: 'var(--text)',
textDecoration: 'none',
},
};

View File

@@ -0,0 +1,55 @@
interface Milestone {
label: string;
status: 'done' | 'active' | 'pending';
}
interface Props {
milestones: Milestone[];
}
export function Progress({ milestones }: Props) {
return (
<div style={styles.container}>
{milestones.map((m) => (
<div key={m.label} style={styles.item}>
<span style={{ ...styles.icon, color: iconColor(m.status) }}>
{m.status === 'done' ? '\u2705' : m.status === 'active' ? '\u25D0' : '\u25CB'}
</span>
<span style={{
color: m.status === 'pending' ? 'var(--text-dim)' : 'var(--text)',
fontWeight: m.status === 'active' ? '600' : '400',
}}>
{m.label}
</span>
</div>
))}
</div>
);
}
function iconColor(status: string): string {
switch (status) {
case 'done': return 'var(--green)';
case 'active': return 'var(--accent)';
default: return 'var(--text-dim)';
}
}
const styles = {
container: {
display: 'flex',
flexDirection: 'column' as const,
gap: '12px',
},
item: {
display: 'flex',
alignItems: 'center',
gap: '10px',
fontSize: '15px',
},
icon: {
fontSize: '16px',
width: '20px',
textAlign: 'center' as const,
},
};

View File

@@ -0,0 +1,77 @@
import { useState } from 'preact/hooks';
interface Props {
onSubmit: (text: string) => void;
placeholder?: string;
buttonText?: string;
loading?: boolean;
}
export function PromptInput({ onSubmit, placeholder, buttonText, loading }: Props) {
const [text, setText] = useState('');
const handleSubmit = (e: Event) => {
e.preventDefault();
if (text.trim() && !loading) {
onSubmit(text.trim());
setText('');
}
};
return (
<form onSubmit={handleSubmit} style={styles.form}>
<textarea
value={text}
onInput={(e) => setText((e.target as HTMLTextAreaElement).value)}
placeholder={placeholder || 'Describe your app idea...'}
style={styles.textarea}
rows={3}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
handleSubmit(e);
}
}}
/>
<button type="submit" disabled={!text.trim() || loading} style={{
...styles.button,
opacity: (!text.trim() || loading) ? 0.5 : 1,
}}>
{loading ? 'Starting...' : (buttonText || 'Build it')}
</button>
</form>
);
}
const styles = {
form: {
display: 'flex',
flexDirection: 'column' as const,
gap: '12px',
width: '100%',
maxWidth: '640px',
},
textarea: {
width: '100%',
padding: '16px',
borderRadius: '12px',
border: '1px solid var(--border)',
background: 'var(--surface)',
color: 'var(--text)',
fontSize: '16px',
lineHeight: '1.5',
resize: 'vertical' as const,
fontFamily: 'inherit',
outline: 'none',
},
button: {
alignSelf: 'flex-end',
padding: '10px 24px',
borderRadius: '8px',
border: 'none',
background: 'var(--accent)',
color: 'white',
fontSize: '15px',
fontWeight: '600',
cursor: 'pointer',
},
};

21
ui/src/index.tsx Normal file
View File

@@ -0,0 +1,21 @@
import { render } from 'preact';
import Router from 'preact-router';
import { Home } from './pages/home';
import { AppDetail } from './pages/app-detail';
import { Building } from './pages/building';
import { Apps } from './pages/apps';
function App() {
return (
<div style={{ minHeight: '100vh' }}>
<Router>
<Home path="/" />
<Apps path="/apps" />
<AppDetail path="/apps/:id" />
<Building path="/apps/:id/live" />
</Router>
</div>
);
}
render(<App />, document.getElementById('app')!);

264
ui/src/pages/app-detail.tsx Normal file
View File

@@ -0,0 +1,264 @@
import { useState, useEffect } from 'preact/hooks';
import { route } from 'preact-router';
import { api, App, Session } from '../api';
import { PromptInput } from '../components/prompt-input';
import { Nav } from '../components/nav';
interface Props {
id?: string;
}
function timeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
const statusColors: Record<string, string> = {
draft: 'var(--text-dim)',
building: 'var(--yellow)',
live: 'var(--green)',
stopped: 'var(--text-dim)',
failed: 'var(--red)',
};
export function AppDetail({ id }: Props) {
const [app, setApp] = useState<App | null>(null);
const [sessions, setSessions] = useState<Session[]>([]);
const [improving, setImproving] = useState(false);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!id) return;
api.getApp(id).then((r) => {
setApp(r.app);
setSessions(r.sessions);
});
}, [id]);
const handleImprove = async (prompt: string) => {
if (!id) return;
setLoading(true);
try {
await api.createSession(id, prompt);
route(`/apps/${id}/live`);
} catch (e) {
console.error('create session failed', e);
setLoading(false);
}
};
if (!app) {
return (
<div>
<Nav />
<main style={styles.main}>
<div style={styles.loading}>Loading...</div>
</main>
</div>
);
}
const statusColor = statusColors[app.status] || 'var(--text-dim)';
return (
<div>
<Nav title={app.name} back="/apps" />
<main style={styles.main}>
<div style={styles.header}>
<div>
<h1 style={styles.heading}>{app.name}</h1>
<p style={styles.description}>{app.description}</p>
</div>
<span style={{ ...styles.status, color: statusColor }}>
{'\u25CF'} {app.status}
</span>
</div>
<div style={styles.actions}>
{app.preview_url && (
<a href={app.preview_url} target="_blank" rel="noopener" style={styles.primaryButton}>
Open App
</a>
)}
<button onClick={() => setImproving(!improving)} style={styles.secondaryButton}>
{improving ? 'Cancel' : 'Improve this app'}
</button>
</div>
{improving && (
<div style={styles.improveSection}>
<PromptInput
onSubmit={handleImprove}
placeholder="What would you like to change or add?"
buttonText="Start"
loading={loading}
/>
</div>
)}
<div style={styles.historySection}>
<h2 style={styles.sectionTitle}>History</h2>
{sessions.length === 0 ? (
<div style={styles.empty}>No sessions yet.</div>
) : (
<div style={styles.timeline}>
{sessions.map((sess, i) => (
<div key={sess.id} style={styles.timelineItem}>
<div style={styles.timelineDot} />
<div style={styles.timelineContent}>
<div style={styles.timelineHeader}>
<span style={styles.version}>v{sessions.length - i}</span>
<span style={styles.timeAgo}>{timeAgo(sess.created_at)}</span>
</div>
<div style={styles.timelinePrompt}>
{i === sessions.length - 1 ? 'Created app' : `"${sess.prompt}"`}
</div>
<div style={{
...styles.sessionStatus,
color: sess.status === 'completed' ? 'var(--green)' :
sess.status === 'running' ? 'var(--yellow)' :
sess.status === 'failed' ? 'var(--red)' : 'var(--text-dim)',
}}>
{sess.status}
</div>
</div>
</div>
))}
</div>
)}
</div>
</main>
</div>
);
}
const styles = {
main: {
maxWidth: '720px',
margin: '0 auto',
padding: '32px 24px',
},
loading: {
textAlign: 'center' as const,
padding: '60px 0',
color: 'var(--text-dim)',
},
header: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
marginBottom: '24px',
},
heading: {
fontSize: '28px',
fontWeight: '700',
marginBottom: '8px',
},
description: {
color: 'var(--text-dim)',
fontSize: '15px',
},
status: {
fontSize: '14px',
fontWeight: '600',
whiteSpace: 'nowrap' as const,
},
actions: {
display: 'flex',
gap: '12px',
marginBottom: '32px',
},
primaryButton: {
padding: '10px 20px',
borderRadius: '8px',
background: 'var(--accent)',
color: 'white',
fontSize: '14px',
fontWeight: '600',
textDecoration: 'none',
border: 'none',
cursor: 'pointer',
},
secondaryButton: {
padding: '10px 20px',
borderRadius: '8px',
border: '1px solid var(--border)',
background: 'transparent',
color: 'var(--text)',
fontSize: '14px',
cursor: 'pointer',
},
improveSection: {
marginBottom: '32px',
display: 'flex',
justifyContent: 'center',
},
historySection: {
borderTop: '1px solid var(--border)',
paddingTop: '24px',
},
sectionTitle: {
fontSize: '18px',
fontWeight: '600',
marginBottom: '16px',
},
empty: {
color: 'var(--text-dim)',
fontSize: '14px',
},
timeline: {
display: 'flex',
flexDirection: 'column' as const,
gap: '0',
},
timelineItem: {
display: 'flex',
gap: '16px',
paddingBottom: '20px',
paddingLeft: '8px',
borderLeft: '2px solid var(--border)',
position: 'relative' as const,
},
timelineDot: {
width: '10px',
height: '10px',
borderRadius: '50%',
background: 'var(--accent)',
position: 'absolute' as const,
left: '-6px',
top: '4px',
},
timelineContent: {
paddingLeft: '8px',
},
timelineHeader: {
display: 'flex',
gap: '12px',
alignItems: 'center',
marginBottom: '4px',
},
version: {
fontSize: '14px',
fontWeight: '600',
color: 'var(--text)',
},
timeAgo: {
fontSize: '13px',
color: 'var(--text-dim)',
},
timelinePrompt: {
fontSize: '14px',
color: 'var(--text)',
marginBottom: '2px',
},
sessionStatus: {
fontSize: '12px',
fontWeight: '500',
},
};

73
ui/src/pages/apps.tsx Normal file
View File

@@ -0,0 +1,73 @@
import { useState, useEffect } from 'preact/hooks';
import { api, App } from '../api';
import { AppCard } from '../components/app-card';
import { Nav } from '../components/nav';
export function Apps() {
const [apps, setApps] = useState<App[]>([]);
useEffect(() => {
api.listApps().then((r) => setApps(r.apps));
}, []);
return (
<div>
<Nav />
<main style={styles.main}>
<div style={styles.header}>
<h1 style={styles.heading}>Your Apps</h1>
<a href="/" style={styles.newButton}>+ New App</a>
</div>
{apps.length === 0 ? (
<div style={styles.empty}>
No apps yet. <a href="/">Create your first one.</a>
</div>
) : (
<div style={styles.grid}>
{apps.map((app) => (
<AppCard key={app.id} app={app} />
))}
</div>
)}
</main>
</div>
);
}
const styles = {
main: {
maxWidth: '960px',
margin: '0 auto',
padding: '32px 24px',
},
header: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '24px',
},
heading: {
fontSize: '24px',
fontWeight: '700',
},
newButton: {
padding: '8px 16px',
borderRadius: '8px',
background: 'var(--accent)',
color: 'white',
fontSize: '14px',
fontWeight: '600',
textDecoration: 'none',
},
grid: {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
gap: '12px',
},
empty: {
textAlign: 'center' as const,
padding: '60px 0',
color: 'var(--text-dim)',
fontSize: '15px',
},
};

191
ui/src/pages/building.tsx Normal file
View File

@@ -0,0 +1,191 @@
import { useState, useEffect, useRef } from 'preact/hooks';
import { route } from 'preact-router';
import { api, App, Session } from '../api';
import { Progress } from '../components/progress';
import { Chat } from '../components/chat';
import { Nav } from '../components/nav';
interface Milestone {
label: string;
status: 'done' | 'active' | 'pending';
}
interface Message {
from: 'agent' | 'user';
text: string;
}
interface Props {
id?: string;
}
const defaultMilestones: Milestone[] = [
{ label: 'Setting up project', status: 'active' },
{ label: 'Creating data models', status: 'pending' },
{ label: 'Building UI components', status: 'pending' },
{ label: 'Adding styling and polish', status: 'pending' },
{ label: 'Final checks', status: 'pending' },
];
export function Building({ id }: Props) {
const [app, setApp] = useState<App | null>(null);
const [session, setSession] = useState<Session | null>(null);
const [milestones, setMilestones] = useState<Milestone[]>(defaultMilestones);
const [messages, setMessages] = useState<Message[]>([]);
const wsRef = useRef<WebSocket | null>(null);
useEffect(() => {
if (!id) return;
api.getApp(id).then((r) => {
setApp(r.app);
const running = r.sessions.find((s) => s.status === 'running' || s.status === 'pending');
if (running) {
setSession(running);
connectWs(running.id);
} else if (r.sessions.length > 0) {
setSession(r.sessions[0]);
}
});
return () => wsRef.current?.close();
}, [id]);
const connectWs = (sessionId: string) => {
const ws = new WebSocket(api.sessionWsUrl(sessionId));
wsRef.current = ws;
ws.onmessage = (e) => {
const line = e.data as string;
try {
const data = JSON.parse(line);
if (data.type === 'assistant' && data.message?.content) {
for (const block of data.message.content) {
if (block.type === 'text' && block.text) {
setMessages((prev) => [...prev, { from: 'agent', text: block.text }]);
}
}
}
} catch {
// Non-JSON line — might be a status update
if (line.trim()) {
updateMilestones(line);
}
}
};
ws.onclose = () => {
if (id) {
api.getApp(id).then((r) => {
setApp(r.app);
if (r.sessions.length > 0) setSession(r.sessions[0]);
});
}
};
};
const updateMilestones = (line: string) => {
const lower = line.toLowerCase();
setMilestones((prev) => {
const next = [...prev];
let target = 0;
if (lower.includes('model') || lower.includes('schema') || lower.includes('database')) target = 1;
if (lower.includes('component') || lower.includes('page') || lower.includes('route')) target = 2;
if (lower.includes('css') || lower.includes('style') || lower.includes('tailwind')) target = 3;
if (lower.includes('test') || lower.includes('done') || lower.includes('complete')) target = 4;
for (let i = 0; i < next.length; i++) {
if (i < target) next[i] = { ...next[i], status: 'done' };
else if (i === target) next[i] = { ...next[i], status: 'active' };
}
return next;
});
};
const handleSend = async (msg: string) => {
if (!session) return;
setMessages((prev) => [...prev, { from: 'user', text: msg }]);
try {
await api.sendMessage(session.id, msg);
} catch (e) {
console.error('send message failed', e);
}
};
const handleStop = async () => {
if (!session) return;
await api.stopSession(session.id);
route(`/apps/${id}`);
};
const done = session?.status === 'completed' || session?.status === 'failed' || session?.status === 'stopped';
return (
<div>
<Nav title={app?.name || 'Building...'} back={`/apps/${id}`} />
<main style={styles.main}>
<div style={styles.header}>
<h1 style={styles.heading}>
{done ? (session?.status === 'completed' ? 'Build complete!' : 'Build ended') : 'Building your app...'}
</h1>
{!done && (
<button onClick={handleStop} style={styles.stopButton}>Stop</button>
)}
{done && (
<a href={`/apps/${id}`} style={styles.viewButton}>View App</a>
)}
</div>
<div style={styles.content}>
<Progress milestones={milestones} />
<div style={styles.chatSection}>
<Chat messages={messages} onSend={handleSend} disabled={done} />
</div>
</div>
</main>
</div>
);
}
const styles = {
main: {
maxWidth: '720px',
margin: '0 auto',
padding: '32px 24px',
},
header: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '32px',
},
heading: {
fontSize: '24px',
fontWeight: '700',
},
stopButton: {
padding: '8px 16px',
borderRadius: '8px',
border: '1px solid var(--red)',
background: 'transparent',
color: 'var(--red)',
fontSize: '14px',
cursor: 'pointer',
},
viewButton: {
padding: '8px 16px',
borderRadius: '8px',
background: 'var(--accent)',
color: 'white',
fontSize: '14px',
fontWeight: '600',
textDecoration: 'none',
},
content: {
display: 'flex',
flexDirection: 'column' as const,
gap: '32px',
},
chatSection: {
marginTop: '8px',
},
};

109
ui/src/pages/home.tsx Normal file
View File

@@ -0,0 +1,109 @@
import { useState, useEffect } from 'preact/hooks';
import { route } from 'preact-router';
import { api, App } from '../api';
import { PromptInput } from '../components/prompt-input';
import { AppCard } from '../components/app-card';
import { Nav } from '../components/nav';
export function Home() {
const [apps, setApps] = useState<App[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
api.listApps().then((r) => setApps(r.apps));
}, []);
const handleCreate = async (description: string) => {
setLoading(true);
try {
const result = await api.createApp('', description);
route(`/apps/${result.app.id}/live`);
} catch (e) {
console.error('create app failed', e);
setLoading(false);
}
};
return (
<div>
<Nav />
<main style={styles.main}>
<section style={styles.hero}>
<h1 style={styles.heading}>What do you want to build?</h1>
<PromptInput
onSubmit={handleCreate}
placeholder="A todo app with categories, due dates, and a clean minimal design..."
buttonText="Build it"
loading={loading}
/>
<div style={styles.examples}>
Try: &quot;A recipe manager&quot; &middot; &quot;A habit tracker&quot; &middot; &quot;A team standup board&quot;
</div>
</section>
{apps.length > 0 && (
<section style={styles.appsSection}>
<div style={styles.sectionHeader}>
<h2 style={styles.sectionTitle}>Your Apps</h2>
<a href="/apps" style={styles.viewAll}>View all</a>
</div>
<div style={styles.grid}>
{apps.slice(0, 6).map((app) => (
<AppCard key={app.id} app={app} />
))}
</div>
</section>
)}
</main>
</div>
);
}
const styles = {
main: {
maxWidth: '960px',
margin: '0 auto',
padding: '0 24px',
},
hero: {
display: 'flex',
flexDirection: 'column' as const,
alignItems: 'center',
textAlign: 'center' as const,
padding: '80px 0 60px',
gap: '20px',
},
heading: {
fontSize: '36px',
fontWeight: '700',
letterSpacing: '-0.02em',
},
examples: {
fontSize: '14px',
color: 'var(--text-dim)',
},
appsSection: {
paddingBottom: '60px',
},
sectionHeader: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '16px',
borderTop: '1px solid var(--border)',
paddingTop: '24px',
},
sectionTitle: {
fontSize: '18px',
fontWeight: '600',
},
viewAll: {
fontSize: '14px',
color: 'var(--accent)',
},
grid: {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
gap: '12px',
},
};

14
ui/tsconfig.json Normal file
View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"jsxImportSource": "preact",
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src"]
}