diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..97de8f4 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,31 @@ +# Octopli + +Electron + React + TypeScript + Tailwind v4 (electron-forge Vite template). The production UI for Lazuli — renders the HUD, edge glow, stop button, and action log. Subscribes to Lazuli's lifecycle + action events over local WebSocket. + +## Run +`pnpm start` — HMR; edit `src/App.tsx`, window updates instantly. + +## Backend +- PostgREST @ `localhost:3001` +- OpenClaw gateway @ `localhost:18789` + +## Install locally +`pnpm package` produces `out/Octopli-darwin-arm64/`. Copy into `~/Applications/` so it launches from Spotlight/Dock. The `.app` bundle locks while running — quit first: + +```bash +osascript -e 'tell application "Octopli" to quit' 2>/dev/null +trash ~/Applications/Octopli.app 2>/dev/null +cp -R out/Octopli-darwin-arm64/Octopli.app ~/Applications/ +``` + +## Project rules +- Dock icon is `icons/octopli-dock.icns`, wired via `forge.config.ts`. Do not ship Electron's default. +- Aesthetic: dark near-black shell, warm accent, web-native typography — match Claude's desktop app. + +## Worktrees +- `main/` — shared trunk +- `claude/` — Claude on `claude/workspace` +- `codex/` — Codex on `codex/octopli` +- `clif/` — Clif (OpenClaw) on `clif/workspace` — no CLAUDE/AGENTS file + +One agent per worktree. Don't use a sibling worktree as scratch. diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 31cb5d3..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,21 +0,0 @@ -# CLAUDE.md — Octopli - -Electron + React + TypeScript + Tailwind CSS v4, scaffolded via electron-forge Vite template. Same stack as Claude's desktop app. - -## Dev -```bash -pnpm start # HMR dev mode — edit src/App.tsx → window updates instantly -``` - -## Backend -PostgREST @ `localhost:3001`, OpenClaw gateway @ `localhost:18789`. - -## Conventions -- Strict TypeScript. No `any` unless the foreign data genuinely is. -- Tailwind classes over custom CSS. Semantic tokens over raw colors where they exist. -- Match Claude's aesthetic — dark near-black shell, warm accent, web-native typography. - -## Commands -- `pnpm start` — dev with HMR -- `pnpm package` — unpacked app in `out/` -- `pnpm make` — platform installers diff --git a/src/App.tsx b/src/App.tsx index bf46d6d..47a77fc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,12 +1,285 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; + +type ConnectionState = 'searching' | 'connecting' | 'connected' | 'offline'; +type LifecycleState = 'idle' | 'arming' | 'active' | 'waiting_for_user' | 'verifying' | 'cooling' | 'error'; + +type LazuliEvent = + | { type: 'state'; state: LifecycleState; reason?: string; at: number } + | { type: 'action'; tool: string; args: unknown; expected_layer?: number; session_id?: string; at: number } + | { type: 'result'; tool: string; ok: boolean; duration_ms: number; layer_used: number; error_code?: string; at: number } + | { type: 'verify'; passed: boolean; diff_summary?: string; at: number } + | { type: 'log'; level: 'info' | 'warn' | 'error'; message: string; at: number } + | { type: 'layout'; phase: 'snapshot' | 'restore'; windows: number; at: number }; + +interface ChatMessage { + id: string; + kind: 'assistant' | 'debug' | 'error'; + title: string; + body: string; + at: number; +} + +const ACTIVE_STATES = new Set([ + 'arming', + 'active', + 'waiting_for_user', + 'verifying', + 'cooling', +]); + +const STATE_COPY: Record = { + idle: { title: 'Ready', body: 'Lazuli is connected and standing by.' }, + arming: { title: 'Starting desktop control', body: 'I am preparing the desktop and moving Octopli into view.' }, + active: { title: 'Working on your Mac', body: 'I am controlling the desktop now. You can stop me at any time.' }, + waiting_for_user: { title: 'Paused for you', body: 'I noticed physical input and I am waiting until you are done.' }, + verifying: { title: 'Checking the result', body: 'I am wrapping the task and checking the desktop state.' }, + cooling: { title: 'Finishing up', body: 'I am restoring the desktop before handing control back.' }, + error: { title: 'Stopped', body: 'Lazuli hit an error or was interrupted.' }, +}; + +function compactJson(value: unknown): string { + try { + const json = JSON.stringify(value); + if (!json) return ''; + return json.length > 120 ? `${json.slice(0, 117)}...` : json; + } catch { + return '[unserializable args]'; + } +} + +function formatEvent(event: LazuliEvent): ChatMessage { + const id = `${event.at}-${Math.random().toString(36).slice(2)}`; + + if (event.type === 'state') { + const copy = STATE_COPY[event.state]; + return { id, kind: event.state === 'error' ? 'error' : 'assistant', ...copy, at: event.at }; + } + + if (event.type === 'action') { + const layer = event.expected_layer ? `L${event.expected_layer}` : 'layer pending'; + return { + id, + kind: 'debug', + title: `Action: ${event.tool}`, + body: `${layer}${event.session_id ? ` - ${event.session_id.slice(0, 8)}` : ''} ${compactJson(event.args)}`.trim(), + at: event.at, + }; + } + + if (event.type === 'result') { + return { + id, + kind: event.ok ? 'debug' : 'error', + title: event.ok ? `Done: ${event.tool}` : `Failed: ${event.tool}`, + body: `${event.duration_ms}ms - L${event.layer_used}${event.error_code ? ` - ${event.error_code}` : ''}`, + at: event.at, + }; + } + + if (event.type === 'verify') { + return { + id, + kind: event.passed ? 'debug' : 'error', + title: event.passed ? 'Verification passed' : 'Verification drift', + body: event.diff_summary ?? 'Desktop invariants checked.', + at: event.at, + }; + } + + if (event.type === 'layout') { + return { + id, + kind: 'debug', + title: event.phase === 'snapshot' ? 'Saved desktop layout' : 'Restored desktop layout', + body: `${event.windows} window${event.windows === 1 ? '' : 's'}`, + at: event.at, + }; + } + + return { + id, + kind: event.level === 'error' ? 'error' : 'debug', + title: event.level, + body: event.message, + at: event.at, + }; +} + export default function App() { + const socketRef = useRef(null); + const lazuliModeRef = useRef(false); + const [connection, setConnection] = useState('searching'); + const [lifecycle, setLifecycle] = useState('idle'); + const [messages, setMessages] = useState([]); + const [lazuliMode, setLazuliMode] = useState(false); + + const presenceActive = ACTIVE_STATES.has(lifecycle); + const isCompactChat = lazuliMode || presenceActive || lifecycle === 'error'; + const connectionLabel = connection === 'offline' ? 'Lazuli offline' : connection; + const stateLabel = useMemo(() => lifecycle.replaceAll('_', ' '), [lifecycle]); + + useEffect(() => { + let cancelled = false; + let reconnectTimer: number | undefined; + + const addMessage = (message: ChatMessage) => { + setMessages((current) => [...current, message].slice(-80)); + }; + + const enterLazuliMode = async () => { + if (lazuliModeRef.current) return; + lazuliModeRef.current = true; + setLazuliMode(true); + const result = await window.octopli.window.enterLazuliMode(); + if (!result.ok) { + addMessage({ + id: `${Date.now()}-window-enter-error`, + kind: 'error', + title: 'Could not move Octopli', + body: result.error, + at: Date.now(), + }); + } + }; + + const exitLazuliMode = async () => { + if (!lazuliModeRef.current) return; + lazuliModeRef.current = false; + setLazuliMode(false); + const result = await window.octopli.window.exitLazuliMode(); + if (!result.ok) { + addMessage({ + id: `${Date.now()}-window-exit-error`, + kind: 'error', + title: 'Could not restore Octopli', + body: result.error, + at: Date.now(), + }); + } + }; + + const connect = async () => { + if (cancelled) return; + setConnection('searching'); + + const result = await window.octopli.lazuli.getPort(); + if (!result.ok) { + setConnection('offline'); + reconnectTimer = window.setTimeout(connect, 1500); + return; + } + + setConnection('connecting'); + + const ws = new WebSocket(`ws://127.0.0.1:${result.port}/events`); + socketRef.current = ws; + + ws.onopen = () => { + setConnection('connected'); + }; + + ws.onmessage = async (message) => { + try { + const event = JSON.parse(String(message.data)) as LazuliEvent; + addMessage(formatEvent(event)); + + if (event.type === 'state') { + setLifecycle(event.state); + if (event.state === 'arming') await enterLazuliMode(); + if (event.state === 'idle') await exitLazuliMode(); + } + } catch { + addMessage({ + id: `${Date.now()}-bad-event`, + kind: 'error', + title: 'Unreadable Lazuli event', + body: 'Octopli received an event it could not parse.', + at: Date.now(), + }); + } + }; + + ws.onclose = () => { + if (socketRef.current === ws) socketRef.current = null; + if (!cancelled) { + setConnection('offline'); + reconnectTimer = window.setTimeout(connect, 1500); + } + }; + + ws.onerror = () => { + ws.close(); + }; + }; + + connect(); + + return () => { + cancelled = true; + if (reconnectTimer) window.clearTimeout(reconnectTimer); + socketRef.current?.close(); + socketRef.current = null; + void exitLazuliMode(); + }; + }, []); + + const requestStop = () => { + const socket = socketRef.current; + if (!socket || socket.readyState !== WebSocket.OPEN) return; + socket.send(JSON.stringify({ type: 'stop' })); + }; + return ( -
+
diff --git a/src/index.css b/src/index.css index 457ef70..e9b6d63 100644 --- a/src/index.css +++ b/src/index.css @@ -91,3 +91,190 @@ body { border-top-left-radius: var(--sidebar-radius); border-top-right-radius: var(--sidebar-radius); } + +.chat-surface { + position: absolute; + inset: 0; + display: grid; + grid-template-rows: 52px minmax(0, 1fr) 58px; + overflow: hidden; +} + +.chat-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 14px 8px; + -webkit-app-region: drag; +} + +.chat-header p { + margin: 0 0 2px; + color: rgba(255, 255, 255, 0.42); + font: 600 10px/1 system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Text", sans-serif; + letter-spacing: 0; + text-transform: uppercase; +} + +.chat-header h1 { + margin: 0; + color: rgba(255, 255, 255, 0.9); + font: 650 17px/1.1 system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Display", sans-serif; +} + +.connection-pill { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + gap: 7px; + max-width: 148px; + height: 26px; + padding: 0 10px; + border: 1px solid rgba(255, 255, 255, 0.07); + border-radius: 999px; + background: rgba(24, 24, 24, 0.82); + color: rgba(255, 255, 255, 0.72); + font: 500 12px/1 system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Text", sans-serif; + text-transform: capitalize; + -webkit-app-region: no-drag; +} + +.connection-pill span:last-child { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.connection-dot { + flex: 0 0 auto; + width: 6px; + height: 6px; + border-radius: 50%; + background: #737373; +} + +.connection-pill[data-state="connected"] .connection-dot { + background: #3aa7ff; + box-shadow: 0 0 12px rgba(58, 167, 255, 0.8); +} + +.connection-pill[data-state="offline"] .connection-dot { + background: #ef4444; +} + +.message-stream { + display: flex; + flex-direction: column; + gap: 9px; + min-height: 0; + overflow: auto; + padding: 10px 14px 14px; +} + +.message-bubble { + align-self: stretch; + max-width: 100%; + padding: 10px 11px; + border: 1px solid rgba(255, 255, 255, 0.065); + border-radius: 8px; + background: rgba(255, 255, 255, 0.045); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.025); +} + +.message-bubble strong { + display: block; + overflow-wrap: anywhere; + color: rgba(255, 255, 255, 0.9); + font: 650 13px/1.25 system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Text", sans-serif; +} + +.message-bubble span { + display: block; + margin-top: 4px; + overflow-wrap: anywhere; + color: rgba(255, 255, 255, 0.58); + font: 500 12px/1.35 system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Text", sans-serif; +} + +.message-bubble.debug { + background: rgba(58, 167, 255, 0.055); + border-color: rgba(58, 167, 255, 0.11); +} + +.message-bubble.debug strong { + color: rgba(169, 216, 255, 0.96); +} + +.message-bubble.error { + background: rgba(255, 96, 96, 0.075); + border-color: rgba(255, 96, 96, 0.16); +} + +.message-bubble.error strong { + color: rgba(255, 160, 160, 0.98); +} + +.chat-footer { + display: flex; + align-items: center; + justify-content: flex-end; + padding: 10px 14px 14px; + border-top: 1px solid rgba(255, 255, 255, 0.045); +} + +.composer-placeholder { + width: 100%; + height: 34px; + border: 1px solid rgba(255, 255, 255, 0.055); + border-radius: 8px; + background: rgba(255, 255, 255, 0.035); +} + +.stop-button { + height: 32px; + min-width: 78px; + padding: 0 14px; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 7px; + background: rgba(255, 255, 255, 0.08); + color: rgba(255, 255, 255, 0.9); + font: 650 12px/1 system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Text", sans-serif; + -webkit-app-region: no-drag; +} + +.stop-button:not(:disabled):hover { + background: rgba(255, 255, 255, 0.12); +} + +.stop-button:not(:disabled):active { + background: rgba(255, 255, 255, 0.16); +} + +.stop-button:disabled { + cursor: default; + opacity: 0.42; +} + +.app-shell[data-lazuli-mode="true"] { + --shell-pad-x: 0px; + --shell-pad-y: 0px; +} + +.app-shell[data-lazuli-mode="true"] .app-layout { + min-height: 100vh; + grid-template-columns: 0 minmax(0, 1fr); +} + +.app-shell[data-lazuli-mode="true"] .sidebar-panel { + display: none; +} + +.app-shell[data-lazuli-mode="true"] .workspace-canvas { + border-radius: 0; +} + +.app-shell[data-lazuli-mode="true"] .chat-surface { + grid-template-rows: 56px minmax(0, 1fr) 62px; +} diff --git a/src/main.ts b/src/main.ts index 1455050..2762ef2 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,4 +1,6 @@ -import { app, BrowserWindow } from 'electron'; +import { app, BrowserWindow, ipcMain, screen } from 'electron'; +import fs from 'node:fs/promises'; +import os from 'node:os'; import path from 'node:path'; import started from 'electron-squirrel-startup'; @@ -7,8 +9,77 @@ if (started) { app.quit(); } +const lazuliPortFile = path.join(os.homedir(), '.lazuli', 'current-port'); +let mainWindow: BrowserWindow | null = null; +let preLazuliBounds: Electron.Rectangle | null = null; +let preLazuliAlwaysOnTop = false; + +ipcMain.handle('lazuli:get-port', async () => { + try { + const raw = await fs.readFile(lazuliPortFile, 'utf8'); + const port = Number.parseInt(raw.trim(), 10); + if (!Number.isFinite(port)) { + return { ok: false, error: `Invalid Lazuli port file: ${raw.trim()}` }; + } + return { ok: true, port }; + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : String(error), + }; + } +}); + +ipcMain.handle('window:enter-lazuli-mode', () => { + if (!mainWindow || mainWindow.isDestroyed()) { + return { ok: false, error: 'Octopli window is not available' }; + } + + if (!preLazuliBounds) { + preLazuliBounds = mainWindow.getBounds(); + preLazuliAlwaysOnTop = mainWindow.isAlwaysOnTop(); + } + + const { workArea } = screen.getPrimaryDisplay(); + const width = 380; + const height = 520; + const margin = 16; + + mainWindow.setAlwaysOnTop(true, 'floating'); + mainWindow.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); + mainWindow.setBounds( + { + x: Math.round(workArea.x + workArea.width - width - margin), + y: Math.round(workArea.y + margin), + width, + height, + }, + true, + ); + mainWindow.show(); + mainWindow.focus(); + + return { ok: true }; +}); + +ipcMain.handle('window:exit-lazuli-mode', () => { + if (!mainWindow || mainWindow.isDestroyed()) { + return { ok: false, error: 'Octopli window is not available' }; + } + + if (preLazuliBounds) { + mainWindow.setBounds(preLazuliBounds, true); + preLazuliBounds = null; + } + + mainWindow.setAlwaysOnTop(preLazuliAlwaysOnTop); + mainWindow.setVisibleOnAllWorkspaces(false); + + return { ok: true }; +}); + const createWindow = () => { - const mainWindow = new BrowserWindow({ + mainWindow = new BrowserWindow({ width: 800, height: 600, minWidth: 640, diff --git a/src/preload.ts b/src/preload.ts index 5e9d369..f186f02 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -1,2 +1,11 @@ -// See the Electron documentation for details on how to use preload scripts: -// https://www.electronjs.org/docs/latest/tutorial/process-model#preload-scripts +import { contextBridge, ipcRenderer } from 'electron'; + +contextBridge.exposeInMainWorld('octopli', { + lazuli: { + getPort: () => ipcRenderer.invoke('lazuli:get-port'), + }, + window: { + enterLazuliMode: () => ipcRenderer.invoke('window:enter-lazuli-mode'), + exitLazuliMode: () => ipcRenderer.invoke('window:exit-lazuli-mode'), + }, +}); diff --git a/src/types.d.ts b/src/types.d.ts new file mode 100644 index 0000000..5b0c4d8 --- /dev/null +++ b/src/types.d.ts @@ -0,0 +1,23 @@ +export {}; + +type LazuliPortResult = + | { ok: true; port: number } + | { ok: false; error: string }; + +type OctopliWindowResult = + | { ok: true } + | { ok: false; error: string }; + +declare global { + interface Window { + octopli: { + lazuli: { + getPort(): Promise; + }; + window: { + enterLazuliMode(): Promise; + exitLazuliMode(): Promise; + }; + }; + } +}