βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β β β
β β ββββββββββββββββββββββββ βββββββββββ βββββββ βββ ββββββββ β
β β ββββββββββββββββββββββββ ββββββββββββ βββββββββ ββββββββββββ β
β β ββββββββββββββββββββββ ββββββββ βββββββ ββββββ ββββββ β β
β β ββββββββββββββββββββββ ββββββββ βββββ βββββββββββββ β β
β β ββββββββββββββββββββββββ ββββββββ βββ βββ βββββββββββββββ β
β β ββββββββββββββββββββββββ ββββββββ βββ βββ βββββ ββββββββ β
β β β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β
β The definitive guide to real-time sync with OpenCode's SSE stream β
β β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
- Architecture Overview
- The SSE Stream
- Event Types Reference
- React Implementation
- Binary Search for Performance
- Directory Scoping
- State Management Patterns
- Edge Cases & Gotchas
- Complete Working Example
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β OpenCode Server β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β Instance Bus β β
β β ββββββββββββ ββββββββββββ ββββββββββββ ββββββββββββ β β
β β β Session β β Message β β Part β β Todo β β β
β β β Events β β Events β β Events β β Events β β β
β β ββββββ¬ββββββ ββββββ¬ββββββ ββββββ¬ββββββ ββββββ¬ββββββ β β
β β β β β β β β
β β βββββββββββββββ΄ββββββββββββββ΄ββββββββββββββ β β
β β β β β
β β βΌ β β
β β ββββββββββββββββ β β
β β β GlobalBus β (Node EventEmitter) β β
β β ββββββββ¬ββββββββ β β
β βββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββββββββββββ β
β β β
β βΌ β
β ββββββββββββββββββββ β
β β GET /global/event β (SSE Endpoint) β
β ββββββββββββ¬ββββββββ β
βββββββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββββββββββββββββ
β
βββββββββββββͺββββββββββββ HTTP/SSE
β
βββββββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββββββββββββββββ
β βΌ React Client β
β ββββββββββββββββββββ β
β β SSE Client β (EventSource / fetch) β
β ββββββββββββ¬ββββββββ β
β β β
β βΌ β
β ββββββββββββββββββββ β
β β Event Router β (by directory) β
β ββββββββββββ¬ββββββββ β
β β β
β βββββββββββββββββββββΌββββββββββββββββββββ β
β βΌ βΌ βΌ β
β βββββββββββββββββββ βββββββββββββββββββ βββββββββββββββββββ β
β β /project-a β β /project-b β β global β β
β β Store β β Store β β Store β β
β βββββββββββββββββββ βββββββββββββββββββ βββββββββββββββββββ β
β β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Key Insight: There is ONE SSE stream for ALL directories. Events are tagged with a directory field, and the client routes them to the appropriate store.
GET /global/event
No headers required for the SSE connection itself. The directory header is only needed for REST API calls.
Every SSE message is JSON with this structure:
interface GlobalEvent {
directory: string; // Absolute path OR "global"
payload: Event; // The actual event data
}
interface Event {
type: string; // Event type discriminator
properties: object; // Event-specific data
}From packages/opencode/src/server/server.ts:220-284:
.get("/global/event", async (c) => {
return streamSSE(c, async (stream) => {
// 1. Send initial connection event
stream.writeSSE({
data: JSON.stringify({
payload: {
type: "server.connected",
properties: {},
},
}),
})
// 2. Subscribe to global bus
async function handler(event: any) {
await stream.writeSSE({
data: JSON.stringify(event),
})
}
GlobalBus.on("event", handler)
// 3. Heartbeat every 30s (CRITICAL for mobile browsers)
const heartbeat = setInterval(() => {
stream.writeSSE({
data: JSON.stringify({
payload: {
type: "server.heartbeat",
properties: {},
},
}),
})
}, 30000)
// 4. Cleanup on disconnect
await new Promise<void>((resolve) => {
stream.onAbort(() => {
clearInterval(heartbeat)
GlobalBus.off("event", handler)
resolve()
})
})
})
})| Event Type | Properties | When Fired |
|---|---|---|
session.created |
{ info: Session } |
New session created |
session.updated |
{ info: Session } |
Session metadata changed (title, archived, etc.) |
session.deleted |
{ info: Session } |
Session deleted |
session.diff |
{ sessionID, diff: FileDiff[] } |
File changes in session |
session.status |
{ sessionID, status: SessionStatus } |
Session busy/idle/retry state |
session.error |
{ sessionID?, error } |
Error occurred |
| Event Type | Properties | When Fired |
|---|---|---|
message.updated |
{ info: Message } |
Message created or updated |
message.removed |
{ sessionID, messageID } |
Message deleted |
message.part.updated |
{ part: Part, delta?: string } |
Part created/updated (streaming) |
message.part.removed |
{ sessionID, messageID, partID } |
Part deleted |
| Event Type | Properties | When Fired |
|---|---|---|
todo.updated |
{ sessionID, todos: Todo[] } |
Todo list changed |
project.updated |
Project |
Project metadata changed |
global.disposed |
{} |
Server shutting down |
server.connected |
{} |
Initial connection established |
server.heartbeat |
{} |
Keep-alive (every 30s) |
permission.updated |
Permission |
Permission request pending |
permission.replied |
{ sessionID, permissionID, response } |
Permission answered |
// From @opencode-ai/sdk/v2/client
type Session = {
id: string;
projectID: string;
directory: string;
parentID?: string;
title: string;
version: string;
time: {
created: number;
updated: number;
compacting?: number;
archived?: number; // If set, session is archived
};
summary?: {
additions: number;
deletions: number;
files: number;
diffs?: FileDiff[];
};
share?: { url: string };
revert?: { messageID: string; partID?: string; snapshot?: string; diff?: string };
};
type Message = UserMessage | AssistantMessage;
type UserMessage = {
id: string;
sessionID: string;
role: "user";
time: { created: number };
agent: string;
model: { providerID: string; modelID: string };
system?: string;
tools?: Record<string, boolean>;
};
type AssistantMessage = {
id: string;
sessionID: string;
role: "assistant";
parentID: string;
modelID: string;
providerID: string;
agent: string;
time: { created: number; completed?: number };
error?: ProviderAuthError | UnknownError | MessageOutputLengthError | MessageAbortedError | ApiError;
cost: number;
tokens: {
input: number;
output: number;
reasoning: number;
cache: { read: number; write: number };
};
finish?: string;
};
type Part = TextPart | ReasoningPart | FilePart | ToolPart | StepStartPart | StepFinishPart | ...;
type TextPart = {
id: string;
sessionID: string;
messageID: string;
type: "text";
text: string;
synthetic?: boolean;
ignored?: boolean;
time?: { start: number; end?: number };
};
type ToolPart = {
id: string;
sessionID: string;
messageID: string;
type: "tool";
callID: string;
tool: string;
state: ToolStatePending | ToolStateRunning | ToolStateCompleted | ToolStateError;
};
type SessionStatus =
| { type: "idle" }
| { type: "busy" }
| { type: "retry"; attempt: number; message: string; next: number };// hooks/useSSE.ts
import { useEffect, useRef, useCallback } from "react";
interface SSEOptions {
url: string;
onEvent: (event: GlobalEvent) => void;
onError?: (error: Error) => void;
onConnect?: () => void;
retryDelay?: number;
maxRetries?: number;
}
interface GlobalEvent {
directory: string;
payload: {
type: string;
properties: Record<string, unknown>;
};
}
export function useSSE({
url,
onEvent,
onError,
onConnect,
retryDelay = 3000,
maxRetries = 10,
}: SSEOptions) {
const retryCount = useRef(0);
const abortController = useRef<AbortController | null>(null);
const connect = useCallback(async () => {
// Abort any existing connection
abortController.current?.abort();
abortController.current = new AbortController();
try {
const response = await fetch(`${url}/global/event`, {
signal: abortController.current.signal,
headers: {
Accept: "text/event-stream",
"Cache-Control": "no-cache",
},
});
if (!response.ok) {
throw new Error(
`SSE failed: ${response.status} ${response.statusText}`,
);
}
if (!response.body) {
throw new Error("No body in SSE response");
}
// Reset retry count on successful connection
retryCount.current = 0;
onConnect?.();
const reader = response.body
.pipeThrough(new TextDecoderStream())
.getReader();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += value;
const chunks = buffer.split("\n\n");
buffer = chunks.pop() ?? "";
for (const chunk of chunks) {
const lines = chunk.split("\n");
const dataLines: string[] = [];
for (const line of lines) {
if (line.startsWith("data:")) {
dataLines.push(line.replace(/^data:\s*/, ""));
}
}
if (dataLines.length) {
try {
const data = JSON.parse(dataLines.join("\n")) as GlobalEvent;
onEvent(data);
} catch (e) {
console.error("Failed to parse SSE event:", e);
}
}
}
}
} catch (error) {
if ((error as Error).name === "AbortError") return;
onError?.(error as Error);
// Retry with exponential backoff
if (retryCount.current < maxRetries) {
const backoff = Math.min(retryDelay * 2 ** retryCount.current, 30000);
retryCount.current++;
setTimeout(connect, backoff);
}
}
}, [url, onEvent, onError, onConnect, retryDelay, maxRetries]);
useEffect(() => {
connect();
return () => {
abortController.current?.abort();
};
}, [connect]);
return {
reconnect: connect,
};
}CRITICAL: OpenCode sorts arrays by ID (lexicographic). You MUST use binary search for O(log n) updates.
// utils/binary.ts
export namespace Binary {
export function search<T>(
array: T[],
id: string,
compare: (item: T) => string,
): { found: boolean; index: number } {
let left = 0;
let right = array.length - 1;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
const midId = compare(array[mid]);
if (midId === id) {
return { found: true, index: mid };
} else if (midId < id) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return { found: false, index: left };
}
export function insert<T>(
array: T[],
item: T,
compare: (item: T) => string,
): T[] {
const id = compare(item);
let left = 0;
let right = array.length;
while (left < right) {
const mid = Math.floor((left + right) / 2);
const midId = compare(array[mid]);
if (midId < id) {
left = mid + 1;
} else {
right = mid;
}
}
const result = [...array];
result.splice(left, 0, item);
return result;
}
}// stores/opencode-store.ts
import { create } from "zustand";
import { immer } from "zustand/middleware/immer";
import { Binary } from "../utils/binary";
import type {
Session,
Message,
Part,
SessionStatus,
Todo,
FileDiff,
} from "@opencode-ai/sdk/v2/client";
interface DirectoryState {
ready: boolean;
sessions: Session[];
sessionStatus: Record<string, SessionStatus>;
sessionDiff: Record<string, FileDiff[]>;
todos: Record<string, Todo[]>;
messages: Record<string, Message[]>;
parts: Record<string, Part[]>;
}
interface OpenCodeStore {
directories: Record<string, DirectoryState>;
// Actions
initDirectory: (directory: string) => void;
handleEvent: (
directory: string,
event: { type: string; properties: any },
) => void;
// Session actions
setSessionReady: (directory: string, ready: boolean) => void;
setSessions: (directory: string, sessions: Session[]) => void;
setMessages: (
directory: string,
sessionID: string,
messages: Message[],
) => void;
setParts: (directory: string, messageID: string, parts: Part[]) => void;
}
const createEmptyDirectoryState = (): DirectoryState => ({
ready: false,
sessions: [],
sessionStatus: {},
sessionDiff: {},
todos: {},
messages: {},
parts: {},
});
export const useOpenCodeStore = create<OpenCodeStore>()(
immer((set, get) => ({
directories: {},
initDirectory: (directory) => {
set((state) => {
if (!state.directories[directory]) {
state.directories[directory] = createEmptyDirectoryState();
}
});
},
handleEvent: (directory, event) => {
set((state) => {
// Ensure directory exists
if (!state.directories[directory]) {
state.directories[directory] = createEmptyDirectoryState();
}
const dir = state.directories[directory];
switch (event.type) {
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// SESSION EVENTS
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
case "session.updated": {
const session = event.properties.info as Session;
const result = Binary.search(dir.sessions, session.id, (s) => s.id);
// Handle archived sessions (remove them)
if (session.time.archived) {
if (result.found) {
dir.sessions.splice(result.index, 1);
}
break;
}
// Update or insert
if (result.found) {
dir.sessions[result.index] = session;
} else {
dir.sessions.splice(result.index, 0, session);
}
break;
}
case "session.status": {
dir.sessionStatus[event.properties.sessionID] =
event.properties.status;
break;
}
case "session.diff": {
dir.sessionDiff[event.properties.sessionID] = event.properties.diff;
break;
}
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// MESSAGE EVENTS
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
case "message.updated": {
const message = event.properties.info as Message;
const sessionID = message.sessionID;
// Initialize messages array if needed
if (!dir.messages[sessionID]) {
dir.messages[sessionID] = [];
}
const messages = dir.messages[sessionID];
const result = Binary.search(messages, message.id, (m) => m.id);
if (result.found) {
messages[result.index] = message;
} else {
messages.splice(result.index, 0, message);
}
break;
}
case "message.removed": {
const { sessionID, messageID } = event.properties;
const messages = dir.messages[sessionID];
if (!messages) break;
const result = Binary.search(messages, messageID, (m) => m.id);
if (result.found) {
messages.splice(result.index, 1);
}
break;
}
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// PART EVENTS (streaming content)
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
case "message.part.updated": {
const part = event.properties.part as Part;
const messageID = part.messageID;
// Initialize parts array if needed
if (!dir.parts[messageID]) {
dir.parts[messageID] = [];
}
const parts = dir.parts[messageID];
const result = Binary.search(parts, part.id, (p) => p.id);
if (result.found) {
parts[result.index] = part;
} else {
parts.splice(result.index, 0, part);
}
break;
}
case "message.part.removed": {
const { messageID, partID } = event.properties;
const parts = dir.parts[messageID];
if (!parts) break;
const result = Binary.search(parts, partID, (p) => p.id);
if (result.found) {
parts.splice(result.index, 1);
}
break;
}
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// TODO EVENTS
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
case "todo.updated": {
dir.todos[event.properties.sessionID] = event.properties.todos;
break;
}
}
});
},
setSessionReady: (directory, ready) => {
set((state) => {
if (state.directories[directory]) {
state.directories[directory].ready = ready;
}
});
},
setSessions: (directory, sessions) => {
set((state) => {
if (state.directories[directory]) {
// Sort by ID for binary search
state.directories[directory].sessions = sessions.sort((a, b) =>
a.id.localeCompare(b.id),
);
}
});
},
setMessages: (directory, sessionID, messages) => {
set((state) => {
if (state.directories[directory]) {
// Sort by ID for binary search
state.directories[directory].messages[sessionID] = messages.sort(
(a, b) => a.id.localeCompare(b.id),
);
}
});
},
setParts: (directory, messageID, parts) => {
set((state) => {
if (state.directories[directory]) {
// Sort by ID for binary search
state.directories[directory].parts[messageID] = parts.sort((a, b) =>
a.id.localeCompare(b.id),
);
}
});
},
})),
);// lib/opencode-client.ts
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client";
export function createClient(baseUrl: string, directory?: string) {
return createOpencodeClient({
baseUrl,
directory, // Sets x-opencode-directory header automatically
throwOnError: true,
});
}
// For SSE (no timeout)
export function createEventClient(baseUrl: string) {
return createOpencodeClient({
baseUrl,
// No timeout for SSE - it's a long-lived connection
});
}// providers/OpenCodeProvider.tsx
'use client';
import { createContext, useContext, useEffect, useCallback, useRef, type ReactNode } from 'react';
import { useSSE } from '../hooks/useSSE';
import { useOpenCodeStore } from '../stores/opencode-store';
import { createClient } from '../lib/opencode-client';
interface OpenCodeContextValue {
url: string;
directory: string;
ready: boolean;
sync: (sessionID: string) => Promise<void>;
}
const OpenCodeContext = createContext<OpenCodeContextValue | null>(null);
interface OpenCodeProviderProps {
url: string;
directory: string;
children: ReactNode;
}
export function OpenCodeProvider({ url, directory, children }: OpenCodeProviderProps) {
const store = useOpenCodeStore();
const clientRef = useRef(createClient(url, directory));
// Initialize directory state
useEffect(() => {
store.initDirectory(directory);
}, [directory, store]);
// Handle SSE events
const handleEvent = useCallback((event: { directory: string; payload: any }) => {
const eventDirectory = event.directory;
// Route global events
if (eventDirectory === 'global') {
switch (event.payload?.type) {
case 'global.disposed':
// Server restarted - re-bootstrap
bootstrap();
break;
case 'project.updated':
// Handle project updates if needed
break;
}
return;
}
// Route to correct directory
// CRITICAL: Only process events for OUR directory
if (eventDirectory === directory) {
store.handleEvent(directory, event.payload);
}
}, [directory, store]);
// Bootstrap: Load initial data
const bootstrap = useCallback(async () => {
const client = clientRef.current;
try {
// Load sessions (filtered and sorted)
const sessionsResponse = await client.session.list();
const fourHoursAgo = Date.now() - 4 * 60 * 60 * 1000;
const sessions = (sessionsResponse.data ?? [])
.filter((s) => !s.time.archived)
.sort((a, b) => a.id.localeCompare(b.id))
.filter((s, i) => {
// Include first N sessions + any updated recently
if (i < 20) return true;
return s.time.updated > fourHoursAgo;
});
store.setSessions(directory, sessions);
// Load session statuses
const statusResponse = await client.session.status();
if (statusResponse.data) {
for (const [sessionID, status] of Object.entries(statusResponse.data)) {
store.handleEvent(directory, {
type: 'session.status',
properties: { sessionID, status },
});
}
}
store.setSessionReady(directory, true);
} catch (error) {
console.error('Bootstrap failed:', error);
}
}, [directory, store]);
// Sync a specific session (messages + parts)
const sync = useCallback(async (sessionID: string) => {
const client = clientRef.current;
try {
const [messagesResponse, todoResponse, diffResponse] = await Promise.all([
client.session.messages({ sessionID, limit: 100 }),
client.session.todo({ sessionID }),
client.session.diff({ sessionID }),
]);
// Set messages (sorted by ID)
if (messagesResponse.data) {
const messages = messagesResponse.data.map((m) => m.info);
store.setMessages(directory, sessionID, messages);
// Set parts for each message
for (const msg of messagesResponse.data) {
store.setParts(directory, msg.info.id, msg.parts);
}
}
// Set todos
if (todoResponse.data) {
store.handleEvent(directory, {
type: 'todo.updated',
properties: { sessionID, todos: todoResponse.data },
});
}
// Set diffs
if (diffResponse.data) {
store.handleEvent(directory, {
type: 'session.diff',
properties: { sessionID, diff: diffResponse.data },
});
}
} catch (error) {
console.error('Sync failed:', error);
}
}, [directory, store]);
// Connect SSE
useSSE({
url,
onEvent: handleEvent,
onConnect: bootstrap,
onError: (error) => console.error('SSE error:', error),
});
// Get ready state
const dirState = store.directories[directory];
const ready = dirState?.ready ?? false;
return (
<OpenCodeContext.Provider value={{ url, directory, ready, sync }}>
{children}
</OpenCodeContext.Provider>
);
}
export function useOpenCode() {
const context = useContext(OpenCodeContext);
if (!context) {
throw new Error('useOpenCode must be used within OpenCodeProvider');
}
return context;
}// hooks/useSession.ts
import { useOpenCodeStore } from "../stores/opencode-store";
import { useOpenCode } from "../providers/OpenCodeProvider";
import { useEffect } from "react";
export function useSessions() {
const { directory, ready } = useOpenCode();
const sessions = useOpenCodeStore(
(state) => state.directories[directory]?.sessions ?? [],
);
return { sessions, ready };
}
export function useSession(sessionID: string) {
const { directory, sync } = useOpenCode();
const session = useOpenCodeStore((state) => {
const sessions = state.directories[directory]?.sessions ?? [];
return sessions.find((s) => s.id === sessionID);
});
const status = useOpenCodeStore(
(state) => state.directories[directory]?.sessionStatus[sessionID],
);
// Sync on mount
useEffect(() => {
sync(sessionID);
}, [sessionID, sync]);
return { session, status };
}
export function useMessages(sessionID: string) {
const { directory } = useOpenCode();
return useOpenCodeStore(
(state) => state.directories[directory]?.messages[sessionID] ?? [],
);
}
export function useParts(messageID: string) {
const { directory } = useOpenCode();
return useOpenCodeStore(
(state) => state.directories[directory]?.parts[messageID] ?? [],
);
}
export function useSessionStatus(sessionID: string) {
const { directory } = useOpenCode();
return useOpenCodeStore(
(state) => state.directories[directory]?.sessionStatus[sessionID],
);
}Every REST API call MUST include the directory. The SDK handles this automatically when you pass directory to createOpencodeClient:
// This sets x-opencode-directory header on ALL requests
const client = createOpencodeClient({
baseUrl: "http://localhost:3000",
directory: "/Users/joel/Code/myproject", // EXACT path, no trailing slash
});- Server defaults to
process.cwd()(where server was started) - Events won't match your client's directory
- Sessions from wrong project appear
- Sync completely breaks
// Server event
{ directory: "/Users/joel/Code/myproject", payload: { type: "session.updated", ... } }
// Client routing
if (event.directory === myDirectory) {
// Process event
} else {
// Ignore - not our project
}Case sensitivity matters. /Users/Joel/Code β /Users/joel/Code
OpenCode generates IDs that are lexicographically sortable (like ULIDs). Arrays are always sorted by ID. Binary search gives O(log n) lookups instead of O(n).
// BAD: O(n) - scans entire array
const session = sessions.find((s) => s.id === targetID);
// GOOD: O(log n) - binary search
const result = Binary.search(sessions, targetID, (s) => s.id);
if (result.found) {
const session = sessions[result.index];
}// With Immer (recommended)
set((state) => {
const sessions = state.directories[directory].sessions;
const result = Binary.search(sessions, session.id, (s) => s.id);
if (result.found) {
sessions[result.index] = session; // Direct mutation OK with Immer
} else {
sessions.splice(result.index, 0, session); // Insert at correct position
}
});
// Without Immer (manual immutability)
set((state) => ({
directories: {
...state.directories,
[directory]: {
...state.directories[directory],
sessions: result.found
? sessions.map((s, i) => (i === result.index ? session : s))
: [
...sessions.slice(0, result.index),
session,
...sessions.slice(result.index),
],
},
},
}));When SSE event arrives, don't replace the entire object - merge it:
// SolidJS uses reconcile() - for React, use shallow merge
case 'session.updated': {
const existing = sessions[result.index];
const updated = event.properties.info;
// Shallow merge preserves reference equality for unchanged fields
sessions[result.index] = { ...existing, ...updated };
break;
}Problem: Using Map<string, Message[]> with Zustand's Immer middleware causes "Proxy has already been revoked" errors.
Root Cause: Immer's MapSet plugin wraps Map values in draft proxies that get revoked after the producer function completes. When Binary.insert or spread operators try to access array elements later, the proxy is already revoked.
// β BAD - Will cause proxy errors
type State = {
messages: Map<string, Message[]>;
};
// β
GOOD - Works perfectly with Immer
type State = {
messages: Record<string, Message[]>;
};Migration:
Map.get(k)βrecord[k]Map.set(k, v)βrecord[k] = vmessages.sizeβObject.keys(messages).length
Rule: Use Record<K, V> instead of Map<K, V> for ANY Zustand + Immer store with nested structures.
Problem: User sends message β SSE event arrives before REST response.
Solution: Use optimistic updates + reconciliation:
// 1. Add optimistic message immediately
const optimisticMessage = {
id: generateID(),
sessionID,
role: "user",
// ...
};
store.handleEvent(directory, {
type: "message.updated",
properties: { info: optimisticMessage },
});
// 2. Send to server
await client.session.prompt({ sessionID, parts });
// 3. SSE event will update with real data
// Binary search finds by ID, updates in placeThe SSE hook handles reconnection with exponential backoff. On reconnect:
onConnect: () => {
// Re-bootstrap to catch any missed events
bootstrap();
};Problem: Client loads sessions, then SSE event arrives for a session not in initial load.
Solution: Events create missing items:
case 'session.updated': {
const result = Binary.search(sessions, session.id, s => s.id);
if (!result.found) {
// Insert new session even if not in initial load
sessions.splice(result.index, 0, session);
}
}Each tab has its own SSE connection. All tabs receive same events. State converges naturally.
Mobile browsers (especially WKWebView) kill idle connections. The server sends heartbeats every 30s. If you don't receive a heartbeat for 60s, reconnect.
Parts arrive incrementally during AI response. The delta field indicates streaming:
case 'message.part.updated': {
const { part, delta } = event.properties;
if (delta && part.type === 'text') {
// Streaming text - append delta to existing
const existing = parts[result.index];
if (existing?.type === 'text') {
existing.text += delta;
return;
}
}
// Full update
parts[result.index] = part;
}src/
βββ app/
β βββ layout.tsx
β βββ [directory]/
β βββ page.tsx
βββ components/
β βββ SessionList.tsx
β βββ SessionView.tsx
β βββ MessageList.tsx
βββ hooks/
β βββ useSSE.ts
β βββ useSession.ts
βββ stores/
β βββ opencode-store.ts
βββ providers/
β βββ OpenCodeProvider.tsx
βββ lib/
β βββ opencode-client.ts
βββ utils/
βββ binary.ts
// app/layout.tsx
import { OpenCodeProvider } from '@/providers/OpenCodeProvider';
export default function RootLayout({ children }: { children: React.ReactNode }) {
// In real app, get these from env/config
const url = process.env.NEXT_PUBLIC_OPENCODE_URL ?? 'http://localhost:3000';
const directory = process.env.NEXT_PUBLIC_OPENCODE_DIRECTORY ?? process.cwd();
return (
<html>
<body>
<OpenCodeProvider url={url} directory={directory}>
{children}
</OpenCodeProvider>
</body>
</html>
);
}// components/SessionList.tsx
'use client';
import { useSessions } from '@/hooks/useSession';
import Link from 'next/link';
export function SessionList() {
const { sessions, ready } = useSessions();
if (!ready) {
return <div>Loading sessions...</div>;
}
return (
<ul>
{sessions.map((session) => (
<li key={session.id}>
<Link href={`/session/${session.id}`}>
{session.title || 'Untitled Session'}
</Link>
<span className="text-muted">
{new Date(session.time.updated).toLocaleDateString()}
</span>
</li>
))}
</ul>
);
}// components/SessionView.tsx
'use client';
import { useSession, useMessages } from '@/hooks/useSession';
import { MessageList } from './MessageList';
interface SessionViewProps {
sessionID: string;
}
export function SessionView({ sessionID }: SessionViewProps) {
const { session, status } = useSession(sessionID);
const messages = useMessages(sessionID);
if (!session) {
return <div>Loading session...</div>;
}
return (
<div>
<header>
<h1>{session.title}</h1>
{status?.type === 'busy' && <span>AI is thinking...</span>}
{status?.type === 'retry' && (
<span>Retrying... attempt {status.attempt}</span>
)}
</header>
<MessageList messages={messages} />
</div>
);
}// components/MessageList.tsx
'use client';
import { useParts } from '@/hooks/useSession';
import type { Message, Part } from '@opencode-ai/sdk/v2/client';
interface MessageListProps {
messages: Message[];
}
export function MessageList({ messages }: MessageListProps) {
return (
<div className="space-y-4">
{messages.map((message) => (
<MessageItem key={message.id} message={message} />
))}
</div>
);
}
function MessageItem({ message }: { message: Message }) {
const parts = useParts(message.id);
return (
<div className={`message ${message.role}`}>
<div className="message-header">
<span className="role">{message.role}</span>
{message.role === 'assistant' && (
<span className="model">{message.modelID}</span>
)}
</div>
<div className="message-content">
{parts.map((part) => (
<PartRenderer key={part.id} part={part} />
))}
</div>
{message.role === 'assistant' && message.tokens && (
<div className="message-footer">
<span>Tokens: {message.tokens.input + message.tokens.output}</span>
<span>Cost: ${message.cost.toFixed(4)}</span>
</div>
)}
</div>
);
}
function PartRenderer({ part }: { part: Part }) {
switch (part.type) {
case 'text':
return <div className="text-part">{part.text}</div>;
case 'reasoning':
return (
<details className="reasoning-part">
<summary>Reasoning</summary>
<div>{part.text}</div>
</details>
);
case 'tool':
return (
<div className={`tool-part status-${part.state.status}`}>
<div className="tool-name">{part.tool}</div>
{part.state.status === 'completed' && (
<div className="tool-output">{part.state.output}</div>
)}
{part.state.status === 'error' && (
<div className="tool-error">{part.state.error}</div>
)}
</div>
);
default:
return null;
}
}βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β SYNC CHECKLIST β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β β
β β ONE SSE connection to /global/event (no directory header needed) β
β β Route events by directory field β
β β Set x-opencode-directory header on ALL REST calls β
β β Use binary search for O(log n) array updates β
β β Sort arrays by ID after initial load β
β β Handle archived sessions (remove from list) β
β β Sync session on navigation (messages + parts + todos + diffs) β
β β Re-bootstrap on reconnect to catch missed events β
β β Handle heartbeat timeout (reconnect after 60s silence) β
β β Use optimistic updates + SSE reconciliation β
β β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
The most common sync issues:
- Wrong directory - Events don't match because directory doesn't match exactly
- Missing directory header - REST calls go to wrong instance
- Linear search - Performance degrades with many sessions/messages
- No reconnection - Connection drops and client doesn't recover
- No initial sync - Messages don't load until SSE event arrives
- Immer + Map - Using Map with Immer causes proxy revocation errors (use Record instead)
- Wrong event type - Subscribing to
message.createdwhen API only emitsmessage.updated - Wrong port - Hardcoding wrong port (OpenCode default is 4056, not 4096)
Follow this guide and your sync will be bulletproof.
The Next.js implementation uses a context-based SSE pattern instead of the callback-based pattern shown above:
// SSEProvider manages connection at app level
<SSEProvider url={OPENCODE_URL}>
{children}
</SSEProvider>
// Components subscribe to specific event types
const { subscribe } = useSSE()
useEffect(() => {
return subscribe("message.updated", (event) => {
// Handle event
})
}, [subscribe])Why context-based?
- Single SSE connection shared across all components
- No prop drilling of callbacks
- Automatic cleanup on unmount
- Matches React patterns better than callback-based hooks
apps/web/src/
βββ lib/
β βββ binary.ts # Binary.search, Binary.insert
βββ react/
β βββ store.ts # Zustand + Immer store
β βββ use-sse.tsx # SSEProvider + useSSE + useSSEDirect
β βββ index.ts # Public exports
βββ app/
βββ providers.tsx # Client providers wrapper
- Rename .ts to .tsx when adding JSX (SSEProvider needs JSX)
- Use Record not Map for Immer compatibility
- Export types from index.ts for clean imports
- Wrap app in Providers component (client boundary)
- useSSEDirect available for cases needing direct control without provider