refactor(desktop): make Tauri thin shell running web sidecar (#273)
## What / Why This PR finishes the desktop refactor: the Tauri app is now a thin shell that launches the web server as a sidecar and loads the UI from `http://127.0.0.1:<port>`. All real backend logic lives in `packages/web/server/index.js`; desktop Rust keeps only native integrations (menu/dialog/notifications/updater/deep-link + window chrome). This unblocks: - consistent behavior across web/desktop/vscode (single backend) - simpler desktop maintenance (no duplicated Rust backend) - host switching between Local + remote instances in desktop - reliable cold-start behavior on slow machines (VSCode + desktop) ## Key changes - Desktop sidecar runtime - build pipeline to bundle web dist + `openchamber-server` sidecar (`packages/desktop/scripts/build-sidecar.mjs`) - robust local port selection (prefer saved/default, fallback to random; persisted in `~/.config/openchamber/settings.json`) - improved PATH handling so the sidecar can locate `opencode` CLI (incl `~/.opencode/bin`, overrides, common bins) - disable native right-click context menu in production builds (dev keeps it) - Desktop instance switcher (Tauri-only) - header button + modal to add/edit/delete remote hosts, set default, probe status/ping, switch back to Local escape hatch - auth gate includes host switcher so you can recover when a remote host is broken/auth-required - host list stored desktop-locally (not tied to the currently selected remote server) - Notifications - decision logic moved server-side; desktop notifications emitted via sidecar stdout and shown natively by Tauri - prevent double-notifications on desktop Local origin (UI ignores SSE notification when native path is active) - restore macOS notification sound - Updates - Tauri updater used only when viewing Local instance in desktop shell (avoid “remote web update” triggering desktop restart) - Settings persistence & UX polish - persist model favorites/recents via `/api/config/settings` (works for web + desktop; not origin-dependent) - persist per-project sidebar collapse state in `projects[].sidebarCollapsed` via `/api/config/settings` (with debounce on toggles) - macOS header sizing/traffic-lights offsets fixed (marketing macOS major injected from desktop; MultiRun header aligned) - VSCode cold-start: keep retrying provider/agent loads after connection to avoid empty UI on slow machines - misc lint/type fixes + bun.lock sync - Desktop bootstrap / resiliency - show onboarding screen when OpenCode CLI is missing (desktop Local origin), with retry hook to restart OpenCode after install ## Testing notes - Desktop (macOS): switch Local <-> remote, set default host, verify auth gate recovery, native notifications (with sound), updater gated to Local - Web: favorites/recents + per-project collapsed state persist across reload/restart - VSCode: slow startup no longer results in missing providers/agents/models
This commit is contained in:
committed by
GitHub
parent
b733f26aed
commit
83ffb1af34
+334
-48
@@ -16,6 +16,8 @@ const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const DEFAULT_PORT = 3000;
|
||||
const DESKTOP_NOTIFY_PREFIX = '[OpenChamberDesktopNotify] ';
|
||||
const uiNotificationClients = new Set();
|
||||
const HEALTH_CHECK_INTERVAL = 15000;
|
||||
const SHUTDOWN_TIMEOUT = 10000;
|
||||
const MODELS_DEV_API_URL = 'https://models.dev/api.json';
|
||||
@@ -796,6 +798,29 @@ const normalizeStringArray = (input) => {
|
||||
);
|
||||
};
|
||||
|
||||
const sanitizeModelRefs = (input, limit) => {
|
||||
if (!Array.isArray(input)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const result = [];
|
||||
const seen = new Set();
|
||||
|
||||
for (const entry of input) {
|
||||
if (!entry || typeof entry !== 'object') continue;
|
||||
const providerID = typeof entry.providerID === 'string' ? entry.providerID.trim() : '';
|
||||
const modelID = typeof entry.modelID === 'string' ? entry.modelID.trim() : '';
|
||||
if (!providerID || !modelID) continue;
|
||||
const key = `${providerID}/${modelID}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
result.push({ providerID, modelID });
|
||||
if (result.length >= limit) break;
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const sanitizeSkillCatalogs = (input) => {
|
||||
if (!Array.isArray(input)) {
|
||||
return undefined;
|
||||
@@ -884,6 +909,10 @@ const sanitizeProjects = (input) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof candidate.sidebarCollapsed === 'boolean') {
|
||||
project.sidebarCollapsed = candidate.sidebarCollapsed;
|
||||
}
|
||||
|
||||
result.push(project);
|
||||
}
|
||||
|
||||
@@ -939,6 +968,7 @@ const sanitizeSettingsUpdate = (payload) => {
|
||||
result.pinnedDirectories = normalizeStringArray(candidate.pinnedDirectories);
|
||||
}
|
||||
|
||||
|
||||
if (typeof candidate.uiFont === 'string' && candidate.uiFont.length > 0) {
|
||||
result.uiFont = candidate.uiFont;
|
||||
}
|
||||
@@ -1046,6 +1076,16 @@ const sanitizeSettingsUpdate = (payload) => {
|
||||
if (typeof candidate.inputBarOffset === 'number' && Number.isFinite(candidate.inputBarOffset)) {
|
||||
result.inputBarOffset = Math.max(0, Math.min(100, Math.round(candidate.inputBarOffset)));
|
||||
}
|
||||
|
||||
const favoriteModels = sanitizeModelRefs(candidate.favoriteModels, 64);
|
||||
if (favoriteModels) {
|
||||
result.favoriteModels = favoriteModels;
|
||||
}
|
||||
|
||||
const recentModels = sanitizeModelRefs(candidate.recentModels, 16);
|
||||
if (recentModels) {
|
||||
result.recentModels = recentModels;
|
||||
}
|
||||
if (typeof candidate.diffLayoutPreference === 'string') {
|
||||
const mode = candidate.diffLayoutPreference.trim();
|
||||
if (mode === 'dynamic' || mode === 'inline' || mode === 'side-by-side') {
|
||||
@@ -1305,14 +1345,59 @@ const migrateSettingsFromLegacyThemePreferences = async (current) => {
|
||||
return { settings: merged, changed: true };
|
||||
};
|
||||
|
||||
const migrateSettingsFromLegacyCollapsedProjects = async (current) => {
|
||||
const settings = current && typeof current === 'object' ? current : {};
|
||||
const collapsed = Array.isArray(settings.collapsedProjects)
|
||||
? normalizeStringArray(settings.collapsedProjects)
|
||||
: [];
|
||||
|
||||
if (collapsed.length === 0 || !Array.isArray(settings.projects)) {
|
||||
if (collapsed.length === 0) {
|
||||
return { settings, changed: false };
|
||||
}
|
||||
// Nothing to apply to; drop legacy key.
|
||||
const next = { ...settings };
|
||||
delete next.collapsedProjects;
|
||||
return { settings: next, changed: true };
|
||||
}
|
||||
|
||||
const set = new Set(collapsed);
|
||||
const projects = sanitizeProjects(settings.projects) || [];
|
||||
let changed = false;
|
||||
|
||||
const nextProjects = projects.map((project) => {
|
||||
const shouldCollapse = set.has(project.id);
|
||||
if (project.sidebarCollapsed !== shouldCollapse) {
|
||||
changed = true;
|
||||
return { ...project, sidebarCollapsed: shouldCollapse };
|
||||
}
|
||||
return project;
|
||||
});
|
||||
|
||||
if (!changed) {
|
||||
// Still drop legacy key if present.
|
||||
if (Object.prototype.hasOwnProperty.call(settings, 'collapsedProjects')) {
|
||||
const next = { ...settings };
|
||||
delete next.collapsedProjects;
|
||||
return { settings: next, changed: true };
|
||||
}
|
||||
return { settings, changed: false };
|
||||
}
|
||||
|
||||
const next = { ...settings, projects: nextProjects };
|
||||
delete next.collapsedProjects;
|
||||
return { settings: next, changed: true };
|
||||
};
|
||||
|
||||
const readSettingsFromDiskMigrated = async () => {
|
||||
const current = await readSettingsFromDisk();
|
||||
const migration1 = await migrateSettingsFromLegacyLastDirectory(current);
|
||||
const migration2 = await migrateSettingsFromLegacyThemePreferences(migration1.settings);
|
||||
if (migration1.changed || migration2.changed) {
|
||||
await writeSettingsToDisk(migration2.settings);
|
||||
const migration3 = await migrateSettingsFromLegacyCollapsedProjects(migration2.settings);
|
||||
if (migration1.changed || migration2.changed || migration3.changed) {
|
||||
await writeSettingsToDisk(migration3.settings);
|
||||
}
|
||||
return migration2.settings;
|
||||
return migration3.settings;
|
||||
};
|
||||
|
||||
const getOrCreateVapidKeys = async () => {
|
||||
@@ -1534,20 +1619,25 @@ const sessionActivityCooldowns = new Map(); // sessionId -> timeoutId
|
||||
const SESSION_COOLDOWN_DURATION_MS = 2000;
|
||||
|
||||
const setSessionActivityPhase = (sessionId, phase) => {
|
||||
if (!sessionId || typeof sessionId !== 'string') return;
|
||||
|
||||
// Cancel existing cooldown timer
|
||||
if (!sessionId || typeof sessionId !== 'string') return false;
|
||||
|
||||
const current = sessionActivityPhases.get(sessionId);
|
||||
if (current?.phase === phase) return false; // No change
|
||||
|
||||
// Match desktop semantics: only enter cooldown from busy.
|
||||
if (phase === 'cooldown' && current?.phase !== 'busy') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Cancel existing cooldown timer only on phase change.
|
||||
const existingTimer = sessionActivityCooldowns.get(sessionId);
|
||||
if (existingTimer) {
|
||||
clearTimeout(existingTimer);
|
||||
sessionActivityCooldowns.delete(sessionId);
|
||||
}
|
||||
|
||||
const current = sessionActivityPhases.get(sessionId);
|
||||
if (current?.phase === phase) return; // No change
|
||||
|
||||
|
||||
sessionActivityPhases.set(sessionId, { phase, updatedAt: Date.now() });
|
||||
|
||||
|
||||
// Schedule transition from cooldown to idle
|
||||
if (phase === 'cooldown') {
|
||||
const timer = setTimeout(() => {
|
||||
@@ -1559,6 +1649,8 @@ const setSessionActivityPhase = (sessionId, phase) => {
|
||||
}, SESSION_COOLDOWN_DURATION_MS);
|
||||
sessionActivityCooldowns.set(sessionId, timer);
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const getSessionActivitySnapshot = () => {
|
||||
@@ -1591,14 +1683,24 @@ const resolveVapidSubject = async () => {
|
||||
|
||||
const originEnv = process.env.OPENCHAMBER_PUBLIC_ORIGIN;
|
||||
if (typeof originEnv === 'string' && originEnv.trim().length > 0) {
|
||||
return originEnv.trim();
|
||||
const trimmed = originEnv.trim();
|
||||
// Convert http://localhost to mailto for VAPID compatibility
|
||||
if (trimmed.startsWith('http://localhost')) {
|
||||
return 'mailto:openchamber@localhost';
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
try {
|
||||
const settings = await readSettingsFromDiskMigrated();
|
||||
const stored = settings?.publicOrigin;
|
||||
if (typeof stored === 'string' && stored.trim().length > 0) {
|
||||
return stored.trim();
|
||||
const trimmed = stored.trim();
|
||||
// Convert http://localhost to mailto for VAPID compatibility
|
||||
if (trimmed.startsWith('http://localhost')) {
|
||||
return 'mailto:openchamber@localhost';
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
@@ -1751,7 +1853,8 @@ const ENV_CONFIGURED_OPENCODE_PORT = (() => {
|
||||
})();
|
||||
|
||||
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_CONFIGURED_API_PREFIX = normalizeApiPrefix(
|
||||
process.env.OPENCODE_API_PREFIX || process.env.OPENCHAMBER_API_PREFIX || ''
|
||||
@@ -1816,9 +1919,11 @@ const startGlobalEventWatcher = async () => {
|
||||
const payload = parseSseDataPayload(block);
|
||||
void maybeSendPushForTrigger(payload);
|
||||
// Track session activity independently of UI (mirrors Tauri desktop behavior)
|
||||
const activity = deriveSessionActivity(payload);
|
||||
if (activity) {
|
||||
setSessionActivityPhase(activity.sessionId, activity.phase);
|
||||
const transitions = deriveSessionActivityTransitions(payload);
|
||||
if (transitions && transitions.length > 0) {
|
||||
for (const activity of transitions) {
|
||||
setSessionActivityPhase(activity.sessionId, activity.phase);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2065,9 +2170,70 @@ function parseSseDataPayload(block) {
|
||||
}
|
||||
}
|
||||
|
||||
function deriveSessionActivity(payload) {
|
||||
function emitDesktopNotification(payload) {
|
||||
if (!ENV_DESKTOP_NOTIFY) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return null;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// One-line protocol consumed by the Tauri shell.
|
||||
process.stdout.write(`${DESKTOP_NOTIFY_PREFIX}${JSON.stringify(payload)}\n`);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function broadcastUiNotification(payload) {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (uiNotificationClients.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const res of uiNotificationClients) {
|
||||
try {
|
||||
writeSseEvent(res, {
|
||||
type: 'openchamber:notification',
|
||||
properties: payload,
|
||||
});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isStreamingAssistantPart(properties) {
|
||||
if (!properties || typeof properties !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const info = properties?.info;
|
||||
const role = info?.role;
|
||||
if (role !== 'assistant') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const part = properties?.part;
|
||||
const partType = part?.type;
|
||||
return (
|
||||
partType === 'step-start' ||
|
||||
partType === 'text' ||
|
||||
partType === 'tool' ||
|
||||
partType === 'reasoning' ||
|
||||
partType === 'file' ||
|
||||
partType === 'patch'
|
||||
);
|
||||
}
|
||||
|
||||
function deriveSessionActivityTransitions(payload) {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (payload.type === 'session.status') {
|
||||
@@ -2077,7 +2243,7 @@ function deriveSessionActivity(payload) {
|
||||
|
||||
if (typeof sessionId === 'string' && sessionId.length > 0 && typeof statusType === 'string') {
|
||||
const phase = statusType === 'busy' || statusType === 'retry' ? 'busy' : 'idle';
|
||||
return { sessionId, phase };
|
||||
return [{ sessionId, phase }];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2087,7 +2253,7 @@ function deriveSessionActivity(payload) {
|
||||
const role = info?.role;
|
||||
const finish = info?.finish;
|
||||
if (typeof sessionId === 'string' && sessionId.length > 0 && role === 'assistant' && finish === 'stop') {
|
||||
return { sessionId, phase: 'cooldown' };
|
||||
return [{ sessionId, phase: 'cooldown' }];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2096,19 +2262,32 @@ function deriveSessionActivity(payload) {
|
||||
const sessionId = info?.sessionID ?? info?.sessionId ?? payload.properties?.sessionID ?? payload.properties?.sessionId;
|
||||
const role = info?.role;
|
||||
const finish = info?.finish;
|
||||
if (typeof sessionId === 'string' && sessionId.length > 0 && role === 'assistant' && finish === 'stop') {
|
||||
return { sessionId, phase: 'cooldown' };
|
||||
|
||||
if (typeof sessionId === 'string' && sessionId.length > 0 && role === 'assistant') {
|
||||
const transitions = [];
|
||||
|
||||
// Desktop parity: mark busy when we see assistant parts streaming.
|
||||
if (isStreamingAssistantPart(payload.properties)) {
|
||||
transitions.push({ sessionId, phase: 'busy' });
|
||||
}
|
||||
|
||||
// Desktop parity: enter cooldown when finish==stop.
|
||||
if (finish === 'stop') {
|
||||
transitions.push({ sessionId, phase: 'cooldown' });
|
||||
}
|
||||
|
||||
return transitions;
|
||||
}
|
||||
}
|
||||
|
||||
if (payload.type === 'session.idle') {
|
||||
const sessionId = payload.properties?.sessionID ?? payload.properties?.sessionId;
|
||||
if (typeof sessionId === 'string' && sessionId.length > 0) {
|
||||
return { sessionId, phase: 'idle' };
|
||||
return [{ sessionId, phase: 'idle' }];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
return [];
|
||||
}
|
||||
|
||||
const PUSH_READY_COOLDOWN_MS = 5000;
|
||||
@@ -2226,6 +2405,7 @@ const maybeSendPushForTrigger = async (payload) => {
|
||||
if (info?.role === 'assistant' && info?.finish === 'stop' && sessionId) {
|
||||
// Check if this is a subtask and if we should notify for subtasks
|
||||
const settings = await readSettingsFromDisk();
|
||||
|
||||
if (settings.notifyOnSubtasks === false) {
|
||||
// Prefer parentID on payload (if present), else fetch from sessions list.
|
||||
const sessionInfo = payload.properties?.session;
|
||||
@@ -2250,6 +2430,19 @@ const maybeSendPushForTrigger = async (payload) => {
|
||||
const title = `${formatMode(info?.mode)} agent is ready`;
|
||||
const body = `${formatModelId(info?.modelID)} completed the task`;
|
||||
|
||||
if (settings.nativeNotificationsEnabled) {
|
||||
const payload = {
|
||||
title,
|
||||
body,
|
||||
tag: `ready-${sessionId}`,
|
||||
kind: 'ready',
|
||||
sessionId,
|
||||
requireHidden: settings.notificationMode !== 'always',
|
||||
};
|
||||
emitDesktopNotification(payload);
|
||||
broadcastUiNotification(payload);
|
||||
}
|
||||
|
||||
await sendPushToAllUiSessions(
|
||||
{
|
||||
title,
|
||||
@@ -2278,6 +2471,40 @@ const maybeSendPushForTrigger = async (payload) => {
|
||||
const timer = setTimeout(() => {
|
||||
pushQuestionDebounceTimers.delete(sessionId);
|
||||
|
||||
void readSettingsFromDisk().then((settings) => {
|
||||
if (!settings.nativeNotificationsEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const firstQuestion = payload.properties?.questions?.[0];
|
||||
const header = typeof firstQuestion?.header === 'string' ? firstQuestion.header.trim() : '';
|
||||
const questionText = typeof firstQuestion?.question === 'string' ? firstQuestion.question.trim() : '';
|
||||
const title = /plan\s*mode/i.test(header)
|
||||
? 'Switch to plan mode'
|
||||
: /build\s*agent/i.test(header)
|
||||
? 'Switch to build mode'
|
||||
: header || 'Input needed';
|
||||
const body = questionText || 'Agent is waiting for your response';
|
||||
|
||||
emitDesktopNotification({
|
||||
kind: 'question',
|
||||
title,
|
||||
body,
|
||||
tag: `question-${sessionId}`,
|
||||
sessionId,
|
||||
requireHidden: settings.notificationMode !== 'always',
|
||||
});
|
||||
|
||||
broadcastUiNotification({
|
||||
kind: 'question',
|
||||
title,
|
||||
body,
|
||||
tag: `question-${sessionId}`,
|
||||
sessionId,
|
||||
requireHidden: settings.notificationMode !== 'always',
|
||||
});
|
||||
});
|
||||
|
||||
const firstQuestion = payload.properties?.questions?.[0];
|
||||
const header = typeof firstQuestion?.header === 'string' ? firstQuestion.header.trim() : '';
|
||||
const questionText = typeof firstQuestion?.question === 'string' ? firstQuestion.question.trim() : '';
|
||||
@@ -2322,6 +2549,36 @@ const maybeSendPushForTrigger = async (payload) => {
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
pushPermissionDebounceTimers.delete(sessionId);
|
||||
void readSettingsFromDisk().then((settings) => {
|
||||
if (!settings.nativeNotificationsEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const title = 'Permission required';
|
||||
const sessionTitle = payload.properties?.sessionTitle;
|
||||
const body = typeof sessionTitle === 'string' && sessionTitle.trim().length > 0
|
||||
? sessionTitle.trim()
|
||||
: 'Agent is waiting for your approval';
|
||||
|
||||
emitDesktopNotification({
|
||||
kind: 'permission',
|
||||
title,
|
||||
body,
|
||||
tag: requestKey ? `permission-${requestKey}` : `permission-${sessionId}`,
|
||||
sessionId,
|
||||
requireHidden: settings.notificationMode !== 'always',
|
||||
});
|
||||
|
||||
broadcastUiNotification({
|
||||
kind: 'permission',
|
||||
title,
|
||||
body,
|
||||
tag: requestKey ? `permission-${requestKey}` : `permission-${sessionId}`,
|
||||
sessionId,
|
||||
requireHidden: settings.notificationMode !== 'always',
|
||||
});
|
||||
});
|
||||
|
||||
if (requestKey) {
|
||||
notifiedPermissionRequests.add(requestKey);
|
||||
}
|
||||
@@ -3367,6 +3624,13 @@ async function main(options = {}) {
|
||||
res.flushHeaders();
|
||||
}
|
||||
|
||||
uiNotificationClients.add(res);
|
||||
const cleanupClient = () => {
|
||||
uiNotificationClients.delete(res);
|
||||
};
|
||||
req.on('close', cleanupClient);
|
||||
req.on('error', cleanupClient);
|
||||
|
||||
const heartbeatInterval = setInterval(() => {
|
||||
writeSseEvent(res, { type: 'openchamber:heartbeat', timestamp: Date.now() });
|
||||
}, 15000);
|
||||
@@ -3381,17 +3645,19 @@ async function main(options = {}) {
|
||||
|
||||
`);
|
||||
const payload = parseSseDataPayload(block);
|
||||
void maybeSendPushForTrigger(payload);
|
||||
const activity = deriveSessionActivity(payload);
|
||||
if (activity) {
|
||||
setSessionActivityPhase(activity.sessionId, activity.phase);
|
||||
writeSseEvent(res, {
|
||||
type: 'openchamber:session-activity',
|
||||
properties: {
|
||||
sessionId: activity.sessionId,
|
||||
phase: activity.phase,
|
||||
const transitions = deriveSessionActivityTransitions(payload);
|
||||
if (transitions && transitions.length > 0) {
|
||||
for (const activity of transitions) {
|
||||
if (setSessionActivityPhase(activity.sessionId, activity.phase)) {
|
||||
writeSseEvent(res, {
|
||||
type: 'openchamber:session-activity',
|
||||
properties: {
|
||||
sessionId: activity.sessionId,
|
||||
phase: activity.phase,
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3418,6 +3684,7 @@ async function main(options = {}) {
|
||||
}
|
||||
} finally {
|
||||
clearInterval(heartbeatInterval);
|
||||
cleanupClient();
|
||||
cleanup();
|
||||
try {
|
||||
res.end();
|
||||
@@ -3502,17 +3769,19 @@ async function main(options = {}) {
|
||||
|
||||
`);
|
||||
const payload = parseSseDataPayload(block);
|
||||
void maybeSendPushForTrigger(payload);
|
||||
const activity = deriveSessionActivity(payload);
|
||||
if (activity) {
|
||||
setSessionActivityPhase(activity.sessionId, activity.phase);
|
||||
writeSseEvent(res, {
|
||||
type: 'openchamber:session-activity',
|
||||
properties: {
|
||||
sessionId: activity.sessionId,
|
||||
phase: activity.phase,
|
||||
const transitions = deriveSessionActivityTransitions(payload);
|
||||
if (transitions && transitions.length > 0) {
|
||||
for (const activity of transitions) {
|
||||
if (setSessionActivityPhase(activity.sessionId, activity.phase)) {
|
||||
writeSseEvent(res, {
|
||||
type: 'openchamber:session-activity',
|
||||
properties: {
|
||||
sessionId: activity.sessionId,
|
||||
phase: activity.phase,
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -7492,7 +7761,14 @@ Context:
|
||||
scheduleOpenCodeApiDetection();
|
||||
}
|
||||
|
||||
const distPath = path.join(__dirname, '..', 'dist');
|
||||
const distPath = (() => {
|
||||
const env = typeof process.env.OPENCHAMBER_DIST_DIR === 'string' ? process.env.OPENCHAMBER_DIST_DIR.trim() : '';
|
||||
if (env) {
|
||||
return path.resolve(env);
|
||||
}
|
||||
return path.join(__dirname, '..', 'dist');
|
||||
})();
|
||||
|
||||
if (fs.existsSync(distPath)) {
|
||||
console.log(`Serving static files from ${distPath}`);
|
||||
app.use(express.static(distPath, {
|
||||
@@ -7516,13 +7792,17 @@ Context:
|
||||
|
||||
let activePort = port;
|
||||
|
||||
const bindHost = typeof process.env.OPENCHAMBER_HOST === 'string' && process.env.OPENCHAMBER_HOST.trim().length > 0
|
||||
? process.env.OPENCHAMBER_HOST.trim()
|
||||
: null;
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const onError = (error) => {
|
||||
server.off('error', onError);
|
||||
reject(error);
|
||||
};
|
||||
server.once('error', onError);
|
||||
server.listen(port, async () => {
|
||||
const onListening = async () => {
|
||||
server.off('error', onError);
|
||||
const addressInfo = server.address();
|
||||
activePort = typeof addressInfo === 'object' && addressInfo ? addressInfo.port : port;
|
||||
@@ -7559,7 +7839,13 @@ Context:
|
||||
}
|
||||
|
||||
resolve();
|
||||
});
|
||||
};
|
||||
|
||||
if (bindHost) {
|
||||
server.listen(port, bindHost, onListening);
|
||||
} else {
|
||||
server.listen(port, onListening);
|
||||
}
|
||||
});
|
||||
|
||||
if (attachSignals && !signalsAttached) {
|
||||
|
||||
Reference in New Issue
Block a user