fix desktop notifications and chat scroll stability

This commit is contained in:
Bohdan Triapitsyn
2026-04-06 17:36:08 +03:00
parent e42471ee7b
commit 9254ec0783
10 changed files with 292 additions and 97 deletions
+13 -6
View File
@@ -2068,16 +2068,23 @@ async fn spawn_local_server(app: &tauri::AppHandle) -> Result<String> {
let app_handle = app.clone(); let app_handle = app.clone();
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
let mut rx = rx; let mut rx = rx;
let mut stdout_buffer = String::new();
while let Some(event) = rx.recv().await { while let Some(event) = rx.recv().await {
match event { match event {
CommandEvent::Stdout(bytes) => { CommandEvent::Stdout(bytes) => {
let line = String::from_utf8_lossy(&bytes); stdout_buffer.push_str(&String::from_utf8_lossy(&bytes));
if let Some(rest) = line.strip_prefix(SIDECAR_NOTIFY_PREFIX) {
if let Ok(parsed) = while let Some(newline_index) = stdout_buffer.find('\n') {
serde_json::from_str::<SidecarNotifyPayload>(rest.trim()) let line = stdout_buffer[..newline_index].trim_end_matches('\r');
{ if let Some(rest) = line.strip_prefix(SIDECAR_NOTIFY_PREFIX) {
maybe_show_sidecar_notification(&app_handle, parsed); if let Ok(parsed) =
serde_json::from_str::<SidecarNotifyPayload>(rest.trim())
{
maybe_show_sidecar_notification(&app_handle, parsed);
}
} }
stdout_buffer.drain(..=newline_index);
} }
} }
CommandEvent::Error(error) => { CommandEvent::Error(error) => {
+58
View File
@@ -175,6 +175,7 @@ function App({ apis }: AppProps) {
const appReadyDispatchedRef = React.useRef(false); const appReadyDispatchedRef = React.useRef(false);
const embeddedSessionChat = React.useMemo<EmbeddedSessionChatConfig | null>(() => readEmbeddedSessionChatConfig(), []); const embeddedSessionChat = React.useMemo<EmbeddedSessionChatConfig | null>(() => readEmbeddedSessionChatConfig(), []);
const embeddedBackgroundWorkEnabled = !embeddedSessionChat || isEmbeddedVisible; const embeddedBackgroundWorkEnabled = !embeddedSessionChat || isEmbeddedVisible;
const recentDesktopNotificationTagsRef = React.useRef<Map<string, number>>(new Map());
React.useEffect(() => { React.useEffect(() => {
setStreamPerfEnabled(showMemoryDebug); setStreamPerfEnabled(showMemoryDebug);
@@ -371,6 +372,63 @@ function App({ apis }: AppProps) {
}; };
}, [embeddedSessionChat]); }, [embeddedSessionChat]);
React.useEffect(() => {
if (embeddedSessionChat || !isDesktopRuntime || typeof window === 'undefined' || typeof EventSource === 'undefined') {
return;
}
const source = new EventSource('/api/notifications/stream');
const handleMessage = (event: MessageEvent<string>) => {
type DesktopNotificationEvent = {
type?: string;
properties?: {
title?: string;
body?: string;
tag?: string;
};
};
let payload: DesktopNotificationEvent;
try {
payload = JSON.parse(event.data) as DesktopNotificationEvent;
} catch {
return;
}
if (payload?.type !== 'openchamber:notification') {
return;
}
const tag = typeof payload.properties?.tag === 'string' ? payload.properties.tag : '';
if (tag) {
const now = Date.now();
const lastSeenAt = recentDesktopNotificationTagsRef.current.get(tag) ?? 0;
if (now - lastSeenAt < 5000) {
return;
}
recentDesktopNotificationTagsRef.current.set(tag, now);
}
void apis.notifications.notifyAgentCompletion({
title: payload.properties?.title,
body: payload.properties?.body,
tag: tag || undefined,
});
};
source.addEventListener('message', handleMessage as EventListener);
source.onerror = () => {
// Let EventSource reconnect automatically.
};
return () => {
source.removeEventListener('message', handleMessage as EventListener);
source.close();
};
}, [apis.notifications, embeddedSessionChat, isDesktopRuntime]);
React.useEffect(() => { React.useEffect(() => {
if (!embeddedSessionChat?.directory || isVSCodeRuntime) { if (!embeddedSessionChat?.directory || isVSCodeRuntime) {
return; return;
@@ -631,6 +631,7 @@ export const ChatContainer: React.FC = () => {
}, [currentSessionId, isDesktopExpandedInput, scrollRef]); }, [currentSessionId, isDesktopExpandedInput, scrollRef]);
const hasHistoryMetadata = Boolean(historyMeta); const hasHistoryMetadata = Boolean(historyMeta);
const lastHydratedSessionRef = React.useRef<string | null>(null);
const isSessionHydrating = const isSessionHydrating =
Boolean(currentSessionId) Boolean(currentSessionId)
@@ -640,12 +641,15 @@ export const ChatContainer: React.FC = () => {
if (!currentSessionId) return; if (!currentSessionId) return;
if (hasSessionMessagesEntry && hasHistoryMetadata) return; if (hasSessionMessagesEntry && hasHistoryMetadata) return;
const isSessionSwitch = lastHydratedSessionRef.current !== currentSessionId;
lastHydratedSessionRef.current = currentSessionId;
const load = async () => { const load = async () => {
await loadMessages(currentSessionId).finally(() => { await loadMessages(currentSessionId).finally(() => {
const statusType = sessionStatusForCurrent.type ?? 'idle'; const statusType = sessionStatusForCurrent.type ?? 'idle';
const isActivePhase = statusType === 'busy' || statusType === 'retry'; const isActivePhase = statusType === 'busy' || statusType === 'retry';
const hasHashTarget = typeof window !== 'undefined' && window.location.hash.length > 0; const hasHashTarget = typeof window !== 'undefined' && window.location.hash.length > 0;
const shouldSkipScroll = (isActivePhase && isPinned) || hasHashTarget; const shouldSkipScroll = hasHashTarget || (isActivePhase && isPinned && !isSessionSwitch);
if (!shouldSkipScroll) { if (!shouldSkipScroll) {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
@@ -1692,17 +1692,10 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
return false; return false;
} }
const container = resolveScrollContainer();
if (!container) {
return false;
}
const virtualizerBehavior = behavior === 'smooth' ? 'smooth' : 'auto'; const virtualizerBehavior = behavior === 'smooth' ? 'smooth' : 'auto';
historyVirtualizer.scrollToIndex(index, { align: 'start', behavior: virtualizerBehavior }); historyVirtualizer.scrollToIndex(index, { align: 'start', behavior: virtualizerBehavior });
const targetTop = Math.max(0, container.scrollTop - 50);
container.scrollTo({ top: targetTop, behavior });
return true; return true;
}, [historyEntries.length, historyVirtualizer, resolveScrollContainer, shouldVirtualizeHistory]); }, [historyEntries.length, historyVirtualizer, shouldVirtualizeHistory]);
const scrollMessageElementIntoView = React.useCallback((messageId: string, behavior: ScrollBehavior = 'auto') => { const scrollMessageElementIntoView = React.useCallback((messageId: string, behavior: ScrollBehavior = 'auto') => {
const container = resolveScrollContainer(); const container = resolveScrollContainer();
@@ -14,19 +14,17 @@ import {
import type { TurnHistorySignals } from '../lib/turns/historySignals'; import type { TurnHistorySignals } from '../lib/turns/historySignals';
import { getMemoryLimits, type SessionHistoryMeta } from '@/stores/types/sessionTypes'; import { getMemoryLimits, type SessionHistoryMeta } from '@/stores/types/sessionTypes';
const waitForFrames = async (count = 1): Promise<void> => {
if (typeof window === 'undefined') {
return;
}
for (let index = 0; index < count; index += 1) {
await new Promise<void>((resolve) => {
window.requestAnimationFrame(() => resolve());
});
}
};
type ViewportAnchor = { messageId: string; offsetTop: number }; type ViewportAnchor = { messageId: string; offsetTop: number };
type PendingScrollRequest = {
sessionId: string;
kind: 'turn' | 'message';
id: string;
behavior: ScrollBehavior;
turnId: string | null;
resolve: (value: boolean) => void;
};
interface UseChatTimelineControllerOptions { interface UseChatTimelineControllerOptions {
sessionId: string | null; sessionId: string | null;
messages: ChatMessageEntry[]; messages: ChatMessageEntry[];
@@ -100,6 +98,8 @@ export const useChatTimelineController = ({
const historyMetaRef = React.useRef<SessionHistoryMeta | null>(historyMeta); const historyMetaRef = React.useRef<SessionHistoryMeta | null>(historyMeta);
const previousTurnCountRef = React.useRef(turnWindowModel.turnCount); const previousTurnCountRef = React.useRef(turnWindowModel.turnCount);
const initializedSessionRef = React.useRef<string | null>(null); const initializedSessionRef = React.useRef<string | null>(null);
const pendingRenderResolversRef = React.useRef<Array<() => void>>([]);
const pendingScrollRequestRef = React.useRef<PendingScrollRequest | null>(null);
const historySignals = React.useMemo(() => { const historySignals = React.useMemo(() => {
const defaultLimit = getMemoryLimits().HISTORICAL_MESSAGES; const defaultLimit = getMemoryLimits().HISTORICAL_MESSAGES;
@@ -189,10 +189,78 @@ export const useChatTimelineController = ({
previousTurnCountRef.current = nextTurnCount; previousTurnCountRef.current = nextTurnCount;
}, [turnWindowModel.turnCount]); }, [turnWindowModel.turnCount]);
const resolvePendingRenderWaiters = React.useCallback(() => {
const resolvers = pendingRenderResolversRef.current;
if (resolvers.length === 0) {
return;
}
pendingRenderResolversRef.current = [];
resolvers.forEach((resolve) => resolve());
}, []);
const waitForNextRenderCommit = React.useCallback((): Promise<void> => {
return new Promise<void>((resolve) => {
pendingRenderResolversRef.current.push(resolve);
});
}, []);
const resolvePendingScrollRequest = React.useCallback((value: boolean) => {
const pending = pendingScrollRequestRef.current;
if (!pending) {
return;
}
pendingScrollRequestRef.current = null;
pending.resolve(value);
}, []);
const attemptPendingScrollRequest = React.useCallback(() => {
const pending = pendingScrollRequestRef.current;
if (!pending) {
return;
}
if (pending.sessionId !== sessionIdRef.current) {
resolvePendingScrollRequest(false);
return;
}
const didScroll = pending.kind === 'turn'
? (messageListRef.current?.scrollToTurnId(pending.id, { behavior: pending.behavior }) ?? false)
: (messageListRef.current?.scrollToMessageId(pending.id, { behavior: pending.behavior }) ?? false);
if (didScroll) {
if (pending.turnId) {
setActiveTurnId(pending.turnId);
}
resolvePendingScrollRequest(true);
return;
}
const targetIndex = pending.kind === 'turn'
? turnModelRef.current.turnIndexById.get(pending.id)
: turnModelRef.current.messageToTurnIndex.get(pending.id);
if (typeof targetIndex === 'number' && targetIndex >= turnStartRef.current) {
resolvePendingScrollRequest(false);
}
}, [messageListRef, resolvePendingScrollRequest]);
React.useEffect(() => {
return () => {
resolvePendingRenderWaiters();
resolvePendingScrollRequest(false);
};
}, [resolvePendingRenderWaiters, resolvePendingScrollRequest]);
const renderedMessages = React.useMemo(() => { const renderedMessages = React.useMemo(() => {
return windowMessagesByTurn(messages, turnWindowModel, turnStart); return windowMessagesByTurn(messages, turnWindowModel, turnStart);
}, [messages, turnStart, turnWindowModel]); }, [messages, turnStart, turnWindowModel]);
React.useLayoutEffect(() => {
resolvePendingRenderWaiters();
attemptPendingScrollRequest();
}, [attemptPendingScrollRequest, renderedMessages, resolvePendingRenderWaiters, turnStart]);
// --- Synchronous scroll compensation for load-more / reveal --- // --- Synchronous scroll compensation for load-more / reveal ---
// fetchOlderHistory and revealBufferedTurns store a snapshot here // fetchOlderHistory and revealBufferedTurns store a snapshot here
// before triggering the state change. useLayoutEffect consumes it // before triggering the state change. useLayoutEffect consumes it
@@ -257,10 +325,10 @@ export const useChatTimelineController = ({
return next > 0 ? next : 0; return next > 0 ? next : 0;
}); });
await waitForFrames(1); await waitForNextRenderCommit();
setPendingRevealWork(false); setPendingRevealWork(false);
return true; return true;
}, [captureViewportAnchor, scrollRef]); }, [captureViewportAnchor, scrollRef, waitForNextRenderCommit]);
const fetchOlderHistory = React.useCallback(async (input: { const fetchOlderHistory = React.useCallback(async (input: {
preserveViewport: boolean; preserveViewport: boolean;
@@ -344,26 +412,29 @@ export const useChatTimelineController = ({
if (turnIndex < turnStartRef.current) { if (turnIndex < turnStartRef.current) {
setTurnStart(turnIndex); setTurnStart(turnIndex);
await waitForFrames(2);
} }
const didScroll = messageListRef.current?.scrollToTurnId(turnId, { const result = await new Promise<boolean>((resolve) => {
behavior: options?.behavior, pendingScrollRequestRef.current = {
}) ?? false; sessionId: sessionIdRef.current ?? sessionId ?? '',
kind: 'turn',
id: turnId,
behavior: options?.behavior ?? 'auto',
turnId,
resolve,
};
attemptPendingScrollRequest();
});
if (didScroll) { if (result) {
setActiveTurnId(turnId);
return true; return true;
} }
await waitForFrames(2); return false;
return messageListRef.current?.scrollToTurnId(turnId, {
behavior: options?.behavior,
}) ?? false;
} finally { } finally {
setPendingRevealWork(false); setPendingRevealWork(false);
} }
}, [messageListRef, sessionId]); }, [attemptPendingScrollRequest, sessionId]);
const scrollToMessage = React.useCallback(async ( const scrollToMessage = React.useCallback(async (
messageId: string, messageId: string,
@@ -389,28 +460,29 @@ export const useChatTimelineController = ({
if (turnIndex < turnStartRef.current) { if (turnIndex < turnStartRef.current) {
setTurnStart(turnIndex); setTurnStart(turnIndex);
await waitForFrames(2);
} }
const didScroll = messageListRef.current?.scrollToMessageId(messageId, { const result = await new Promise<boolean>((resolve) => {
behavior: options?.behavior, pendingScrollRequestRef.current = {
}) ?? false; sessionId: sessionIdRef.current ?? sessionId ?? '',
kind: 'message',
id: messageId,
behavior: options?.behavior ?? 'auto',
turnId: turnId ?? null,
resolve,
};
attemptPendingScrollRequest();
});
if (didScroll) { if (result) {
if (turnId) {
setActiveTurnId(turnId);
}
return true; return true;
} }
await waitForFrames(2); return false;
return messageListRef.current?.scrollToMessageId(messageId, {
behavior: options?.behavior,
}) ?? false;
} finally { } finally {
setPendingRevealWork(false); setPendingRevealWork(false);
} }
}, [messageListRef, sessionId]); }, [attemptPendingScrollRequest, sessionId]);
const resumeToBottom = React.useCallback(() => { const resumeToBottom = React.useCallback(() => {
const nextStart = getInitialTurnStart(turnModelRef.current.turnCount); const nextStart = getInitialTurnStart(turnModelRef.current.turnCount);
+18 -1
View File
@@ -114,6 +114,7 @@ export const useChatScrollManager = ({
const pinnedSyncRafRef = React.useRef<number | null>(null); const pinnedSyncRafRef = React.useRef<number | null>(null);
const preferInstantPinRef = React.useRef(false); const preferInstantPinRef = React.useRef(false);
const autoFollowDuringWorkRef = React.useRef(false); const autoFollowDuringWorkRef = React.useRef(false);
const pendingSessionSwitchSnapRef = React.useRef(false);
const viewportAnchorTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null); const viewportAnchorTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingViewportAnchorRef = React.useRef<{ sessionId: string; anchor: number } | null>(null); const pendingViewportAnchorRef = React.useRef<{ sessionId: string; anchor: number } | null>(null);
const lastViewportAnchorRef = React.useRef<{ sessionId: string; anchor: number } | null>(null); const lastViewportAnchorRef = React.useRef<{ sessionId: string; anchor: number } | null>(null);
@@ -206,21 +207,35 @@ export const useChatScrollManager = ({
pinnedSyncRafRef.current = null; pinnedSyncRafRef.current = null;
updateScrollButtonVisibility(); updateScrollButtonVisibility();
if (!isPinnedRef.current) { if (!isPinnedRef.current) {
pendingSessionSwitchSnapRef.current = false;
return; return;
} }
const distanceFromBottom = getDistanceFromBottom(); const distanceFromBottom = getDistanceFromBottom();
if (sessionIsWorking) { if (sessionIsWorking) {
if (distanceFromBottom <= 0.5) { if (pendingSessionSwitchSnapRef.current && distanceFromBottom > 0.5) {
autoFollowDuringWorkRef.current = false;
scrollToBottomInternal({ instant: true });
pendingSessionSwitchSnapRef.current = false;
preferInstantPinRef.current = false; preferInstantPinRef.current = false;
return; return;
} }
if (distanceFromBottom <= 0.5) {
if (!pendingSessionSwitchSnapRef.current) {
preferInstantPinRef.current = false;
}
return;
}
scrollPinnedToBottom(distanceFromBottom); scrollPinnedToBottom(distanceFromBottom);
preferInstantPinRef.current = false; preferInstantPinRef.current = false;
pendingSessionSwitchSnapRef.current = false;
return; return;
} }
pendingSessionSwitchSnapRef.current = false;
if (distanceFromBottom <= getAutoFollowThreshold()) { if (distanceFromBottom <= getAutoFollowThreshold()) {
preferInstantPinRef.current = false; preferInstantPinRef.current = false;
return; return;
@@ -480,6 +495,7 @@ export const useChatScrollManager = ({
flushViewportAnchor(); flushViewportAnchor();
pendingViewportAnchorRef.current = null; pendingViewportAnchorRef.current = null;
autoFollowDuringWorkRef.current = false; autoFollowDuringWorkRef.current = false;
pendingSessionSwitchSnapRef.current = true;
// Always start pinned at bottom on session switch // Always start pinned at bottom on session switch
preferInstantPinRef.current = true; preferInstantPinRef.current = true;
@@ -497,6 +513,7 @@ export const useChatScrollManager = ({
React.useEffect(() => { React.useEffect(() => {
if (!sessionIsWorking) { if (!sessionIsWorking) {
autoFollowDuringWorkRef.current = false; autoFollowDuringWorkRef.current = false;
pendingSessionSwitchSnapRef.current = false;
} }
}, [sessionIsWorking]); }, [sessionIsWorking]);
+21 -1
View File
@@ -407,7 +407,19 @@ const {
const ENV_SKIP_OPENCODE_START = process.env.OPENCODE_SKIP_START === 'true' || const ENV_SKIP_OPENCODE_START = process.env.OPENCODE_SKIP_START === 'true' ||
process.env.OPENCHAMBER_SKIP_OPENCODE_START === 'true'; process.env.OPENCHAMBER_SKIP_OPENCODE_START === 'true';
const ENV_DESKTOP_NOTIFY = process.env.OPENCHAMBER_DESKTOP_NOTIFY === 'true'; const ENV_DESKTOP_NOTIFY = (() => {
if (process.env.OPENCHAMBER_DESKTOP_NOTIFY === 'true') {
return true;
}
if (process.env.OPENCHAMBER_RUNTIME === 'desktop') {
return true;
}
const argv0 = typeof process.argv?.[0] === 'string' ? process.argv[0] : '';
const argv1 = typeof process.argv?.[1] === 'string' ? process.argv[1] : '';
return /openchamber-server/i.test(argv0) || /openchamber-server/i.test(argv1);
})();
const ENV_CONFIGURED_OPENCODE_WSL_DISTRO = const ENV_CONFIGURED_OPENCODE_WSL_DISTRO =
typeof process.env.OPENCODE_WSL_DISTRO === 'string' && process.env.OPENCODE_WSL_DISTRO.trim().length > 0 typeof process.env.OPENCODE_WSL_DISTRO === 'string' && process.env.OPENCODE_WSL_DISTRO.trim().length > 0
? process.env.OPENCODE_WSL_DISTRO.trim() ? process.env.OPENCODE_WSL_DISTRO.trim()
@@ -758,6 +770,11 @@ const bootstrapOpenCodeAtStartup = async (...args) => {
if (openCodeLifecycleState.openCodeProcess && !openCodeLifecycleState.isExternalOpenCode) { if (openCodeLifecycleState.openCodeProcess && !openCodeLifecycleState.isExternalOpenCode) {
startHealthMonitoring(); startHealthMonitoring();
} }
if (ENV_DESKTOP_NOTIFY) {
void ensureGlobalWatcherStarted().catch((error) => {
console.warn(`Global event watcher startup failed: ${error?.message || error}`);
});
}
}; };
const killProcessOnPort = (...args) => openCodeLifecycleRuntime.killProcessOnPort(...args); const killProcessOnPort = (...args) => openCodeLifecycleRuntime.killProcessOnPort(...args);
@@ -874,6 +891,7 @@ async function main(options = {}) {
opencodeWslDistro: resolvedWslDistro || null, opencodeWslDistro: resolvedWslDistro || null,
nodeBinaryResolved: resolvedNodeBinary || null, nodeBinaryResolved: resolvedNodeBinary || null,
bunBinaryResolved: resolvedBunBinary || null, bunBinaryResolved: resolvedBunBinary || null,
desktopNotifyEnabled: ENV_DESKTOP_NOTIFY,
planModeExperimentalEnabled: PLAN_MODE_EXPERIMENT_ENABLED, planModeExperimentalEnabled: PLAN_MODE_EXPERIMENT_ENABLED,
}), }),
uiPassword, uiPassword,
@@ -891,6 +909,8 @@ async function main(options = {}) {
removePushSubscription, removePushSubscription,
updateUiVisibility, updateUiVisibility,
isUiVisible, isUiVisible,
getUiNotificationClients: () => uiNotificationClients,
writeSseEvent,
sessionRuntime, sessionRuntime,
setPushInitialized, setPushInitialized,
fs, fs,
@@ -35,6 +35,8 @@ export const registerNotificationRoutes = (app, dependencies) => {
removePushSubscription, removePushSubscription,
updateUiVisibility, updateUiVisibility,
isUiVisible, isUiVisible,
getUiNotificationClients,
writeSseEvent,
getSessionActivitySnapshot, getSessionActivitySnapshot,
getSessionStateSnapshot, getSessionStateSnapshot,
getSessionAttentionSnapshot, getSessionAttentionSnapshot,
@@ -158,6 +160,35 @@ export const registerNotificationRoutes = (app, dependencies) => {
}); });
}); });
app.get('/api/notifications/stream', async (req, res) => {
const uiToken = uiAuthController?.ensureSessionToken
? await uiAuthController.ensureSessionToken(req, res)
: getUiSessionTokenFromRequest(req);
if (!uiToken) {
return;
}
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
res.setHeader('Cache-Control', 'no-cache, no-transform');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders?.();
const clients = getUiNotificationClients();
clients.add(res);
try {
writeSseEvent(res, {
type: 'openchamber:notification-stream-ready',
properties: { uiToken },
});
} catch {
}
req.on('close', () => {
clients.delete(res);
});
});
app.get('/api/session-activity', (_req, res) => { app.get('/api/session-activity', (_req, res) => {
void ensureSessionWatcher(); void ensureSessionWatcher();
res.json(getSessionActivitySnapshot()); res.json(getSessionActivitySnapshot());
+4
View File
@@ -33,6 +33,8 @@ export const createBootstrapRuntime = (dependencies) => {
removePushSubscription, removePushSubscription,
updateUiVisibility, updateUiVisibility,
isUiVisible, isUiVisible,
getUiNotificationClients,
writeSseEvent,
sessionRuntime, sessionRuntime,
setPushInitialized, setPushInitialized,
fs, fs,
@@ -84,6 +86,8 @@ export const createBootstrapRuntime = (dependencies) => {
removePushSubscription, removePushSubscription,
updateUiVisibility, updateUiVisibility,
isUiVisible, isUiVisible,
getUiNotificationClients,
writeSseEvent,
getSessionActivitySnapshot: sessionRuntime.getSessionActivitySnapshot, getSessionActivitySnapshot: sessionRuntime.getSessionActivitySnapshot,
getSessionStateSnapshot: sessionRuntime.getSessionStateSnapshot, getSessionStateSnapshot: sessionRuntime.getSessionStateSnapshot,
getSessionAttentionSnapshot: sessionRuntime.getSessionAttentionSnapshot, getSessionAttentionSnapshot: sessionRuntime.getSessionAttentionSnapshot,
+32 -43
View File
@@ -1,14 +1,27 @@
import { createOpencodeClient } from '@opencode-ai/sdk/v2';
export const createOpenCodeWatcherRuntime = (deps) => { export const createOpenCodeWatcherRuntime = (deps) => {
const { const {
waitForOpenCodePort, waitForOpenCodePort,
buildOpenCodeUrl, buildOpenCodeUrl,
getOpenCodeAuthHeaders, getOpenCodeAuthHeaders,
parseSseDataPayload,
onPayload, onPayload,
} = deps; } = deps;
let abortController = null; let abortController = null;
const unwrapGlobalEventPayload = (eventData) => {
if (!eventData || typeof eventData !== 'object') {
return null;
}
if (eventData.payload && typeof eventData.payload === 'object') {
return eventData.payload;
}
return eventData;
};
const start = async () => { const start = async () => {
if (abortController) { if (abortController) {
return; return;
@@ -23,62 +36,38 @@ export const createOpenCodeWatcherRuntime = (deps) => {
const run = async () => { const run = async () => {
while (!signal.aborted) { while (!signal.aborted) {
attempt += 1; attempt += 1;
let upstream;
let reader;
try { try {
const url = buildOpenCodeUrl('/global/event', ''); const baseUrl = buildOpenCodeUrl('/', '').replace(/\/$/, '');
upstream = await fetch(url, { const client = createOpencodeClient({
headers: { baseUrl,
Accept: 'text/event-stream', headers: getOpenCodeAuthHeaders(),
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
...getOpenCodeAuthHeaders(),
},
signal,
}); });
if (!upstream.ok || !upstream.body) { const result = await client.global.event({
throw new Error(`bad status ${upstream.status}`); signal,
} sseMaxRetryAttempts: 0,
onSseEvent: (event) => {
const payload = unwrapGlobalEventPayload(event.data);
if (!payload || typeof payload !== 'object') {
return;
}
onPayload(payload);
},
});
console.log('[PushWatcher] connected'); console.log('[PushWatcher] connected');
const decoder = new TextDecoder(); for await (const _ of result.stream) {
reader = upstream.body.getReader(); void _;
let buffer = ''; if (signal.aborted) {
while (!signal.aborted) {
const { value, done } = await reader.read();
if (done) {
break; break;
} }
buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, '\n');
let separatorIndex = buffer.indexOf('\n\n');
while (separatorIndex !== -1) {
const block = buffer.slice(0, separatorIndex);
buffer = buffer.slice(separatorIndex + 2);
separatorIndex = buffer.indexOf('\n\n');
const payload = parseSseDataPayload(block);
onPayload(payload);
}
} }
} catch (error) { } catch (error) {
if (signal.aborted) { if (signal.aborted) {
return; return;
} }
console.warn('[PushWatcher] disconnected', error?.message ?? error); console.warn('[PushWatcher] disconnected', error?.message ?? error);
} finally {
try {
if (reader) {
await reader.cancel();
reader.releaseLock();
} else if (upstream?.body && !upstream.body.locked) {
await upstream.body.cancel();
}
} catch {
}
} }
const backoffMs = Math.min(1000 * Math.pow(2, Math.min(attempt, 5)), 30000); const backoffMs = Math.min(1000 * Math.pow(2, Math.min(attempt, 5)), 30000);