feat: standalone web UI (React + Vite)

React + TypeScript UI on port 5274. Connects to local daemon via
WebSocket IPC (DaemonAdapter). Full chat layout with sidebar, message
pane, and peer list. Mirrors the TUI feature set.

- src/types.ts        — IPC types mirroring proto.go
- src/adapter/daemon  — WebSocket IPC adapter with auto-reconnect
- src/store/index.ts  — Zustand store; handles all daemon events
- src/pages/          — Onboarding (connect + join) and Chat
- src/components/     — Sidebar, MessagePane, PeerList

Dev: cd web && npm run dev  (port 5274)
Build: cd web && npm run build

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Fredrik Johansson
2026-06-22 21:57:32 +02:00
parent 77830a3b3f
commit ff14e955ea
27 changed files with 3782 additions and 0 deletions

76
web/src/adapter/daemon.ts Normal file
View File

@@ -0,0 +1,76 @@
import type { IpcMessage } from '../types'
type Listener = (msg: IpcMessage) => void
// DaemonAdapter connects to a locally-running waste daemon over WebSocket IPC.
// The daemon must have WS IPC enabled (--ws-port flag).
export class DaemonAdapter {
private ws: WebSocket | null = null
private listeners: Listener[] = []
private url: string
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
status: 'disconnected' | 'connecting' | 'connected' = 'disconnected'
onStatusChange?: (s: DaemonAdapter['status']) => void
constructor(url: string) {
this.url = url
}
connect() {
if (this.ws) return
this.setStatus('connecting')
const ws = new WebSocket(this.url)
this.ws = ws
ws.onopen = () => {
this.setStatus('connected')
}
ws.onmessage = (ev) => {
try {
const msg: IpcMessage = JSON.parse(ev.data)
this.listeners.forEach(l => l(msg))
} catch {
// ignore malformed frames
}
}
ws.onclose = () => {
this.ws = null
this.setStatus('disconnected')
// Reconnect after 2s
this.reconnectTimer = setTimeout(() => this.connect(), 2000)
}
ws.onerror = () => {
ws.close()
}
}
disconnect() {
if (this.reconnectTimer) clearTimeout(this.reconnectTimer)
this.ws?.close()
this.ws = null
this.setStatus('disconnected')
}
send(msg: IpcMessage) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(msg))
}
}
on(listener: Listener) {
this.listeners.push(listener)
return () => {
this.listeners = this.listeners.filter(l => l !== listener)
}
}
private setStatus(s: DaemonAdapter['status']) {
this.status = s
this.onStatusChange?.(s)
}
}