Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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.
21 changes: 0 additions & 21 deletions CLAUDE.md

This file was deleted.

275 changes: 274 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
@@ -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<LifecycleState>([
'arming',
'active',
'waiting_for_user',
'verifying',
'cooling',
]);

const STATE_COPY: Record<LifecycleState, { title: string; body: string }> = {
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<WebSocket | null>(null);
const lazuliModeRef = useRef(false);
const [connection, setConnection] = useState<ConnectionState>('searching');
const [lifecycle, setLifecycle] = useState<LifecycleState>('idle');
const [messages, setMessages] = useState<ChatMessage[]>([]);
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 (
<main className="app-shell">
<main className="app-shell" data-lazuli-mode={isCompactChat}>
<div className="app-layout">
<aside className="sidebar-panel" aria-label="Sidebar">
<div className="sidebar-dragbar" aria-hidden="true" />
</aside>
<section className="workspace-canvas" aria-label="Page">
<div className="workspace-dragbar" aria-hidden="true" />
<div className="chat-surface" aria-label="Octopli chat">
<header className="chat-header">
<div>
<p>Octopli</p>
<h1>{isCompactChat ? 'Computer control' : 'Chat'}</h1>
</div>
<div className="connection-pill" data-state={connection}>
<span className="connection-dot" aria-hidden="true" />
<span>{connection === 'connected' ? stateLabel : connectionLabel}</span>
</div>
</header>

<div className="message-stream" aria-live="polite">
{messages.length === 0 ? (
<article className="message-bubble assistant">
<strong>Ready</strong>
<span>Waiting for Lazuli to attach.</span>
</article>
) : (
messages.map((message) => (
<article className={`message-bubble ${message.kind}`} key={message.id}>
<strong>{message.title}</strong>
<span>{message.body}</span>
</article>
))
)}
</div>

<footer className="chat-footer">
{isCompactChat ? (
<button
className="stop-button"
disabled={!presenceActive || connection !== 'connected'}
onClick={requestStop}
type="button"
>
Stop
</button>
) : (
<div className="composer-placeholder" aria-hidden="true" />
)}
</footer>
</div>
</section>
</div>
</main>
Expand Down
Loading
Loading