diff --git a/frontend/public/icons.svg b/frontend/public/icons.svg deleted file mode 100644 index e952219..0000000 --- a/frontend/public/icons.svg +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/src/App.css b/frontend/src/App.css index d6c7c9a..97395a9 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1,182 +1,59 @@ -.counter { - font-size: 16px; - padding: 5px 10px; - border-radius: 5px; - color: var(--accent); - background: var(--accent-bg); - border: 2px solid transparent; - transition: border-color 0.3s; - margin-bottom: 24px; - - &:hover { - border-color: var(--accent-border); - } - &:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; - } +.app { + max-width: 56rem; + margin: 0 auto; + padding: var(--space-2xl) var(--space-xl); } -.hero { - position: relative; - - .base, - .framework, - .vite { - inset-inline: 0; - margin: 0 auto; - } - - .base { - width: 170px; - position: relative; - z-index: 0; - } - - .framework, - .vite { - position: absolute; - } - - .framework { - z-index: 1; - top: 34px; - height: 28px; - transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) scale(1.4); - } - - .vite { - z-index: 0; - top: 107px; - height: 26px; - width: auto; - transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) scale(0.8); - } +.app h1 { + margin: 0 0 var(--space-sm); + color: var(--text-primary); } -#center { - display: flex; - flex-direction: column; - gap: 25px; - place-content: center; - place-items: center; - flex-grow: 1; - - @media (max-width: 1024px) { - padding: 32px 20px 24px; - gap: 18px; - } +.app .subtitle { + margin: 0 0 var(--space-2xl); + color: var(--text-secondary); } -#next-steps { - display: flex; - border-top: 1px solid var(--border); - text-align: left; - - & > div { - flex: 1 1 0; - padding: 32px; - @media (max-width: 1024px) { - padding: 24px 20px; - } - } - - .icon { - margin-bottom: 16px; - width: 22px; - height: 22px; - } - - @media (max-width: 1024px) { - flex-direction: column; - text-align: center; - } +.health { + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + padding: var(--space-lg) var(--space-xl); + background: var(--bg-secondary); } -#docs { - border-right: 1px solid var(--border); - - @media (max-width: 1024px) { - border-right: none; - border-bottom: 1px solid var(--border); - } +.health h2 { + margin: 0 0 var(--space-md); + font-size: 1.05rem; + color: var(--text-secondary); } -#next-steps ul { - list-style: none; - padding: 0; - display: flex; - gap: 8px; - margin: 32px 0 0; - - .logo { - height: 18px; - } - - a { - color: var(--text-h); - font-size: 16px; - border-radius: 6px; - background: var(--social-bg); - display: flex; - padding: 6px 12px; - align-items: center; - gap: 8px; - text-decoration: none; - transition: box-shadow 0.3s; - - &:hover { - box-shadow: var(--shadow); - } - .button-icon { - height: 18px; - width: 18px; - } - } - - @media (max-width: 1024px) { - margin-top: 20px; - flex-wrap: wrap; - justify-content: center; - - li { - flex: 1 1 calc(50% - 8px); - } - - a { - width: 100%; - justify-content: center; - box-sizing: border-box; - } - } +.health p { + margin: 0; } -#spacer { - height: 88px; - border-top: 1px solid var(--border); - @media (max-width: 1024px) { - height: 48px; - } +.badge { + display: inline-block; + padding: var(--space-xs) var(--space-md); + border-radius: var(--radius-sm); + font-size: 0.85rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; } -.ticks { - position: relative; - width: 100%; +.badge--ok { + background: var(--accent-success); + color: var(--bg-primary); +} - &::before, - &::after { - content: ''; - position: absolute; - top: -4.5px; - border: 5px solid transparent; - } +.badge--error { + background: var(--accent-error); + color: var(--bg-primary); +} - &::before { - left: 0; - border-left-color: var(--border); - } - &::after { - right: 0; - border-right-color: var(--border); - } +code { + background: var(--bg-tertiary); + padding: 0 var(--space-xs); + border-radius: var(--radius-sm); + font-size: 0.95em; } diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx new file mode 100644 index 0000000..2b328e5 --- /dev/null +++ b/frontend/src/App.test.tsx @@ -0,0 +1,49 @@ +import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import App from './App' + +describe('App health probe', () => { + beforeEach(() => { + vi.restoreAllMocks() + }) + afterEach(() => { + vi.restoreAllMocks() + }) + + it('renders the loading state, then the health badge once /api/v1/health resolves', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ status: 'ok', version: '0.1.0' }), + } as Response) + + render() + + expect(screen.getByRole('status')).toHaveTextContent(/Checking/i) + + await waitFor(() => { + expect(screen.getByTestId('health-ok')).toBeInTheDocument() + }) + expect(screen.getByTestId('health-ok')).toHaveTextContent(/ok/i) + expect(screen.getByTestId('health-ok')).toHaveTextContent(/v0\.1\.0/) + expect(fetchSpy).toHaveBeenCalledWith( + '/api/v1/health', + expect.objectContaining({ signal: expect.any(AbortSignal) }) + ) + }) + + it('renders the error state when /api/v1/health 500s', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ + ok: false, + status: 500, + json: async () => ({}), + } as Response) + + render() + + await waitFor(() => { + expect(screen.getByTestId('health-error')).toBeInTheDocument() + }) + expect(screen.getByTestId('health-error')).toHaveTextContent(/HTTP 500/) + }) +}) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f3d615c..ed2122b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,101 +1,67 @@ -import { useState } from 'react' -import reactLogo from './assets/react.svg' -import viteLogo from './assets/vite.svg' -import heroImg from './assets/hero.png' +import { useEffect, useState } from 'react' import './App.css' +interface HealthResponse { + status: 'ok' + version: string +} + +type FetchState = + | { kind: 'loading' } + | { kind: 'ok'; payload: HealthResponse } + | { kind: 'error'; message: string } + +const HEALTH_URL = '/api/v1/health' + function App() { - const [count, setCount] = useState(0) + const [state, setState] = useState({ kind: 'loading' }) - return ( - <> -
-
- - React logo - Vite logo -
-
-

