fix desktop notifications and chat scroll stability
This commit is contained in:
@@ -407,7 +407,19 @@ const {
|
||||
|
||||
const ENV_SKIP_OPENCODE_START = process.env.OPENCODE_SKIP_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 =
|
||||
typeof process.env.OPENCODE_WSL_DISTRO === 'string' && process.env.OPENCODE_WSL_DISTRO.trim().length > 0
|
||||
? process.env.OPENCODE_WSL_DISTRO.trim()
|
||||
@@ -758,6 +770,11 @@ const bootstrapOpenCodeAtStartup = async (...args) => {
|
||||
if (openCodeLifecycleState.openCodeProcess && !openCodeLifecycleState.isExternalOpenCode) {
|
||||
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);
|
||||
|
||||
@@ -874,6 +891,7 @@ async function main(options = {}) {
|
||||
opencodeWslDistro: resolvedWslDistro || null,
|
||||
nodeBinaryResolved: resolvedNodeBinary || null,
|
||||
bunBinaryResolved: resolvedBunBinary || null,
|
||||
desktopNotifyEnabled: ENV_DESKTOP_NOTIFY,
|
||||
planModeExperimentalEnabled: PLAN_MODE_EXPERIMENT_ENABLED,
|
||||
}),
|
||||
uiPassword,
|
||||
@@ -891,6 +909,8 @@ async function main(options = {}) {
|
||||
removePushSubscription,
|
||||
updateUiVisibility,
|
||||
isUiVisible,
|
||||
getUiNotificationClients: () => uiNotificationClients,
|
||||
writeSseEvent,
|
||||
sessionRuntime,
|
||||
setPushInitialized,
|
||||
fs,
|
||||
|
||||
@@ -35,6 +35,8 @@ export const registerNotificationRoutes = (app, dependencies) => {
|
||||
removePushSubscription,
|
||||
updateUiVisibility,
|
||||
isUiVisible,
|
||||
getUiNotificationClients,
|
||||
writeSseEvent,
|
||||
getSessionActivitySnapshot,
|
||||
getSessionStateSnapshot,
|
||||
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) => {
|
||||
void ensureSessionWatcher();
|
||||
res.json(getSessionActivitySnapshot());
|
||||
|
||||
@@ -33,6 +33,8 @@ export const createBootstrapRuntime = (dependencies) => {
|
||||
removePushSubscription,
|
||||
updateUiVisibility,
|
||||
isUiVisible,
|
||||
getUiNotificationClients,
|
||||
writeSseEvent,
|
||||
sessionRuntime,
|
||||
setPushInitialized,
|
||||
fs,
|
||||
@@ -84,6 +86,8 @@ export const createBootstrapRuntime = (dependencies) => {
|
||||
removePushSubscription,
|
||||
updateUiVisibility,
|
||||
isUiVisible,
|
||||
getUiNotificationClients,
|
||||
writeSseEvent,
|
||||
getSessionActivitySnapshot: sessionRuntime.getSessionActivitySnapshot,
|
||||
getSessionStateSnapshot: sessionRuntime.getSessionStateSnapshot,
|
||||
getSessionAttentionSnapshot: sessionRuntime.getSessionAttentionSnapshot,
|
||||
|
||||
@@ -1,14 +1,27 @@
|
||||
import { createOpencodeClient } from '@opencode-ai/sdk/v2';
|
||||
|
||||
export const createOpenCodeWatcherRuntime = (deps) => {
|
||||
const {
|
||||
waitForOpenCodePort,
|
||||
buildOpenCodeUrl,
|
||||
getOpenCodeAuthHeaders,
|
||||
parseSseDataPayload,
|
||||
onPayload,
|
||||
} = deps;
|
||||
|
||||
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 () => {
|
||||
if (abortController) {
|
||||
return;
|
||||
@@ -23,62 +36,38 @@ export const createOpenCodeWatcherRuntime = (deps) => {
|
||||
const run = async () => {
|
||||
while (!signal.aborted) {
|
||||
attempt += 1;
|
||||
let upstream;
|
||||
let reader;
|
||||
try {
|
||||
const url = buildOpenCodeUrl('/global/event', '');
|
||||
upstream = await fetch(url, {
|
||||
headers: {
|
||||
Accept: 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
...getOpenCodeAuthHeaders(),
|
||||
},
|
||||
signal,
|
||||
const baseUrl = buildOpenCodeUrl('/', '').replace(/\/$/, '');
|
||||
const client = createOpencodeClient({
|
||||
baseUrl,
|
||||
headers: getOpenCodeAuthHeaders(),
|
||||
});
|
||||
|
||||
if (!upstream.ok || !upstream.body) {
|
||||
throw new Error(`bad status ${upstream.status}`);
|
||||
}
|
||||
const result = await client.global.event({
|
||||
signal,
|
||||
sseMaxRetryAttempts: 0,
|
||||
onSseEvent: (event) => {
|
||||
const payload = unwrapGlobalEventPayload(event.data);
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return;
|
||||
}
|
||||
onPayload(payload);
|
||||
},
|
||||
});
|
||||
|
||||
console.log('[PushWatcher] connected');
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
reader = upstream.body.getReader();
|
||||
let buffer = '';
|
||||
|
||||
while (!signal.aborted) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) {
|
||||
for await (const _ of result.stream) {
|
||||
void _;
|
||||
if (signal.aborted) {
|
||||
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) {
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user