feat(vela-ui): add voice session shell

Add a minimal UI shell that connects to the gateway WebSocket and exposes developer-visible session state. Align the architecture, protocol, setup, integration, and backlog docs with the current UI increment.
This commit is contained in:
2026-04-08 18:40:45 +02:00
parent fa5a458003
commit 4b11703c93
7 changed files with 317 additions and 20 deletions

View File

@@ -5,5 +5,6 @@ This workspace contains the Vela browser UI as a minimal SvelteKit app.
Current status:
- SvelteKit app boots in the Yarn workspace
- root page shows the initial Vela UI starter screen
- PWA features and voice interaction flows remain future increments
- root page shows a minimal voice-session shell with connect/disconnect controls
- the shell can connect to the gateway `/ws` endpoint and display developer-visible session status
- microphone capture, transcript rendering, and audio playback remain future increments

View File

@@ -2,43 +2,262 @@
<title>Vela UI</title>
<meta
name="description"
content="Minimal starter UI for the Vela voice assistant."
content="Minimal voice-session shell for the Vela voice assistant."
/>
</svelte:head>
<script>
import { onDestroy } from 'svelte';
import {
CLIENT_EVENT_TYPES,
PROTOCOL_PACKAGE_NAME,
SERVER_EVENT_TYPES
SERVER_EVENT_TYPES,
SESSION_STATES,
isMessageEnvelope,
isServerEventType
} from '@vela/protocol';
const appStatus = 'Bootstrapped';
const nextFocus = `Build the voice session shell on top of ${PROTOCOL_PACKAGE_NAME}.`;
const DEFAULT_GATEWAY_PORT = '3001';
const FALLBACK_GATEWAY_URL = `ws://localhost:${DEFAULT_GATEWAY_PORT}/ws`;
const configuredGatewayUrl = import.meta.env.VITE_VELA_GATEWAY_WS_URL;
function resolveGatewayWebSocketUrl() {
if (configuredGatewayUrl) {
return configuredGatewayUrl;
}
if (typeof window === 'undefined') {
return FALLBACK_GATEWAY_URL;
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const isLocalhost = ['localhost', '127.0.0.1'].includes(window.location.hostname);
if (isLocalhost && window.location.port !== DEFAULT_GATEWAY_PORT) {
return `${protocol}//${window.location.hostname}:${DEFAULT_GATEWAY_PORT}/ws`;
}
return `${protocol}//${window.location.host}/ws`;
}
function formatCloseReason(event) {
const reason = event.reason ? ` (${event.reason})` : '';
return `code ${event.code}, clean ${event.wasClean ? 'yes' : 'no'}${reason}`;
}
let gatewayWebSocketUrl = resolveGatewayWebSocketUrl();
let connectionState = 'not connected';
let connectionDetail = 'Socket is idle.';
let gatewaySessionState = 'not received';
let sessionId = 'not assigned';
let lastServerEvent = 'none';
let lastError = 'none';
let lastClose = 'not closed';
let socket = null;
let connectionAttempts = 0;
function clearSocketHandlers(targetSocket) {
targetSocket.onopen = null;
targetSocket.onmessage = null;
targetSocket.onerror = null;
targetSocket.onclose = null;
}
function resetSessionStatus() {
gatewaySessionState = 'not received';
sessionId = 'not assigned';
lastServerEvent = 'none';
}
function connect() {
if (typeof window === 'undefined') {
return;
}
if (socket && (connectionState === 'connecting' || connectionState === 'connected')) {
return;
}
gatewayWebSocketUrl = resolveGatewayWebSocketUrl();
resetSessionStatus();
lastError = 'none';
lastClose = 'not closed';
connectionState = 'connecting';
connectionDetail = 'Opening WebSocket connection to gateway.';
connectionAttempts += 1;
const nextSocket = new WebSocket(gatewayWebSocketUrl);
socket = nextSocket;
nextSocket.onopen = () => {
if (socket !== nextSocket) {
return;
}
connectionState = 'connected';
connectionDetail = 'Gateway WebSocket is open.';
};
nextSocket.onmessage = ({ data }) => {
if (socket !== nextSocket || typeof data !== 'string') {
return;
}
let message;
try {
message = JSON.parse(data);
} catch {
connectionState = 'error';
connectionDetail = 'Received non-JSON message from gateway.';
lastError = 'invalid server message: JSON parse failed';
return;
}
if (!isMessageEnvelope(message) || !isServerEventType(message.type)) {
connectionState = 'error';
connectionDetail = 'Received unsupported message from gateway.';
lastError = 'invalid server message: envelope or event type mismatch';
return;
}
lastServerEvent = message.type;
if (message.type === 'session.ready') {
sessionId = message.payload.sessionId;
return;
}
if (message.type === 'session.state') {
gatewaySessionState = message.payload.value;
return;
}
if (message.type === 'error') {
connectionState = 'error';
connectionDetail = 'Gateway reported a protocol error.';
lastError = `${message.payload.code}: ${message.payload.message}`;
}
};
nextSocket.onerror = () => {
if (socket !== nextSocket) {
return;
}
connectionState = 'error';
connectionDetail = 'Browser reported a WebSocket error.';
lastError = 'browser websocket error';
};
nextSocket.onclose = (event) => {
if (socket !== nextSocket) {
return;
}
lastClose = formatCloseReason(event);
connectionState = connectionState === 'error' ? 'error' : 'disconnected';
connectionDetail = connectionState === 'error'
? 'Socket closed after an error.'
: 'Gateway WebSocket is closed.';
clearSocketHandlers(nextSocket);
socket = null;
};
}
function disconnect() {
if (!socket) {
connectionState = 'disconnected';
connectionDetail = 'No active socket to close.';
return;
}
connectionDetail = 'Closing WebSocket connection.';
if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) {
socket.close(1000, 'client disconnect');
}
}
onDestroy(() => {
if (!socket) {
return;
}
const activeSocket = socket;
clearSocketHandlers(activeSocket);
socket = null;
if (activeSocket.readyState === WebSocket.OPEN || activeSocket.readyState === WebSocket.CONNECTING) {
activeSocket.close(1000, 'page dispose');
}
});
</script>
<div class="page">
<section class="card">
<p class="eyebrow">Vela UI</p>
<h1>Minimal SvelteKit starter</h1>
<h1>Voice session shell</h1>
<p>
This workspace now runs as the browser shell for Vela. The voice controls, transcript, and
streaming session UI will be added in later increments.
This minimal browser shell can connect to the gateway WebSocket and expose developer-visible
session status. Microphone capture, transcript rendering, and audio playback remain future
increments.
</p>
<p class="contract-note">
Shared protocol package loaded with {CLIENT_EVENT_TYPES.length} client event types and
{SERVER_EVENT_TYPES.length} server event types.
{SERVER_EVENT_TYPES.length} server event types across {SESSION_STATES.length} gateway session
states.
</p>
<div class="controls">
<button on:click={connect} disabled={connectionState === 'connecting' || connectionState === 'connected'}>
Connect
</button>
<button on:click={disconnect} disabled={!socket && connectionState !== 'connected' && connectionState !== 'connecting'}>
Disconnect
</button>
</div>
<div class="meta">
<div>
<span>Status</span>
<strong>{appStatus}</strong>
<span>UI connection state</span>
<strong>{connectionState}</strong>
</div>
<div>
<span>Next</span>
<strong>{nextFocus}</strong>
<span>Connection detail</span>
<strong>{connectionDetail}</strong>
</div>
<div>
<span>Gateway WebSocket URL</span>
<strong>{gatewayWebSocketUrl}</strong>
</div>
<div>
<span>Session ID</span>
<strong>{sessionId}</strong>
</div>
<div>
<span>Gateway session state</span>
<strong>{gatewaySessionState}</strong>
</div>
<div>
<span>Last server event</span>
<strong>{lastServerEvent}</strong>
</div>
<div>
<span>Last error</span>
<strong>{lastError}</strong>
</div>
<div>
<span>Last close</span>
<strong>{lastClose}</strong>
</div>
<div>
<span>Connection attempts</span>
<strong>{connectionAttempts}</strong>
</div>
<div>
<span>Protocol package</span>
<strong>{PROTOCOL_PACKAGE_NAME}</strong>
</div>
</div>
</section>
@@ -91,9 +310,32 @@
margin-top: 1rem;
}
.controls {
margin-top: 1.5rem;
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
button {
padding: 0.8rem 1.1rem;
border: 1px solid #2b4a6b;
border-radius: 0.75rem;
background: #12233a;
color: #e6eef8;
font: inherit;
cursor: pointer;
}
button:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.meta {
margin-top: 1.5rem;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
gap: 1rem;
}