crimata-os/ui/js/os.js
Andrew Gundersen 31b5e321a1 Add crimata-agent and redesign agentic OS UI
- crimata-agent (TypeScript, port 7702): WebSocket server + HTTP /events
  endpoint, Claude tool-use loop (render/remove/call_api/show_cursor_response),
  session.start fires on first browser connection to seed the canvas
- OS UI redesigned: light grey canvas (#e8e8e8), no dock bar, cursor dot,
  Cmd+Space input bubble above cursor, elements placed by agent
- Canvas elements: app_icon (floating icons with running dot), window (no-chrome
  content cards), cursor_response (text + action pills)
- Component system: HTML fragments fetched from /components/{app}/{name}.html,
  inline scripts re-executed after injection
- contacts/crimata.json: added icon, defaultComponent, components[], api[]
- dock: apps.h/c/main.c extended to parse and serve components + api from
  crimata.json; BUF_SIZE increased for richer JSON output
- ui/components/contacts/list.html + card.html: first component pair
- provision.sh: installs crimata-agent, adds WebSocket + /events + /components
  nginx locations, fixes /auth/ prefix stripping

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 15:52:05 -05:00

313 lines
9.9 KiB
JavaScript

/* ── Canvas / WebSocket layer ───────────────────────────────────────────────
Manages the agent WebSocket connection and renders canvas elements.
This layer is pure vanilla JS — Alpine handles auth + cursor input.
─────────────────────────────────────────────────────────────────────────── */
let ws = null
let canvasDiv = null
const elMap = {} // id → DOM element
function connectWS() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws'
ws = new WebSocket(`${proto}://${location.host}/ws`)
ws.onopen = () => console.log('agent connected')
ws.onmessage = (e) => {
const msg = JSON.parse(e.data)
switch (msg.op) {
case 'render': renderEl(msg.element); break
case 'remove': removeEl(msg.id); break
case 'canvas': msg.elements.forEach(renderEl); break
case 'cursor_response': showCursorResponse(msg); break
}
}
ws.onclose = () => {
console.log('agent disconnected — reconnecting in 2s')
setTimeout(connectWS, 2000)
}
}
function sendEvent(event) {
if (ws && ws.readyState === WebSocket.OPEN)
ws.send(JSON.stringify(event))
}
/* ── Element lifecycle ────────────────────────────────────────────────────── */
function removeEl(id) {
const div = elMap[id]
if (div) { div.remove(); delete elMap[id] }
}
function renderEl(el) {
removeEl(el.id)
const div = document.createElement('div')
div.dataset.elId = el.id
div.classList.add('canvas-element', el.type.replace(/_/g, '-'))
div.style.left = el.x + 'px'
div.style.top = el.y + 'px'
if (el.type === 'app_icon') {
buildAppIcon(div, el)
} else if (el.type === 'window') {
buildWindow(div, el)
}
canvasDiv.appendChild(div)
elMap[el.id] = div
makeDraggable(div, el)
}
/* ── App icon ─────────────────────────────────────────────────────────────── */
function buildAppIcon(div, el) {
const icon = el.props?.icon || '📦'
const label = el.props?.label || el.props?.name || el.component
const dot = el.running ? '<div class="icon-dot"></div>' : ''
div.innerHTML = `
<div class="icon-glyph">${icon}</div>
<div class="icon-label">${label}</div>
${dot}
`
div.addEventListener('click', () => {
sendEvent({
type: 'app_icon.clicked',
data: { id: el.id, component: el.component },
position: { x: el.x, y: el.y },
})
})
}
/* ── Window (content card) ────────────────────────────────────────────────── */
function buildWindow(div, el) {
const content = document.createElement('div')
content.className = 'window-content'
content.innerHTML = '<div class="loading">Loading…</div>'
div.appendChild(content)
// Apply explicit size if provided
if (el.props?.width) div.style.width = el.props.width + 'px'
if (el.props?.height) div.style.height = el.props.height + 'px'
const parts = el.component.split('.')
const appId = parts[0]
const compName = parts[1] || parts[0]
const url = `/components/${appId}/${compName}.html`
fetch(url)
.then(r => r.ok ? r.text() : Promise.reject(r.status))
.then(html => {
content.innerHTML = html
// Make props available to component scripts via window scope
window.__componentProps = el.props || {}
// Re-execute inline scripts (innerHTML doesn't run scripts)
content.querySelectorAll('script').forEach(old => {
const s = document.createElement('script')
s.textContent = old.textContent
old.replaceWith(s)
})
})
.catch(() => {
content.innerHTML = `<div class="loading">${el.component}</div>`
})
// Forward data-event button clicks to agent
div.addEventListener('click', (e) => {
const btn = e.target.closest('button[data-event]')
if (btn) {
sendEvent({
type: 'button.clicked',
data: { window: el.id, event: btn.dataset.event, value: btn.dataset.value },
})
}
})
}
/* ── Cursor response ──────────────────────────────────────────────────────── */
function showCursorResponse(msg) {
removeEl('cursor_response')
const div = document.createElement('div')
div.dataset.elId = 'cursor_response'
div.classList.add('canvas-element', 'cursor-response')
div.style.left = msg.position.x + 'px'
div.style.top = msg.position.y + 'px'
let html = ''
if (msg.text) html += `<p>${msg.text}</p>`
if (msg.pills?.length) {
html += '<div class="response-pills">'
msg.pills.forEach(p => { html += `<button data-pill="${p}">${p}</button>` })
html += '</div>'
}
div.innerHTML = html
div.querySelectorAll('[data-pill]').forEach(btn => {
btn.addEventListener('click', () => {
sendEvent({ type: 'pill.clicked', data: { text: btn.dataset.pill } })
removeEl('cursor_response')
})
})
// Dismiss on outside click
const dismiss = (e) => {
if (!div.contains(e.target)) {
removeEl('cursor_response')
document.removeEventListener('click', dismiss)
}
}
setTimeout(() => document.addEventListener('click', dismiss), 50)
canvasDiv.appendChild(div)
elMap['cursor_response'] = div
}
/* ── Drag ─────────────────────────────────────────────────────────────────── */
function makeDraggable(div, el) {
let startX, startY, startL, startT
const handle = div // drag from anywhere on element
handle.addEventListener('mousedown', (e) => {
// Don't initiate drag from interactive elements inside windows
if (el.type === 'window' && (
e.target.tagName === 'BUTTON' ||
e.target.tagName === 'INPUT' ||
e.target.tagName === 'A' ||
e.target.tagName === 'SELECT'
)) return
e.preventDefault()
startX = e.clientX
startY = e.clientY
startL = parseInt(div.style.left) || 0
startT = parseInt(div.style.top) || 0
const onMove = (e) => {
div.style.left = (startL + e.clientX - startX) + 'px'
div.style.top = (startT + e.clientY - startY) + 'px'
}
const onUp = () => {
window.removeEventListener('mousemove', onMove)
window.removeEventListener('mouseup', onUp)
}
window.addEventListener('mousemove', onMove)
window.addEventListener('mouseup', onUp)
})
}
/* ── Alpine component (auth + cursor input) ─────────────────────────────────
Keeps Alpine scope minimal — just auth state and cursor input.
─────────────────────────────────────────────────────────────────────────── */
const cursorDot = document.getElementById('cursor-dot')
document.addEventListener('alpine:init', () => {
Alpine.data('os', () => ({
// Auth
authed: false,
loginUsername: '',
loginPassword: '',
loginError: '',
// Cursor input
inputMode: false,
cursorQuery: '',
cursorX: 0,
cursorY: 0,
trackCursor(e) {
this.cursorX = e.clientX
this.cursorY = e.clientY
if (cursorDot) {
cursorDot.style.left = e.clientX + 'px'
cursorDot.style.top = e.clientY + 'px'
}
},
async init() {
canvasDiv = document.getElementById('canvas')
await this.checkAuth()
if (this.authed) connectWS()
},
async checkAuth() {
const token = sessionStorage.getItem('auth_token')
if (!token) return
try {
const res = await fetch('/auth/me', {
headers: { Authorization: `Bearer ${token}` },
})
if (res.ok) {
this.authed = true
} else {
sessionStorage.removeItem('auth_token')
}
} catch (_) { /* auth service down */ }
},
async submitLogin() {
this.loginError = ''
try {
const res = await fetch('/auth/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: this.loginUsername,
password: this.loginPassword,
}),
})
const data = await res.json()
if (!res.ok || !data.success) {
this.loginError = data.error || 'Login failed'
return
}
sessionStorage.setItem('auth_token', data.token)
this.loginPassword = ''
this.authed = true
connectWS()
} catch (_) {
this.loginError = 'Auth service unreachable'
}
},
handleKeydown(e) {
if ((e.metaKey || e.ctrlKey) && e.code === 'Space') {
e.preventDefault()
this.inputMode ? this.exitInputMode() : this.enterInputMode()
}
},
enterInputMode() {
this.inputMode = true
this.cursorQuery = ''
this.$nextTick(() => this.$refs.cursorInput?.focus())
},
exitInputMode() {
this.inputMode = false
this.cursorQuery = ''
},
submitQuery() {
const text = this.cursorQuery.trim()
if (!text) { this.exitInputMode(); return }
sendEvent({
type: 'cursor.input',
text,
position: { x: this.cursorX, y: this.cursorY },
})
this.exitInputMode()
},
}))
})