Get started

-

- Edit src/App.tsx and save to test HMR -

-
- -
+ useEffect(() => { + const controller = new AbortController() + fetch(HEALTH_URL, { signal: controller.signal }) + .then(async (res) => { + if (!res.ok) { + throw new Error(`HTTP ${res.status}`) + } + const data: HealthResponse = await res.json() + setState({ kind: 'ok', payload: data }) + }) + .catch((err: unknown) => { + if (controller.signal.aborted) return + const message = err instanceof Error ? err.message : String(err) + setState({ kind: 'error', message }) + }) -
+ return () => controller.abort() + }, []) -
-
- -

Documentation

-

Your questions, answered

- -
-
- -

Connect with us

-

Join the Vite community

- -
-
+ return ( +
+

harness-python-react

+

+ Production-quality LLM-driven coding harness — Python + React template. +

-
-
- +
+

Backend health

+ {state.kind === 'loading' && ( +

+ Checking {HEALTH_URL}… +

+ )} + {state.kind === 'ok' && ( +

+ {state.payload.status}{' '} + v{state.payload.version} +

+ )} + {state.kind === 'error' && ( +

+ error {state.message} +

+ )} +
+
) } diff --git a/frontend/src/assets/hero.png b/frontend/src/assets/hero.png deleted file mode 100644 index 02251f4..0000000 Binary files a/frontend/src/assets/hero.png and /dev/null differ diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg deleted file mode 100644 index 8e0e0f1..0000000 --- a/frontend/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/frontend/src/assets/vite.svg b/frontend/src/assets/vite.svg deleted file mode 100644 index 5101b67..0000000 --- a/frontend/src/assets/vite.svg +++ /dev/null @@ -1 +0,0 @@ -Vite diff --git a/frontend/src/index.css b/frontend/src/index.css index f12902e..0b51fc6 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,109 +1,32 @@ -:root { - --text: #6b6375; - --text-h: #08060d; - --bg: #fff; - --border: #e5e4e7; - --code-bg: #f4f3ec; - --accent: #aa3bff; - --accent-bg: rgba(170, 59, 255, 0.1); - --accent-border: rgba(170, 59, 255, 0.5); - --social-bg: rgba(244, 243, 236, 0.5); - --shadow: rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; +@import './styles/palette.css'; - --sans: system-ui, 'Segoe UI', Roboto, sans-serif; - --heading: system-ui, 'Segoe UI', Roboto, sans-serif; - --mono: ui-monospace, Consolas, monospace; - - font: 18px/145% var(--sans); - letter-spacing: 0.18px; - color-scheme: light dark; - color: var(--text); - background: var(--bg); - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - - @media (max-width: 1024px) { - font-size: 16px; - } -} - -@media (prefers-color-scheme: dark) { - :root { - --text: #9ca3af; - --text-h: #f3f4f6; - --bg: #16171d; - --border: #2e303a; - --code-bg: #1f2028; - --accent: #c084fc; - --accent-bg: rgba(192, 132, 252, 0.15); - --accent-border: rgba(192, 132, 252, 0.5); - --social-bg: rgba(47, 48, 58, 0.5); - --shadow: rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px; - } - - #social .button-icon { - filter: invert(1) brightness(2); - } -} - -#root { - width: 1126px; - max-width: 100%; - margin: 0 auto; - text-align: center; - border-inline: 1px solid var(--border); - min-height: 100svh; - display: flex; - flex-direction: column; +* { box-sizing: border-box; } +html, body { margin: 0; + padding: 0; + background: var(--bg-primary); + color: var(--text-primary); + font-family: + system-ui, + -apple-system, + 'Segoe UI', + Roboto, + sans-serif; + font-size: 16px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; } -h1, -h2 { - font-family: var(--heading); - font-weight: 500; - color: var(--text-h); -} - -h1 { - font-size: 56px; - letter-spacing: -1.68px; - margin: 32px 0; - @media (max-width: 1024px) { - font-size: 36px; - margin: 20px 0; - } -} -h2 { - font-size: 24px; - line-height: 118%; - letter-spacing: -0.24px; - margin: 0 0 8px; - @media (max-width: 1024px) { - font-size: 20px; - } -} -p { - margin: 0; +a { + color: var(--accent-primary); } code, -.counter { - font-family: var(--mono); - display: inline-flex; - border-radius: 4px; - color: var(--text-h); -} - -code { - font-size: 15px; - line-height: 135%; - padding: 4px 8px; - background: var(--code-bg); +pre { + font-family: 'JetBrains Mono', ui-monospace, 'Cascadia Code', Menlo, monospace; } diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts new file mode 100644 index 0000000..883355f --- /dev/null +++ b/frontend/src/lib/api/client.ts @@ -0,0 +1,128 @@ +/** + * Typed SSE client primitive — POST + ReadableStream consumer. + * + * `EventSource` is GET-only, so any backend that ships a `POST /chat` (or + * any POST that returns text/event-stream) needs this fetch + ReadableStream + * pattern. Parses canonical SSE `event: \ndata: \n\n` blocks, + * normalises CRLF -> LF, and dispatches each block via the `onEvent` + * callback. Caller-supplied AbortSignal cancels the stream. + * + * The function is generic over the parsed event payload type — pass the + * union of event shapes your endpoint emits and TypeScript flows narrowing + * to your `onEvent` handler. + */ + +const BASE = '/api/v1' + +/** Minimum shape every SSE event payload must carry. */ +export interface SseEvent { + /** SSE `event:` field, copied onto the JSON payload if absent. */ + event_type?: string +} + +export interface SendMessageRequest { + session_id: string + message: string +} + +export interface CreateSessionResponse { + session_id: string + created_at: string + message_count: number +} + +export class SseError extends Error { + readonly status?: number + + constructor(message: string, status?: number) { + super(message) + this.name = 'SseError' + this.status = status + } +} + +/** Create a new chat session via the backend's `/sessions` endpoint. */ +export async function createSession(): Promise { + const res = await fetch(`${BASE}/sessions`, { method: 'POST' }) + if (!res.ok) { + throw new SseError(`Failed to create session: ${res.status}`, res.status) + } + return (await res.json()) as CreateSessionResponse +} + +/** + * POST a chat message and consume the SSE stream. + * + * Generic param `TEvent` is the parsed payload shape — typically a + * discriminated union keyed off `event_type`. + */ +export async function sendMessage( + body: SendMessageRequest, + onEvent: (event: TEvent) => void, + signal?: AbortSignal +): Promise { + const res = await fetch(`${BASE}/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal, + }) + + if (!res.ok) { + const detail = await res.text() + throw new SseError(`Chat failed (${res.status}): ${detail}`, res.status) + } + if (!res.body) { + throw new SseError('Chat response has no body — cannot consume SSE stream.') + } + + const reader = res.body.getReader() + const decoder = new TextDecoder() + let buffer = '' + + function processBlock(block: string): void { + if (!block.trim()) return + + let eventType = '' + let dataStr = '' + for (const line of block.split('\n')) { + const trimmed = line.trim() + if (trimmed.startsWith('event:')) { + eventType = trimmed.slice(6).trim() + } else if (trimmed.startsWith('data:')) { + dataStr = trimmed.slice(5).trim() + } + } + if (!dataStr) return + + try { + const data = JSON.parse(dataStr) as TEvent + if (!data.event_type && eventType) { + ;(data as SseEvent).event_type = eventType + } + onEvent(data) + } catch (err) { + // Console-level visibility for malformed payloads — never throw past + // the boundary so a single bad chunk doesn't kill the stream. + console.warn('[SSE] Failed to parse:', dataStr, err) + } + } + + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + buffer = buffer.replace(/\r\n/g, '\n') + + let sepIdx: number + while ((sepIdx = buffer.indexOf('\n\n')) !== -1) { + processBlock(buffer.slice(0, sepIdx)) + buffer = buffer.slice(sepIdx + 2) + } + } + processBlock(buffer) + } finally { + reader.releaseLock() + } +} diff --git a/frontend/src/styles/palette.css b/frontend/src/styles/palette.css new file mode 100644 index 0000000..7429adf --- /dev/null +++ b/frontend/src/styles/palette.css @@ -0,0 +1,64 @@ +/* ========================================================================== + harness-python-react — design tokens + CSS-variable palette: every colour, space, radius, and transition lives + here so a theme swap is a one-file edit. Light is the default; toggle + `data-theme="dark"` on to switch. + ========================================================================== */ + +:root { + /* Surfaces */ + --bg-primary: #ffffff; + --bg-secondary: #f8f9fb; + --bg-tertiary: #f0f1f4; + --bg-surface: #e8eaf0; + + /* Text */ + --text-primary: #111827; + --text-secondary: #4b5563; + --text-muted: #6b7280; + + /* Accent / status */ + --accent-primary: #2563eb; + --accent-success: #16a34a; + --accent-warning: #d97706; + --accent-error: #dc2626; + --accent-reasoning: #7c3aed; + + /* Borders */ + --border-default: #e5e7eb; + --border-active: #2563eb; + + /* Spacing */ + --space-xs: 0.25rem; + --space-sm: 0.5rem; + --space-md: 0.75rem; + --space-lg: 1rem; + --space-xl: 1.5rem; + --space-2xl: 2rem; + + /* Radii */ + --radius-sm: 4px; + --radius-md: 8px; + --radius-lg: 12px; + + /* Transitions */ + --transition-fast: 150ms ease-out; + --transition-normal: 200ms ease-out; +} + +[data-theme='dark'] { + --bg-primary: #0f1117; + --bg-secondary: #1a1d27; + --bg-tertiary: #242836; + --bg-surface: #2e3344; + --text-primary: #f0f1f4; + --text-secondary: #c5c8d2; + --text-muted: #9ba0ab; + --accent-primary: #60a5fa; + --accent-success: #4ade80; + --accent-warning: #fbbf24; + --accent-error: #f87171; + --accent-reasoning: #a78bfa; + --border-default: #2e3344; + --border-active: #60a5fa; +}