feat(web): add WebSocket transport for message event streaming with SSE fallback (#764)

* feat: add websocket message stream transport

* fix: avoid false missing session directories in sidebar

* fix: re-probe project root session directories

* refactor: use button group for message stream transport

* fix: resolve chat input hook dependency warning

---------

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
Yifan
2026-04-17 11:07:26 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent fd8972a7d9
commit bee9d19f3a
26 changed files with 1515 additions and 163 deletions
@@ -2880,6 +2880,8 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
};
}, [fetchBranches, runtimeGit, selectedDraftProject, selectedDraftProjectBranches?.all, selectedDraftProjectPath, showDraftTargetSelectors]);
const selectedDraftProjectCurrentBranch = selectedDraftProjectBranches?.current?.trim() ?? '';
const projectRootBranchOption = React.useMemo(() => {
if (!selectedDraftProject) {
return null;
@@ -2888,15 +2890,14 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
if (!value) {
return null;
}
const projectRootBranch = selectedDraftProjectBranches?.current?.trim() ?? '';
if (!projectRootBranch) {
if (!selectedDraftProjectCurrentBranch) {
return null;
}
return {
value,
label: projectRootBranch,
label: selectedDraftProjectCurrentBranch,
};
}, [selectedDraftProject, selectedDraftProjectBranches]);
}, [selectedDraftProject, selectedDraftProjectCurrentBranch]);
const worktreeBranchOptions = React.useMemo(() => {
if (!selectedDraftProject) {
@@ -2914,11 +2915,11 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
return buildSessionTargetOptions({
projectRoot: normalizePath(selectedDraftProject.path) ?? '',
rootBranch: selectedDraftProjectBranches?.current?.trim() ?? '',
rootBranch: selectedDraftProjectCurrentBranch,
worktrees,
pendingBootstrapDirectory: newSessionDraft?.bootstrapPendingDirectory ?? null,
});
}, [availableWorktreesByProject, newSessionDraft?.bootstrapPendingDirectory, selectedDraftProject, selectedDraftProjectBranches?.current, selectedDraftProjectPath]);
}, [availableWorktreesByProject, newSessionDraft?.bootstrapPendingDirectory, selectedDraftProject, selectedDraftProjectCurrentBranch, selectedDraftProjectPath]);
const selectedDraftDirectory = React.useMemo(
() => normalizePath(newSessionDraft?.bootstrapPendingDirectory ?? null)
@@ -122,7 +122,7 @@ const VisualSectionContent: React.FC = () => {
// Chat section: User message rendering, Diff layout, Mobile status bar, Show reasoning traces, Queue mode, Persist draft
const ChatSectionContent: React.FC = () => {
return <OpenChamberVisualSettings visibleSettings={['chatRenderMode', 'activityRenderMode', 'userMessageRendering', 'mermaidRendering', 'reasoning', 'showToolFileIcons', 'expandedTools', 'stickyUserHeader', 'diffLayout', 'mobileStatusBar', 'dotfiles', 'queueMode', 'persistDraft', 'inputSpellcheck']} />;
return <OpenChamberVisualSettings visibleSettings={['chatRenderMode', 'messageTransport', 'activityRenderMode', 'userMessageRendering', 'mermaidRendering', 'reasoning', 'showToolFileIcons', 'expandedTools', 'stickyUserHeader', 'diffLayout', 'mobileStatusBar', 'dotfiles', 'queueMode', 'persistDraft', 'inputSpellcheck']} />;
};
// Sessions section: Default model & agent, Session retention
@@ -23,6 +23,7 @@ import { isDesktopShell, isVSCodeRuntime, isWebRuntime } from '@/lib/desktop';
import { useDeviceInfo } from '@/lib/device';
import { usePwaDetection } from '@/hooks/usePwaDetection';
import { updateDesktopSettings } from '@/lib/persistence';
import { useConfigStore } from '@/stores/useConfigStore';
import {
setDirectoryShowHidden,
useDirectoryShowHidden,
@@ -126,6 +127,24 @@ const CHAT_RENDER_MODE_OPTIONS: Option<'sorted' | 'live'>[] = [
},
];
const MESSAGE_STREAM_TRANSPORT_OPTIONS: Option<'auto' | 'ws' | 'sse'>[] = [
{
id: 'auto',
label: 'Auto',
description: 'Prefer WebSocket and fall back to SSE if needed.',
},
{
id: 'ws',
label: 'WebSocket',
description: 'Use WebSocket for message streaming.',
},
{
id: 'sse',
label: 'SSE',
description: 'Use Server-Sent Events for message streaming.',
},
];
const ACTIVITY_RENDER_MODE_OPTIONS: Option<'collapsed' | 'summary'>[] = [
{
id: 'collapsed',
@@ -177,7 +196,7 @@ const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain'
return mode === 'markdown' ? 'markdown' : 'plain';
};
export type VisibleSetting = 'theme' | 'pwaInstallName' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'activityRenderMode' | 'stickyUserHeader' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'reasoning' | 'showToolFileIcons' | 'expandedTools' | 'queueMode' | 'terminalQuickKeys' | 'persistDraft' | 'inputSpellcheck' | 'reportUsage';
export type VisibleSetting = 'theme' | 'pwaInstallName' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'stickyUserHeader' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'reasoning' | 'showToolFileIcons' | 'expandedTools' | 'queueMode' | 'terminalQuickKeys' | 'persistDraft' | 'inputSpellcheck' | 'reportUsage';
interface OpenChamberVisualSettingsProps {
/** Which settings to show. If undefined, shows all. */
@@ -233,6 +252,8 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
const setWeekStartPreference = useUIStore(state => state.setWeekStartPreference);
const showMobileSessionStatusBar = useUIStore(state => state.showMobileSessionStatusBar);
const setShowMobileSessionStatusBar = useUIStore(state => state.setShowMobileSessionStatusBar);
const messageStreamTransport = useConfigStore((state) => state.settingsMessageStreamTransport);
const setMessageStreamTransport = useConfigStore((state) => state.setSettingsMessageStreamTransport);
const isSettingsDialogOpen = useUIStore(state => state.isSettingsDialogOpen);
const {
themeMode,
@@ -323,6 +344,11 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
void updateDesktopSettings({ chatRenderMode: mode });
}, [setChatRenderMode]);
const handleMessageStreamTransportChange = React.useCallback((mode: 'auto' | 'ws' | 'sse') => {
setMessageStreamTransport(mode);
void updateDesktopSettings({ messageStreamTransport: mode });
}, [setMessageStreamTransport]);
const handleActivityRenderModeChange = React.useCallback((mode: 'collapsed' | 'summary') => {
setActivityRenderMode(mode);
void updateDesktopSettings({ activityRenderMode: mode });
@@ -399,6 +425,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
const hasBehaviorSettings = shouldShow('mermaidRendering')
|| shouldShow('userMessageRendering')
|| shouldShow('chatRenderMode')
|| shouldShow('messageTransport')
|| (shouldShow('activityRenderMode') && chatRenderMode === 'sorted')
|| shouldShow('stickyUserHeader')
|| shouldShow('diffLayout')
@@ -848,7 +875,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
{(shouldShow('userMessageRendering') || shouldShow('mermaidRendering') || shouldShow('chatRenderMode') || (shouldShow('activityRenderMode') && chatRenderMode === 'sorted') || (shouldShow('diffLayout') && !isVSCode)) && (
{(shouldShow('userMessageRendering') || shouldShow('mermaidRendering') || shouldShow('chatRenderMode') || shouldShow('messageTransport') || (shouldShow('activityRenderMode') && chatRenderMode === 'sorted') || (shouldShow('diffLayout') && !isVSCode)) && (
<div className="grid grid-cols-1 gap-y-2 md:grid-cols-[minmax(0,16rem)_minmax(0,16rem)] md:justify-start md:gap-x-2">
{shouldShow('chatRenderMode') && (
<section className="p-2 md:col-span-2">
@@ -934,6 +961,35 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
</section>
)}
{shouldShow('messageTransport') && (
<section className="p-2 md:col-span-2">
<h4 className="typography-ui-header font-medium text-foreground">Message Stream Transport</h4>
<div className="mt-1 flex max-w-[24rem] flex-col gap-2">
<div className="flex flex-wrap items-center gap-1">
{MESSAGE_STREAM_TRANSPORT_OPTIONS.map((option) => (
<Button
key={option.id}
variant="outline"
size="xs"
className={cn(
'!font-normal',
messageStreamTransport === option.id
? 'border-[var(--primary-base)] text-[var(--primary-base)] bg-[var(--primary-base)]/10 hover:text-[var(--primary-base)]'
: 'text-foreground'
)}
onClick={() => handleMessageStreamTransportChange(option.id)}
>
{option.label}
</Button>
))}
</div>
<span className="typography-meta text-muted-foreground">
{MESSAGE_STREAM_TRANSPORT_OPTIONS.find((option) => option.id === messageStreamTransport)?.description}
</span>
</div>
</section>
)}
{shouldShow('activityRenderMode') && chatRenderMode === 'sorted' && (
<section className="p-2 md:col-span-2">
<h4 className="typography-ui-header font-medium text-foreground">Activity Default</h4>
@@ -44,18 +44,29 @@ async function probeDirectory(directory: string): Promise<DirectoryStatusValue>
try {
await opencodeClient.listLocalDirectory(directory);
return 'exists';
} catch {
} catch (error) {
const looksLikeSdkWorktree =
directory.includes('/opencode/worktree/') ||
directory.includes('/.opencode/data/worktree/') ||
directory.includes('/.local/share/opencode/worktree/');
if (looksLikeSdkWorktree) {
const ok = await opencodeClient.probeDirectory(directory).catch(() => false);
if (ok) return 'exists';
const reachable = await opencodeClient.probeDirectory(directory).catch(() => false);
if (reachable) {
return 'exists';
}
return 'missing';
const message = error instanceof Error ? error.message.toLowerCase() : '';
const definitelyMissing =
message.includes('enoent') ||
message.includes('not found') ||
message.includes('does not exist') ||
message.includes('no such file');
if (definitelyMissing || looksLikeSdkWorktree) {
return 'missing';
}
return 'unknown';
}
}
@@ -75,13 +86,17 @@ export const useDirectoryStatusProbe = ({
React.useEffect(() => {
const directories = new Set<string>();
const normalizedProjectRoots = new Set<string>();
sortedSessions.forEach((session) => {
const dir = normalizePath((session as Session & { directory?: string | null }).directory ?? null);
if (dir) directories.add(dir);
});
projects.forEach((project) => {
const normalized = normalizePath(project.path);
if (normalized) directories.add(normalized);
if (normalized) {
directories.add(normalized);
normalizedProjectRoots.add(normalized);
}
});
const now = Date.now();
@@ -90,12 +105,25 @@ export const useDirectoryStatusProbe = ({
const preseeded = new Map<string, DirectoryStatusValue>();
for (const directory of directories) {
const isProjectRoot = normalizedProjectRoots.has(directory);
const known = directoryStatusRef.current.get(directory);
if (known && known !== 'unknown') continue;
if (known === 'exists') continue;
const cachedAt = missingCache[directory];
if (known === 'missing') {
if (isProjectRoot) {
toProbe.push(directory);
continue;
}
if (cachedAt && now - cachedAt < MISSING_REPROBE_MS) {
continue;
}
toProbe.push(directory);
continue;
}
// Use cached "missing" status if fresh enough — skip the HTTP probe
const cachedAt = missingCache[directory];
if (cachedAt && now - cachedAt < MISSING_REPROBE_MS) {
if (!isProjectRoot && cachedAt && now - cachedAt < MISSING_REPROBE_MS) {
preseeded.set(directory, 'missing');
continue;
}
+1
View File
@@ -569,6 +569,7 @@ export interface SettingsPayload {
showExpandedBashTools?: boolean;
showExpandedEditTools?: boolean;
chatRenderMode?: 'sorted' | 'live';
messageStreamTransport?: 'auto' | 'ws' | 'sse';
activityRenderMode?: 'collapsed' | 'summary';
mermaidRenderingMode?: 'svg' | 'ascii';
fontSize?: number;
+1
View File
@@ -125,6 +125,7 @@ export type DesktopSettings = {
timeFormatPreference?: 'auto' | '12h' | '24h';
weekStartPreference?: 'auto' | 'sunday' | 'monday';
chatRenderMode?: 'sorted' | 'live';
messageStreamTransport?: 'auto' | 'ws' | 'sse';
activityRenderMode?: 'collapsed' | 'summary';
mermaidRenderingMode?: 'svg' | 'ascii';
userMessageRenderingMode?: 'markdown' | 'plain';
+13
View File
@@ -297,6 +297,9 @@ const getRuntimeSettingsAPI = () => getRegisteredRuntimeAPIs()?.settings ?? null
const applyDesktopUiPreferences = (settings: DesktopSettings) => {
const store = useUIStore.getState();
const configStore = typeof window !== 'undefined'
? window.__zustand_config_store__?.getState?.() ?? null
: null;
const queueStore = useMessageQueueStore.getState();
if (typeof settings.showReasoningTraces === 'boolean' && settings.showReasoningTraces !== store.showReasoningTraces) {
@@ -407,6 +410,12 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => {
store.setUserMessageRenderingMode(settings.userMessageRenderingMode);
}
}
if (typeof settings.messageStreamTransport === 'string'
&& (settings.messageStreamTransport === 'auto' || settings.messageStreamTransport === 'ws' || settings.messageStreamTransport === 'sse')) {
if (configStore && settings.messageStreamTransport !== configStore.settingsMessageStreamTransport) {
configStore.setSettingsMessageStreamTransport(settings.messageStreamTransport);
}
}
if (typeof settings.stickyUserHeader === 'boolean' && settings.stickyUserHeader !== store.stickyUserHeader) {
store.setStickyUserHeader(settings.stickyUserHeader);
}
@@ -804,6 +813,10 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
&& (candidate.chatRenderMode === 'sorted' || candidate.chatRenderMode === 'live')) {
result.chatRenderMode = candidate.chatRenderMode;
}
if (typeof candidate.messageStreamTransport === 'string'
&& (candidate.messageStreamTransport === 'auto' || candidate.messageStreamTransport === 'ws' || candidate.messageStreamTransport === 'sse')) {
result.messageStreamTransport = candidate.messageStreamTransport;
}
if (typeof candidate.activityRenderMode === 'string'
&& (candidate.activityRenderMode === 'collapsed' || candidate.activityRenderMode === 'summary')) {
result.activityRenderMode = candidate.activityRenderMode;
+20
View File
@@ -29,6 +29,7 @@ interface OpenChamberDefaults {
autoCreateWorktree?: boolean;
gitmojiEnabled?: boolean;
zenModel?: string;
messageStreamTransport?: 'auto' | 'ws' | 'sse';
}
const fetchOpenChamberDefaults = async (): Promise<OpenChamberDefaults> => {
@@ -45,6 +46,10 @@ const fetchOpenChamberDefaults = async (): Promise<OpenChamberDefaults> => {
const defaultAgent = typeof data?.defaultAgent === 'string' ? data.defaultAgent.trim() : '';
const gitmojiEnabled = typeof data?.gitmojiEnabled === 'boolean' ? data.gitmojiEnabled : undefined;
const zenModel = typeof data?.zenModel === 'string' ? data.zenModel.trim() : '';
const messageStreamTransport =
data?.messageStreamTransport === 'ws' || data?.messageStreamTransport === 'sse' || data?.messageStreamTransport === 'auto'
? data.messageStreamTransport
: undefined;
return {
defaultModel: defaultModel.length > 0 ? defaultModel : undefined,
@@ -53,6 +58,7 @@ const fetchOpenChamberDefaults = async (): Promise<OpenChamberDefaults> => {
autoCreateWorktree: typeof data?.autoCreateWorktree === 'boolean' ? data.autoCreateWorktree : undefined,
gitmojiEnabled,
zenModel: zenModel.length > 0 ? zenModel : undefined,
messageStreamTransport,
};
}
} catch {
@@ -74,6 +80,10 @@ const fetchOpenChamberDefaults = async (): Promise<OpenChamberDefaults> => {
const defaultAgent = typeof data?.defaultAgent === 'string' ? data.defaultAgent.trim() : '';
const gitmojiEnabled = typeof data?.gitmojiEnabled === 'boolean' ? data.gitmojiEnabled : undefined;
const zenModel = typeof data?.zenModel === 'string' ? data.zenModel.trim() : '';
const messageStreamTransport =
data?.messageStreamTransport === 'ws' || data?.messageStreamTransport === 'sse' || data?.messageStreamTransport === 'auto'
? data.messageStreamTransport
: undefined;
return {
defaultModel: defaultModel.length > 0 ? defaultModel : undefined,
@@ -82,6 +92,7 @@ const fetchOpenChamberDefaults = async (): Promise<OpenChamberDefaults> => {
autoCreateWorktree: typeof data?.autoCreateWorktree === 'boolean' ? data.autoCreateWorktree : undefined,
gitmojiEnabled,
zenModel: zenModel.length > 0 ? zenModel : undefined,
messageStreamTransport,
};
} catch {
return {};
@@ -466,6 +477,7 @@ interface ConfigStore {
settingsAutoCreateWorktree: boolean;
settingsGitmojiEnabled: boolean;
settingsZenModel: string | undefined;
settingsMessageStreamTransport: 'auto' | 'ws' | 'sse';
// Voice provider preference ('browser', 'openai', 'openai-compatible', or 'say' for macOS)
voiceProvider: 'browser' | 'openai' | 'openai-compatible' | 'say';
setVoiceProvider: (provider: 'browser' | 'openai' | 'openai-compatible' | 'say') => void;
@@ -534,6 +546,7 @@ interface ConfigStore {
setSettingsAutoCreateWorktree: (enabled: boolean) => void;
setSettingsGitmojiEnabled: (enabled: boolean) => void;
setSettingsZenModel: (model: string | undefined) => void;
setSettingsMessageStreamTransport: (transport: 'auto' | 'ws' | 'sse') => void;
getResolvedGitGenerationModel: () => { providerId: string; modelId: string } | null;
saveAgentModelSelection: (agentName: string, providerId: string, modelId: string) => void;
getAgentModelSelection: (agentName: string) => { providerId: string; modelId: string } | null;
@@ -583,6 +596,7 @@ export const useConfigStore = create<ConfigStore>()(
settingsAutoCreateWorktree: false,
settingsGitmojiEnabled: false,
settingsZenModel: undefined,
settingsMessageStreamTransport: 'auto',
// Voice provider preference - load from localStorage or default to 'browser'
voiceProvider: (() => {
if (typeof window !== 'undefined') {
@@ -1256,6 +1270,7 @@ export const useConfigStore = create<ConfigStore>()(
settingsAutoCreateWorktree: openChamberDefaults.autoCreateWorktree ?? false,
settingsGitmojiEnabled: openChamberDefaults.gitmojiEnabled ?? false,
settingsZenModel: resolvedZenModel,
settingsMessageStreamTransport: openChamberDefaults.messageStreamTransport ?? state.settingsMessageStreamTransport ?? 'auto',
directoryScoped: {
...state.directoryScoped,
[directoryKey]: nextSnapshot,
@@ -1687,6 +1702,10 @@ export const useConfigStore = create<ConfigStore>()(
set({ settingsZenModel: model });
},
setSettingsMessageStreamTransport: (transport: 'auto' | 'ws' | 'sse') => {
set({ settingsMessageStreamTransport: transport });
},
getResolvedGitGenerationModel: () => {
const state = get();
return resolveGitGenerationModelSelection({
@@ -1983,6 +2002,7 @@ export const useConfigStore = create<ConfigStore>()(
settingsAutoCreateWorktree: state.settingsAutoCreateWorktree,
settingsGitmojiEnabled: state.settingsGitmojiEnabled,
settingsZenModel: state.settingsZenModel,
settingsMessageStreamTransport: state.settingsMessageStreamTransport,
speechRate: state.speechRate,
speechPitch: state.speechPitch,
speechVolume: state.speechVolume,
@@ -3,6 +3,7 @@ import { createEventPipeline } from '../event-pipeline';
const originalDocument = globalThis.document;
const originalWindow = globalThis.window;
const originalWebSocket = globalThis.WebSocket;
function installDomStubs() {
globalThis.document = {
@@ -12,14 +13,52 @@ function installDomStubs() {
};
globalThis.window = {
location: {
href: 'http://127.0.0.1:3000/',
origin: 'http://127.0.0.1:3000',
},
addEventListener() {},
removeEventListener() {},
};
}
class FakeWebSocket {
static instances = [];
constructor(url) {
this.url = url;
this.readyState = 0;
this.onopen = null;
this.onmessage = null;
this.onerror = null;
this.onclose = null;
FakeWebSocket.instances.push(this);
}
close() {
this.readyState = 3;
}
emitOpen() {
this.readyState = 1;
this.onopen?.();
}
emitMessage(payload) {
this.onmessage?.({ data: JSON.stringify(payload) });
}
emitClose() {
this.readyState = 3;
this.onclose?.();
}
}
afterEach(() => {
globalThis.document = originalDocument;
globalThis.window = originalWindow;
globalThis.WebSocket = originalWebSocket;
FakeWebSocket.instances = [];
});
function createSdkWithSingleEvent(event, hold) {
@@ -473,6 +512,160 @@ describe('createEventPipeline', () => {
expect(received[0].payload.type).toBe('message.part.updated');
expect(received[0].payload.properties.part.text).toBe('next');
});
it('consumes websocket message stream frames when transport is ws', async () => {
installDomStubs();
globalThis.WebSocket = FakeWebSocket;
const received = [];
const sdk = {
global: {
event: async () => {
throw new Error('SSE should not be used in ws mode');
},
},
};
const delivered = new Promise((resolve) => {
const { cleanup } = createEventPipeline({
sdk,
transport: 'ws',
onEvent: (directory, payload) => {
received.push({ directory, payload });
cleanup();
resolve();
},
});
});
await Promise.resolve();
const socket = FakeWebSocket.instances[0];
expect(socket?.url).toContain('/api/global/event/ws');
socket.emitOpen();
socket.emitMessage({ type: 'ready', scope: 'global' });
socket.emitMessage({
type: 'event',
eventId: 'evt-1',
directory: '/tmp/project',
payload: {
type: 'session.status',
properties: {
sessionID: 'session-1',
},
},
});
await delivered;
expect(received).toEqual([
{
directory: '/tmp/project',
payload: {
type: 'session.status',
properties: {
sessionID: 'session-1',
},
},
},
]);
});
it('falls back to SSE when websocket closes before ready in auto mode', async () => {
installDomStubs();
globalThis.WebSocket = FakeWebSocket;
let releaseStream;
const hold = new Promise((resolve) => {
releaseStream = resolve;
});
const received = [];
const sdk = createSdkWithSingleEvent({
payload: {
type: 'server.connected',
properties: {},
},
}, hold);
const delivered = new Promise((resolve) => {
const { cleanup } = createEventPipeline({
sdk,
transport: 'auto',
onEvent: (directory, payload) => {
received.push({ directory, payload });
cleanup();
releaseStream();
resolve();
},
});
});
await Promise.resolve();
const socket = FakeWebSocket.instances[0];
socket.emitClose();
await delivered;
expect(received).toEqual([
{
directory: 'global',
payload: {
type: 'server.connected',
properties: {},
},
},
]);
});
it('falls back to SSE when websocket does not become ready in auto mode', async () => {
installDomStubs();
globalThis.WebSocket = FakeWebSocket;
let releaseStream;
const hold = new Promise((resolve) => {
releaseStream = resolve;
});
const received = [];
const sdk = createSdkWithSingleEvent({
payload: {
type: 'server.connected',
properties: {},
},
}, hold);
const delivered = new Promise((resolve) => {
const { cleanup } = createEventPipeline({
sdk,
transport: 'auto',
onEvent: (directory, payload) => {
received.push({ directory, payload });
cleanup();
releaseStream();
resolve();
},
});
});
await Promise.resolve();
const socket = FakeWebSocket.instances[0];
socket.emitOpen();
await new Promise((resolve) => setTimeout(resolve, 2300));
await delivered;
expect(received).toEqual([
{
directory: 'global',
payload: {
type: 'server.connected',
properties: {},
},
},
]);
});
});
// ---------------------------------------------------------------------------
+313 -104
View File
@@ -1,5 +1,5 @@
/**
* Event Pipeline — SSE connection, event coalescing, and batched flush.
* Event Pipeline — transport connection, event coalescing, and batched flush.
*
* Plain closure API:
* const { cleanup } = createEventPipeline({ sdk, onEvent })
@@ -9,12 +9,9 @@
*/
import type { Event, OpencodeClient } from "@opencode-ai/sdk/v2/client"
import { opencodeClient } from "@/lib/opencode/client"
import { syncDebug } from "./debug"
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type QueuedEvent = {
directory: string
payload: Event
@@ -22,25 +19,30 @@ export type QueuedEvent = {
export type FlushHandler = (events: QueuedEvent[]) => void
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const FLUSH_FRAME_MS = 16
const STREAM_YIELD_MS = 8
const RECONNECT_DELAY_MS = 250
const HEARTBEAT_TIMEOUT_MS = 15_000
// ---------------------------------------------------------------------------
// Pipeline factory
// ---------------------------------------------------------------------------
const WS_FALLBACK_WINDOW_MS = 60_000
const WS_READY_TIMEOUT_MS = 2_000
const ABSOLUTE_URL_PATTERN = /^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//
export type EventPipelineInput = {
sdk: OpencodeClient
onEvent: (directory: string, payload: Event) => void
routeDirectory?: (directory: string, payload: Event) => string
/** Called after SSE reconnects (visibility restore or heartbeat timeout). */
/** Called after stream reconnects (visibility restore or heartbeat timeout). */
onReconnect?: () => void
transport?: "auto" | "ws" | "sse"
}
type MessageStreamWsFrame = {
type: "ready" | "event" | "error"
payload?: unknown
eventId?: string
directory?: string
message?: string
scope?: "global" | "directory"
}
const normalizeEventType = (payload: Event): Event => {
@@ -79,9 +81,57 @@ function resolveEventDirectory(event: unknown, payload: Event): string {
return propertyDirectory && propertyDirectory.length > 0 ? propertyDirectory : "global"
}
// Per-directory queue state. Each directory owns an independent flush timer
// so a busy directory's delta storm cannot block another directory's events
// from reaching the UI (head-of-line blocking across sessions).
function resolveEventPayload(payload: unknown): Event | null {
if (!payload || typeof payload !== "object") {
return null
}
const record = payload as { type?: unknown; payload?: unknown }
if (typeof record.type === "string") {
return payload as Event
}
if (record.payload && typeof record.payload === "object" && typeof (record.payload as { type?: unknown }).type === "string") {
return record.payload as Event
}
return null
}
function resolveAbsoluteUrl(candidate: string): string {
const normalized = typeof candidate === "string" && candidate.trim().length > 0 ? candidate.trim() : "/api"
if (ABSOLUTE_URL_PATTERN.test(normalized)) {
return normalized
}
if (typeof window === "undefined") {
return normalized
}
const baseReference = window.location?.href || window.location?.origin
if (!baseReference) {
return normalized
}
return new URL(normalized, baseReference).toString()
}
function toWebSocketUrl(candidate: string): string {
const url = new URL(resolveAbsoluteUrl(candidate))
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
return url.toString()
}
function buildGlobalEventWsUrl(lastEventId?: string): string {
const baseUrl = opencodeClient.getBaseUrl()
const normalizedBase = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`
const httpUrl = new URL("global/event/ws", resolveAbsoluteUrl(normalizedBase))
if (lastEventId && lastEventId.length > 0) {
httpUrl.searchParams.set("lastEventId", lastEventId)
}
return toWebSocketUrl(httpUrl.toString())
}
type DirectoryQueue = {
queue: Event[]
buffer: Event[]
@@ -92,11 +142,12 @@ type DirectoryQueue = {
}
export function createEventPipeline(input: EventPipelineInput) {
const { sdk, onEvent, onReconnect, routeDirectory } = input
const { sdk, onEvent, onReconnect, routeDirectory, transport = "auto" } = input
const abort = new AbortController()
let hasConnected = false
let lastEventId: string | undefined
let wsFallbackUntil = 0
// One queue + one flush timer per directory. Lazily created on first event.
const directories = new Map<string, DirectoryQueue>()
const getOrCreateDir = (directory: string): DirectoryQueue => {
@@ -114,19 +165,13 @@ export function createEventPipeline(input: EventPipelineInput) {
return d
}
// Coalesce key — same-type events for the same entity replace earlier ones.
// Keys are scoped to a single directory's queue, so directory is implicit.
// message.part.delta is a special case: consecutive deltas for the same
// (messageID, partID, field) are accumulated (string-concatenated) rather
// than replaced, because the reducer is a pure append and merging is
// semantically identical to applying each delta individually.
const key = (payload: Event): string | undefined => {
if (payload.type === "session.status") {
const props = payload.properties as { sessionID: string }
return `session.status:${props.sessionID}`
}
if (payload.type === "lsp.updated") {
return `lsp.updated`
return "lsp.updated"
}
if (payload.type === "message.part.updated") {
const part = (payload.properties as { part: { messageID: string; id: string } }).part
@@ -141,9 +186,6 @@ export function createEventPipeline(input: EventPipelineInput) {
const deltaKey = (messageID: string, partID: string, field: string) => `${messageID}:${partID}:${field}`
// Flush one directory — swap queue, dispatch events.
// React 18 auto-batching still collapses the setState calls inside a single
// directory's flush into one render pass.
const flushDir = (directory: string) => {
const d = directories.get(directory)
if (!d) return
@@ -189,7 +231,6 @@ export function createEventPipeline(input: EventPipelineInput) {
d.timer = setTimeout(() => flushDir(directory), Math.max(0, FLUSH_FRAME_MS - elapsed))
}
// Helpers
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
const isAbortError = (error: unknown): boolean =>
error instanceof DOMException && error.name === "AbortError" ||
@@ -200,6 +241,50 @@ export function createEventPipeline(input: EventPipelineInput) {
let lastEventAt = Date.now()
let heartbeat: ReturnType<typeof setTimeout> | undefined
const markConnected = () => {
if (hasConnected) {
onReconnect?.()
return
}
hasConnected = true
}
const enqueueEvent = (directory: string, payload: Event) => {
const normalizedPayload = normalizeEventType(payload)
const routedDirectory = routeDirectory?.(directory, normalizedPayload) || directory
const d = getOrCreateDir(routedDirectory)
const k = key(normalizedPayload)
if (k) {
const i = d.coalesced.get(k)
if (i !== undefined) {
if (normalizedPayload.type === "message.part.delta") {
const prev = d.queue[i] as unknown as { properties: { delta: string } }
const inc = normalizedPayload.properties as { delta: string }
d.queue[i] = {
...normalizedPayload,
properties: {
...(normalizedPayload.properties as object),
delta: prev.properties.delta + inc.delta,
},
} as unknown as Event
} else {
d.queue[i] = normalizedPayload
if (normalizedPayload.type === "message.part.updated") {
const part = (normalizedPayload.properties as { part: { messageID: string; id: string } }).part
d.staleDeltas.add(deltaKey(part.messageID, part.id, "text"))
d.staleDeltas.add(deltaKey(part.messageID, part.id, "output"))
}
}
syncDebug.pipeline.coalesced(normalizedPayload.type, k)
return
}
d.coalesced.set(k, d.queue.length)
}
d.queue.push(normalizedPayload)
scheduleDir(routedDirectory)
}
const resetHeartbeat = () => {
lastEventAt = Date.now()
if (heartbeat) clearTimeout(heartbeat)
@@ -214,87 +299,214 @@ export function createEventPipeline(input: EventPipelineInput) {
heartbeat = undefined
}
// SSE loop — iterate SDK global event stream, enqueue with coalescing
const runSseAttempt = async (signal: AbortSignal) => {
const events = await sdk.global.event({
signal,
onSseError: (error: unknown) => {
if (isAbortError(error)) return
if (streamErrorLogged) return
streamErrorLogged = true
console.error("[event-pipeline] SSE stream error", error)
},
})
markConnected()
let yielded = Date.now()
resetHeartbeat()
for await (const event of events.stream) {
resetHeartbeat()
streamErrorLogged = false
const payload = resolveEventPayload((event as { payload?: Event }).payload ?? event)
if (!payload) {
continue
}
const directory = resolveEventDirectory(event, payload)
enqueueEvent(directory, payload)
if (Date.now() - yielded < STREAM_YIELD_MS) continue
yielded = Date.now()
await wait(0)
}
}
const runWsAttempt = async (signal: AbortSignal) => {
await new Promise<void>((resolve, reject) => {
let settled = false
let opened = false
const socket = new WebSocket(buildGlobalEventWsUrl(lastEventId))
const setFallbackCode = (error: Error) => {
if (!opened && transport === "auto") {
wsFallbackUntil = Date.now() + WS_FALLBACK_WINDOW_MS
;(error as Error & { code?: string }).code = "WS_FALLBACK"
}
}
let readyTimer: ReturnType<typeof setTimeout> | undefined = setTimeout(() => {
readyTimer = undefined
const error = new Error("Message stream WebSocket ready timeout")
setFallbackCode(error)
settleReject(error)
try {
socket.close()
} catch {
// ignore
}
}, WS_READY_TIMEOUT_MS)
const cleanup = () => {
if (readyTimer) {
clearTimeout(readyTimer)
readyTimer = undefined
}
socket.onopen = null
socket.onmessage = null
socket.onerror = null
socket.onclose = null
}
const settleResolve = () => {
if (settled) return
settled = true
signal.removeEventListener("abort", handleAbort)
cleanup()
resolve()
}
const settleReject = (error: unknown) => {
if (settled) return
settled = true
signal.removeEventListener("abort", handleAbort)
cleanup()
reject(error)
}
const handleAbort = () => {
try {
socket.close()
} catch {
// ignore close failures during abort
}
settleResolve()
}
signal.addEventListener("abort", handleAbort, { once: true })
socket.onopen = () => {
streamErrorLogged = false
}
socket.onmessage = (messageEvent) => {
resetHeartbeat()
streamErrorLogged = false
let frame: MessageStreamWsFrame | null = null
try {
frame = JSON.parse(String(messageEvent.data)) as MessageStreamWsFrame
} catch (error) {
console.warn("[event-pipeline] Failed to parse WS frame", error)
return
}
if (!frame || typeof frame.type !== "string") {
return
}
if (frame.type === "ready") {
opened = true
if (readyTimer) {
clearTimeout(readyTimer)
readyTimer = undefined
}
markConnected()
return
}
if (frame.type === "error") {
const error = new Error(frame.message || "Message stream WebSocket error")
setFallbackCode(error)
settleReject(error)
try {
socket.close()
} catch {
// ignore
}
return
}
if (frame.type !== "event") {
return
}
const payload = resolveEventPayload(frame.payload)
if (!payload) {
return
}
if (typeof frame.eventId === "string" && frame.eventId.length > 0) {
lastEventId = frame.eventId
}
const directory = resolveEventDirectory(
{ directory: frame.directory, payload },
payload,
)
enqueueEvent(directory, payload)
}
socket.onerror = () => {
void 0
}
socket.onclose = () => {
if (signal.aborted) {
settleResolve()
return
}
const error = new Error("Global message stream WebSocket closed")
setFallbackCode(error)
settleReject(error)
}
})
}
const resolveTransport = (): "ws" | "sse" => {
if (typeof WebSocket !== "function") {
return "sse"
}
if (transport === "ws") {
return "ws"
}
if (transport === "sse") {
return "sse"
}
return wsFallbackUntil > Date.now() ? "sse" : "ws"
}
void (async () => {
while (!abort.signal.aborted) {
attempt = new AbortController()
lastEventAt = Date.now()
let retryDelayMs = RECONNECT_DELAY_MS
const currentTransport = resolveTransport()
const onAbort = () => {
attempt?.abort()
}
abort.signal.addEventListener("abort", onAbort)
try {
const events = await sdk.global.event({
signal: attempt.signal,
onSseError: (error: unknown) => {
if (isAbortError(error)) return
if (streamErrorLogged) return
streamErrorLogged = true
console.error("[event-pipeline] stream error", error)
},
})
if (hasConnected) {
onReconnect?.()
if (currentTransport === "ws") {
await runWsAttempt(attempt.signal)
} else {
hasConnected = true
}
let yielded = Date.now()
resetHeartbeat()
// Enqueue event with coalescing + stale delta tracking
for await (const event of events.stream) {
resetHeartbeat()
streamErrorLogged = false
const payload = (event as { payload?: Event }).payload ?? (event as unknown as Event)
if (!payload || typeof payload !== "object" || typeof (payload as { type?: unknown }).type !== "string") {
continue
}
const normalizedPayload = normalizeEventType(payload)
const directory = resolveEventDirectory(event, normalizedPayload)
const routedDirectory = routeDirectory?.(directory, normalizedPayload) || directory
const d = getOrCreateDir(routedDirectory)
const k = key(normalizedPayload)
if (k) {
const i = d.coalesced.get(k)
if (i !== undefined) {
if (normalizedPayload.type === "message.part.delta") {
// Accumulate delta strings — append to the already-queued event
// rather than replacing it. The reducer is a pure string append so
// this is semantically identical to applying each delta separately.
const prev = d.queue[i] as unknown as { properties: { delta: string } }
const inc = normalizedPayload.properties as { delta: string }
d.queue[i] = {
...normalizedPayload,
properties: {
...(normalizedPayload.properties as object),
delta: prev.properties.delta + inc.delta,
},
} as unknown as Event
} else {
d.queue[i] = normalizedPayload
if (normalizedPayload.type === "message.part.updated") {
const part = (normalizedPayload.properties as { part: { messageID: string; id: string } }).part
d.staleDeltas.add(deltaKey(part.messageID, part.id, "text"))
d.staleDeltas.add(deltaKey(part.messageID, part.id, "output"))
}
}
syncDebug.pipeline.coalesced(normalizedPayload.type, k)
continue
}
d.coalesced.set(k, d.queue.length)
}
d.queue.push(normalizedPayload)
scheduleDir(routedDirectory)
if (Date.now() - yielded < STREAM_YIELD_MS) continue
yielded = Date.now()
await wait(0)
await runSseAttempt(attempt.signal)
}
} catch (error) {
if (!isAbortError(error) && !streamErrorLogged) {
const code = typeof error === "object" && error !== null ? (error as { code?: unknown }).code : undefined
if (currentTransport === "ws" && code === "WS_FALLBACK") {
retryDelayMs = 0
} else if (!isAbortError(error) && !streamErrorLogged) {
streamErrorLogged = true
console.error("[event-pipeline] stream failed", error)
}
@@ -305,12 +517,12 @@ export function createEventPipeline(input: EventPipelineInput) {
}
if (abort.signal.aborted) return
await wait(RECONNECT_DELAY_MS)
if (retryDelayMs > 0) {
await wait(retryDelayMs)
}
}
})().finally(flushAll)
// Visibility handler — abort SSE on heartbeat timeout so the loop reconnects.
// The reconnect triggers onReconnect above, which lets consumers resync state.
const onVisibility = () => {
if (typeof document === "undefined") return
if (document.visibilityState !== "visible") return
@@ -318,8 +530,6 @@ export function createEventPipeline(input: EventPipelineInput) {
attempt?.abort()
}
// pageshow handler — fires on back-forward cache restore (common on mobile PWA).
// bfcache restores the page without a fresh load, so SSE state may be stale.
const onPageShow = (event: PageTransitionEvent) => {
if (!event.persisted) return
attempt?.abort()
@@ -330,7 +540,6 @@ export function createEventPipeline(input: EventPipelineInput) {
window.addEventListener("pageshow", onPageShow)
}
// Cleanup — abort SSE, flush remaining events, remove listeners
const cleanup = () => {
if (typeof document !== "undefined") {
document.removeEventListener("visibilitychange", onVisibility)
+4 -1
View File
@@ -26,6 +26,7 @@ import { stripMessageDiffSnapshots, stripSessionDiffSnapshots } from "./sanitize
import { syncDebug } from "./debug"
import { opencodeClient } from "@/lib/opencode/client"
import { usePermissionStore } from "@/stores/permissionStore"
import { useConfigStore } from "@/stores/useConfigStore"
import { toast } from "@/components/ui"
import { appendNotification } from "./notification-store"
import type { State } from "./types"
@@ -1184,6 +1185,7 @@ export function SyncProvider(props: {
directory: string
children: React.ReactNode
}) {
const messageStreamTransport = useConfigStore((state) => state.settingsMessageStreamTransport)
const childStoresRef = useRef<ChildStoreManager | null>(null)
if (!childStoresRef.current) childStoresRef.current = new ChildStoreManager()
const childStores = childStoresRef.current
@@ -1291,6 +1293,7 @@ export function SyncProvider(props: {
const { cleanup } = createEventPipeline({
sdk: props.sdk,
transport: messageStreamTransport,
routeDirectory: (directory, payload) => {
return resolveDirectoryFromRoutingIndex(routingIndex, directory, payload, childStores)
},
@@ -1314,7 +1317,7 @@ export function SyncProvider(props: {
},
})
return cleanup
}, [props.sdk, props.directory, childStores, routingIndex])
}, [props.sdk, childStores, routingIndex, messageStreamTransport])
// Ensure current directory's child store exists
useEffect(() => {