fix: restore vscode native notification behavior
Move VS Code/Cursor desktop notifications onto the webview Notification API instead of the extension-host watcher path, which could not reliably produce native OS notifications. Route OpenCode runtime events from the shared sync pipeline into the VS Code webview so completion, error, question, and permission notifications use the same live event stream as the UI. Respect the OpenChamber notification settings in VS Code, including template rendering, completion cooldowns, permission auto-accept suppression, and the notify-while-focused mode. Use VS Code's window focus signal from the extension host instead of document.hasFocus() inside the webview, so hidden-only notifications are suppressed while Cursor or VS Code is focused across platforms.
This commit is contained in:
@@ -6,6 +6,7 @@ import type { StoreApi } from "zustand"
|
||||
import { useStore } from "zustand"
|
||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
import { createEventPipeline } from "./event-pipeline"
|
||||
import { isVSCodeRuntime } from "@/lib/desktop"
|
||||
import { reduceGlobalEvent, applyGlobalProject, applyDirectoryEvent } from "./event-reducer"
|
||||
import { useGlobalSyncStore, type GlobalSyncStore } from "./global-sync-store"
|
||||
import { ChildStoreManager, type DirectoryStore } from "./child-store"
|
||||
@@ -308,6 +309,15 @@ type EventRoutingIndex = {
|
||||
sessionMessageIdsById: Map<string, Set<string>>
|
||||
}
|
||||
|
||||
const SHOULD_DISPATCH_VSCODE_NOTIFICATIONS = isVSCodeRuntime()
|
||||
|
||||
const dispatchVSCodeRuntimeNotificationEvent = (directory: string, payload: Event) => {
|
||||
if (!SHOULD_DISPATCH_VSCODE_NOTIFICATIONS || typeof window === "undefined") return
|
||||
window.dispatchEvent(new CustomEvent("openchamber:vscode-notification-event", {
|
||||
detail: { directory, payload },
|
||||
}))
|
||||
}
|
||||
|
||||
const createEventRoutingIndex = (): EventRoutingIndex => ({
|
||||
sessionDirectoryById: new Map(),
|
||||
messageSessionById: new Map(),
|
||||
@@ -1432,6 +1442,7 @@ export function SyncProvider(props: {
|
||||
return resolveDirectoryFromRoutingIndex(routingIndex, directory, payload, childStores)
|
||||
},
|
||||
onEvent: (directory, payload) => {
|
||||
dispatchVSCodeRuntimeNotificationEvent(directory, payload)
|
||||
if (payload.type === "installation.update-available") {
|
||||
const version = typeof (payload.properties as { version?: unknown })?.version === "string"
|
||||
? (payload.properties as { version: string }).version
|
||||
|
||||
@@ -137,6 +137,18 @@ export class AgentManagerPanelProvider {
|
||||
});
|
||||
}
|
||||
|
||||
public notifyWindowFocusChanged(focused: boolean): void {
|
||||
if (!this._panel) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._panel.webview.postMessage({
|
||||
type: 'command',
|
||||
command: 'windowFocusChanged',
|
||||
payload: { focused },
|
||||
});
|
||||
}
|
||||
|
||||
private _sendCachedState() {
|
||||
if (!this._panel) {
|
||||
return;
|
||||
@@ -147,6 +159,7 @@ export class AgentManagerPanelProvider {
|
||||
status: this._cachedStatus,
|
||||
error: this._cachedError,
|
||||
});
|
||||
this.notifyWindowFocusChanged(vscode.window.state.focused);
|
||||
}
|
||||
|
||||
private _buildSseHeaders(extra?: Record<string, string>): Record<string, string> {
|
||||
|
||||
@@ -275,6 +275,18 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
|
||||
});
|
||||
}
|
||||
|
||||
public notifyWindowFocusChanged(focused: boolean): void {
|
||||
if (!this._view) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._view.webview.postMessage({
|
||||
type: 'command',
|
||||
command: 'windowFocusChanged',
|
||||
payload: { focused },
|
||||
});
|
||||
}
|
||||
|
||||
// Message delivery confirmation
|
||||
private _confirmMessage(messageId: string) {
|
||||
this._pendingMessages.delete(messageId);
|
||||
@@ -354,6 +366,7 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
|
||||
status: this._cachedStatus,
|
||||
error: this._cachedError,
|
||||
});
|
||||
this.notifyWindowFocusChanged(vscode.window.state.focused);
|
||||
}
|
||||
|
||||
private _scheduleBroadcast(): void {
|
||||
|
||||
@@ -149,12 +149,27 @@ export class SessionEditorPanelProvider {
|
||||
}
|
||||
}
|
||||
|
||||
public notifyWindowFocusChanged(focused: boolean): void {
|
||||
for (const entry of this._panels.values()) {
|
||||
entry.panel.webview.postMessage({
|
||||
type: 'command',
|
||||
command: 'windowFocusChanged',
|
||||
payload: { focused },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private _sendCachedStateToPanel(entry: SessionPanelState) {
|
||||
entry.panel.webview.postMessage({
|
||||
type: 'connectionStatus',
|
||||
status: this._cachedStatus,
|
||||
error: this._cachedError,
|
||||
});
|
||||
entry.panel.webview.postMessage({
|
||||
type: 'command',
|
||||
command: 'windowFocusChanged',
|
||||
payload: { focused: vscode.window.state.focused },
|
||||
});
|
||||
}
|
||||
|
||||
private _disposePanel(sessionId: string) {
|
||||
|
||||
@@ -197,6 +197,14 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
})
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.window.onDidChangeWindowState((state) => {
|
||||
chatViewProvider?.notifyWindowFocusChanged(state.focused);
|
||||
sessionEditorProvider?.notifyWindowFocusChanged(state.focused);
|
||||
agentManagerProvider?.notifyWindowFocusChanged(state.focused);
|
||||
})
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('openchamber.openAgentManager', () => {
|
||||
agentManagerProvider?.createOrShow();
|
||||
|
||||
@@ -26,6 +26,19 @@ const clearGlobalEventWatcherRetry = (): void => {
|
||||
globalEventWatcherRetryTimer = null;
|
||||
};
|
||||
|
||||
const unwrapGlobalEventPayload = (eventData: unknown): Record<string, unknown> | null => {
|
||||
if (!eventData || typeof eventData !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const record = eventData as { payload?: unknown };
|
||||
if (record.payload && typeof record.payload === 'object') {
|
||||
return record.payload as Record<string, unknown>;
|
||||
}
|
||||
|
||||
return eventData as Record<string, unknown>;
|
||||
};
|
||||
|
||||
const reconcileSessionActivityFromStatus = async (manager: OpenCodeManager): Promise<void> => {
|
||||
const baseUrl = manager.getApiUrl();
|
||||
if (!baseUrl) {
|
||||
@@ -61,7 +74,6 @@ const reconcileSessionActivityFromStatus = async (manager: OpenCodeManager): Pro
|
||||
const setSessionActivityPhase = (sessionId: string, phase: ActivityPhase): void => {
|
||||
if (!sessionId) return;
|
||||
|
||||
// Cancel existing cooldown timer
|
||||
const existingTimer = sessionActivityCooldowns.get(sessionId);
|
||||
if (existingTimer) {
|
||||
clearTimeout(existingTimer);
|
||||
@@ -69,36 +81,30 @@ const setSessionActivityPhase = (sessionId: string, phase: ActivityPhase): void
|
||||
}
|
||||
|
||||
const current = sessionActivityPhases.get(sessionId);
|
||||
if (current?.phase === phase) return; // No change
|
||||
if (current?.phase === phase) return;
|
||||
|
||||
sessionActivityPhases.set(sessionId, { phase, updatedAt: Date.now() });
|
||||
|
||||
// Notify webview if available
|
||||
if (chatViewProvider) {
|
||||
chatViewProvider.postMessage({
|
||||
type: 'openchamber:session-activity',
|
||||
properties: {
|
||||
sessionId,
|
||||
phase,
|
||||
},
|
||||
});
|
||||
}
|
||||
chatViewProvider?.postMessage({
|
||||
type: 'openchamber:session-activity',
|
||||
properties: {
|
||||
sessionId,
|
||||
phase,
|
||||
},
|
||||
});
|
||||
|
||||
// Schedule transition from cooldown to idle
|
||||
if (phase === 'cooldown') {
|
||||
const timer = setTimeout(() => {
|
||||
const now = sessionActivityPhases.get(sessionId);
|
||||
if (now?.phase === 'cooldown') {
|
||||
sessionActivityPhases.set(sessionId, { phase: 'idle', updatedAt: Date.now() });
|
||||
if (chatViewProvider) {
|
||||
chatViewProvider.postMessage({
|
||||
type: 'openchamber:session-activity',
|
||||
properties: {
|
||||
sessionId,
|
||||
phase: 'idle',
|
||||
},
|
||||
});
|
||||
}
|
||||
chatViewProvider?.postMessage({
|
||||
type: 'openchamber:session-activity',
|
||||
properties: {
|
||||
sessionId,
|
||||
phase: 'idle',
|
||||
},
|
||||
});
|
||||
}
|
||||
sessionActivityCooldowns.delete(sessionId);
|
||||
}, SESSION_COOLDOWN_DURATION_MS);
|
||||
@@ -230,22 +236,19 @@ export const startGlobalEventWatcher = async (
|
||||
const result = await client.global.event({
|
||||
signal,
|
||||
sseMaxRetryAttempts: 0,
|
||||
onSseEvent: (event) => {
|
||||
const payload = event.data;
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return;
|
||||
}
|
||||
const activity = deriveSessionActivity(payload as Record<string, unknown>);
|
||||
if (activity) {
|
||||
setSessionActivityPhase(activity.sessionId, activity.phase);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
console.log('[VSCode:Activity] connected');
|
||||
|
||||
for await (const _ of result.stream) {
|
||||
void _;
|
||||
for await (const event of result.stream) {
|
||||
const payload = unwrapGlobalEventPayload((event as { payload?: unknown }).payload ?? event);
|
||||
if (payload) {
|
||||
const activity = deriveSessionActivity(payload);
|
||||
if (activity) {
|
||||
setSessionActivityPhase(activity.sessionId, activity.phase);
|
||||
}
|
||||
}
|
||||
|
||||
if (signal.aborted) {
|
||||
break;
|
||||
}
|
||||
@@ -279,7 +282,6 @@ export const stopGlobalEventWatcher = (): void => {
|
||||
globalEventWatcherAbortController = null;
|
||||
chatViewProvider = null;
|
||||
|
||||
// Clear all cooldown timers
|
||||
for (const timer of sessionActivityCooldowns.values()) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
@@ -1,23 +1,40 @@
|
||||
import type { NotificationPayload, NotificationsAPI } from '@openchamber/ui/lib/api/types';
|
||||
import { sendBridgeMessage } from './bridge';
|
||||
|
||||
type NotifyResponse = { shown?: boolean };
|
||||
const showWebviewNotification = async (payload?: NotificationPayload): Promise<boolean> => {
|
||||
if (typeof Notification === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Notification.permission === 'default') {
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission !== 'granted') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (Notification.permission !== 'granted') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const title = typeof payload?.title === 'string' && payload.title.trim().length > 0
|
||||
? payload.title.trim()
|
||||
: 'OpenChamber';
|
||||
const body = typeof payload?.body === 'string' ? payload.body : '';
|
||||
|
||||
new Notification(title, { body });
|
||||
return true;
|
||||
};
|
||||
|
||||
export const createVSCodeNotificationsAPI = (): NotificationsAPI => ({
|
||||
async notifyAgentCompletion(payload?: NotificationPayload): Promise<boolean> {
|
||||
try {
|
||||
const response = await sendBridgeMessage<NotifyResponse>('notifications:notify', { payload });
|
||||
return Boolean(response?.shown);
|
||||
return await showWebviewNotification(payload);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
async canNotify(): Promise<boolean> {
|
||||
try {
|
||||
return await sendBridgeMessage<boolean>('notifications:can-notify');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return typeof Notification !== 'undefined' && Notification.permission !== 'denied';
|
||||
},
|
||||
});
|
||||
|
||||
@@ -36,6 +36,7 @@ declare global {
|
||||
__OPENCHAMBER_CONNECTION__?: { status: ConnectionStatus; error?: string; cliAvailable?: boolean };
|
||||
__OPENCHAMBER_HOME__?: string;
|
||||
__OPENCHAMBER_PANEL_TYPE__?: PanelType;
|
||||
__OPENCHAMBER_VSCODE_WINDOW_FOCUSED__?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -435,6 +436,19 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => {
|
||||
});
|
||||
}
|
||||
|
||||
if (normalizedPathname === '/api/notifications/auto-accept' && method === 'POST') {
|
||||
const bodyText = await extractBodyText(url, init, method);
|
||||
const body = bodyText
|
||||
? JSON.parse(bodyText) as { sessionId?: unknown; enabled?: unknown }
|
||||
: {};
|
||||
const result = await sendBridgeMessage<{ success?: boolean }>('api:notifications/auto-accept', body)
|
||||
.catch(() => ({ success: false }));
|
||||
return new Response(JSON.stringify(result), {
|
||||
status: result?.success === false ? 400 : 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
if (/^\/api\/sessions\/[^/]+\/message-sent$/.test(normalizedPathname) && method === 'POST') {
|
||||
const sessionId = normalizedPathname.split('/')[3] || '';
|
||||
return new Response(
|
||||
@@ -527,7 +541,7 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => {
|
||||
});
|
||||
}
|
||||
|
||||
if ((pathname === '/api/tts/speak' || pathname === '/api/tts/say/speak' || pathname === '/api/text/summarize') && method === 'POST') {
|
||||
if ((pathname === '/api/tts/speak' || pathname === '/api/tts/say/speak') && method === 'POST') {
|
||||
return new Response(JSON.stringify({ error: 'TTS endpoints are not available in VS Code runtime' }), {
|
||||
status: 501,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -1228,6 +1242,352 @@ onCommand('showSettings', () => {
|
||||
window.dispatchEvent(new CustomEvent('openchamber:navigate', { detail: { view: 'settings' } }));
|
||||
});
|
||||
|
||||
const showOpenChamberNotification = (payload: { title?: unknown; body?: unknown; sessionId?: unknown; requireHidden?: unknown } | undefined) => {
|
||||
if (typeof Notification === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const show = () => {
|
||||
const isVSCodeWindowFocused = window.__OPENCHAMBER_VSCODE_WINDOW_FOCUSED__ ?? document.hasFocus();
|
||||
if (payload?.requireHidden === true && isVSCodeWindowFocused) {
|
||||
return false;
|
||||
}
|
||||
if (Notification.permission !== 'granted') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const title = typeof payload?.title === 'string' && payload.title.trim().length > 0
|
||||
? payload.title.trim()
|
||||
: 'OpenChamber';
|
||||
const body = typeof payload?.body === 'string' ? payload.body : '';
|
||||
const sessionId = typeof payload?.sessionId === 'string' && payload.sessionId.trim().length > 0
|
||||
? payload.sessionId.trim()
|
||||
: '';
|
||||
|
||||
const notification = new Notification(title, { body });
|
||||
notification.onclick = () => {
|
||||
if (sessionId) {
|
||||
import('@/sync/session-ui-store').then(({ useSessionUIStore }) => {
|
||||
useSessionUIStore.getState().setCurrentSession(sessionId);
|
||||
});
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent('openchamber:navigate', { detail: { view: 'chat' } }));
|
||||
};
|
||||
return true;
|
||||
};
|
||||
|
||||
if (Notification.permission === 'default') {
|
||||
void Notification.requestPermission().then((permission) => {
|
||||
if (permission === 'granted') {
|
||||
show();
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
return show();
|
||||
};
|
||||
|
||||
onCommand('showNotification', (payload) => {
|
||||
showOpenChamberNotification(payload as { title?: unknown; body?: unknown; sessionId?: unknown; requireHidden?: unknown } | undefined);
|
||||
});
|
||||
|
||||
onCommand('windowFocusChanged', (payload) => {
|
||||
if (typeof payload === 'object' && payload && typeof (payload as { focused?: unknown }).focused === 'boolean') {
|
||||
window.__OPENCHAMBER_VSCODE_WINDOW_FOCUSED__ = (payload as { focused: boolean }).focused;
|
||||
}
|
||||
});
|
||||
|
||||
const readyNotificationCooldowns = new Map<string, number>();
|
||||
const READY_NOTIFICATION_COOLDOWN_MS = 5000;
|
||||
const DEFAULT_NOTIFICATION_MESSAGE_MAX_LENGTH = 250;
|
||||
let notificationSettingsSyncPromise: Promise<void> | null = null;
|
||||
|
||||
const getPayloadString = (value: unknown): string => typeof value === 'string' ? value.trim() : '';
|
||||
|
||||
const normalizeNotificationPlainText = (text: string): string => text
|
||||
.replace(/```[\s\S]*?```/g, ' ')
|
||||
.replace(/`([^`]*)`/g, '$1')
|
||||
.replace(/^[\t ]*[-*+]\s+/gm, '')
|
||||
.replace(/^#{1,6}\s+/gm, '')
|
||||
.replace(/\*\*(.*?)\*\*/g, '$1')
|
||||
.replace(/__(.*?)__/g, '$1')
|
||||
.replace(/\*(.*?)\*/g, '$1')
|
||||
.replace(/_(.*?)_/g, '$1')
|
||||
.replace(/\[(.*?)\]\((.*?)\)/g, '$1')
|
||||
.replace(/\s*\n\s*/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
const truncateNotificationText = (text: string, maxLength: number): string => (
|
||||
text.length <= maxLength ? text : `${text.slice(0, maxLength)}...`
|
||||
);
|
||||
|
||||
const resolvePositiveNotificationNumber = (value: unknown, fallback: number): number => (
|
||||
typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback
|
||||
);
|
||||
|
||||
const ensureNotificationSettingsSynced = async () => {
|
||||
if (!notificationSettingsSyncPromise) {
|
||||
notificationSettingsSyncPromise = import('@/lib/persistence')
|
||||
.then(({ syncDesktopSettings }) => syncDesktopSettings())
|
||||
.catch((error) => {
|
||||
console.warn('[OpenChamber] Failed to sync notification settings:', error);
|
||||
});
|
||||
}
|
||||
await notificationSettingsSyncPromise;
|
||||
};
|
||||
|
||||
const prepareNotificationLastMessage = (
|
||||
message: string,
|
||||
settings: { maxLastMessageLength: number },
|
||||
): string => {
|
||||
const maxLength = resolvePositiveNotificationNumber(settings.maxLastMessageLength, DEFAULT_NOTIFICATION_MESSAGE_MAX_LENGTH);
|
||||
return truncateNotificationText(normalizeNotificationPlainText(message), maxLength);
|
||||
};
|
||||
|
||||
const resolveTemplate = (template: string, variables: Record<string, string>): string => (
|
||||
template.replace(/\{(\w+)\}/g, (_match, key: string) => variables[key] ?? '')
|
||||
);
|
||||
|
||||
const shouldApplyTemplateMessage = (template: string, resolved: string, variables: Record<string, string>) => {
|
||||
if (!resolved) return false;
|
||||
if (template.includes('{last_message}')) {
|
||||
return variables.last_message.trim().length > 0;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const formatNotificationLabel = (raw: string, fallback: string): string => {
|
||||
if (!raw) return fallback;
|
||||
return raw.split(/[-_\s]+/).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(' ');
|
||||
};
|
||||
|
||||
const extractNotificationTextFromParts = (parts: unknown): string => {
|
||||
if (!Array.isArray(parts)) return '';
|
||||
return parts
|
||||
.map((part) => {
|
||||
if (!part || typeof part !== 'object') return '';
|
||||
const entry = part as { type?: unknown; text?: unknown; content?: unknown };
|
||||
if (entry.type === 'text' || typeof entry.text === 'string' || typeof entry.content === 'string') {
|
||||
return typeof entry.text === 'string' ? entry.text : typeof entry.content === 'string' ? entry.content : '';
|
||||
}
|
||||
return '';
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
.trim();
|
||||
};
|
||||
|
||||
const extractNotificationLastMessage = (payload: Record<string, unknown>): string => {
|
||||
const properties = (payload.properties ?? payload) as Record<string, unknown>;
|
||||
const info = properties.info as Record<string, unknown> | undefined;
|
||||
if (!info) return '';
|
||||
return extractNotificationTextFromParts(info.parts ?? properties.parts) || extractNotificationTextFromParts(info.content);
|
||||
};
|
||||
|
||||
const fetchLastAssistantMessageText = async (sessionId: string, messageId?: string): Promise<string> => {
|
||||
if (!sessionId) return '';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/session/${encodeURIComponent(sessionId)}/message?limit=5`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
signal: AbortSignal.timeout(3000),
|
||||
});
|
||||
if (!response.ok) return '';
|
||||
|
||||
const messages = await response.json().catch(() => null) as unknown;
|
||||
if (!Array.isArray(messages)) return '';
|
||||
|
||||
let target = messageId
|
||||
? messages.find((message) => {
|
||||
const info = message && typeof message === 'object'
|
||||
? (message as { info?: { id?: unknown; role?: unknown } }).info
|
||||
: undefined;
|
||||
return info?.id === messageId && info?.role === 'assistant';
|
||||
})
|
||||
: null;
|
||||
|
||||
if (!target) {
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const message = messages[index];
|
||||
const info = message && typeof message === 'object'
|
||||
? (message as { info?: { role?: unknown; finish?: unknown } }).info
|
||||
: undefined;
|
||||
if (info?.role === 'assistant' && info?.finish === 'stop') {
|
||||
target = message;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!target || typeof target !== 'object') return '';
|
||||
const message = target as { parts?: unknown; content?: unknown; info?: { parts?: unknown; content?: unknown } };
|
||||
return extractNotificationTextFromParts(message.parts ?? message.info?.parts)
|
||||
|| extractNotificationTextFromParts(message.content ?? message.info?.content);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const getNotificationTemplate = (
|
||||
settings: { notificationTemplates?: Record<string, { title?: string; message?: string }> },
|
||||
key: 'completion' | 'error' | 'question',
|
||||
fallback: { title: string; message: string },
|
||||
) => {
|
||||
const candidate = settings.notificationTemplates?.[key];
|
||||
return {
|
||||
title: typeof candidate?.title === 'string' ? candidate.title : fallback.title,
|
||||
message: typeof candidate?.message === 'string' ? candidate.message : fallback.message,
|
||||
};
|
||||
};
|
||||
|
||||
const buildNotificationVariables = (payload: Record<string, unknown>, sessionId: string, lastMessage: string): Record<string, string> => {
|
||||
const properties = (payload.properties ?? payload) as Record<string, unknown>;
|
||||
const info = properties.info as Record<string, unknown> | undefined;
|
||||
const pathInfo = info?.path as { root?: unknown; cwd?: unknown } | undefined;
|
||||
const worktree = getPayloadString(pathInfo?.root ?? pathInfo?.cwd);
|
||||
const modelId = getPayloadString(info?.modelID ?? info?.modelId ?? (info?.model as { modelID?: unknown } | undefined)?.modelID);
|
||||
return {
|
||||
project_name: worktree.split(/[\\/]/).filter(Boolean).pop() || '',
|
||||
worktree,
|
||||
branch: '',
|
||||
session_name: getPayloadString(properties.sessionTitle ?? (properties.session as { title?: unknown } | undefined)?.title ?? info?.sessionTitle),
|
||||
agent_name: formatNotificationLabel(getPayloadString(info?.agent ?? info?.mode), 'Agent'),
|
||||
model_name: formatNotificationLabel(modelId, 'Assistant'),
|
||||
last_message: lastMessage,
|
||||
session_id: sessionId,
|
||||
};
|
||||
};
|
||||
|
||||
const getNotificationSessionId = (payload: Record<string, unknown>): string => {
|
||||
const properties = (payload.properties ?? payload) as Record<string, unknown>;
|
||||
const info = properties.info as Record<string, unknown> | undefined;
|
||||
return getPayloadString(info?.sessionID ?? info?.sessionId ?? properties.sessionID ?? properties.sessionId ?? properties.session);
|
||||
};
|
||||
|
||||
window.addEventListener('openchamber:vscode-notification-event', (event) => {
|
||||
const detail = (event as CustomEvent<{ payload?: unknown }>).detail;
|
||||
const payload = detail?.payload;
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
const record = payload as Record<string, unknown>;
|
||||
const type = getPayloadString(record.type);
|
||||
const properties = (record.properties ?? record) as Record<string, unknown>;
|
||||
const info = properties.info as Record<string, unknown> | undefined;
|
||||
const sessionId = getNotificationSessionId(record);
|
||||
if (!sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
Promise.all([
|
||||
import('@/stores/useUIStore'),
|
||||
import('@/stores/permissionStore'),
|
||||
]).then(async ([{ useUIStore }, { usePermissionStore }]) => {
|
||||
const localSettings = useUIStore.getState();
|
||||
await ensureNotificationSettingsSynced();
|
||||
const syncedSettings = useUIStore.getState();
|
||||
const settings = {
|
||||
...syncedSettings,
|
||||
nativeNotificationsEnabled: localSettings.nativeNotificationsEnabled,
|
||||
notificationMode: localSettings.notificationMode,
|
||||
notifyOnCompletion: localSettings.notifyOnCompletion,
|
||||
notifyOnError: localSettings.notifyOnError,
|
||||
notifyOnQuestion: localSettings.notifyOnQuestion,
|
||||
notificationTemplates: localSettings.notificationTemplates,
|
||||
summarizeLastMessage: localSettings.summarizeLastMessage,
|
||||
summaryThreshold: localSettings.summaryThreshold,
|
||||
summaryLength: localSettings.summaryLength,
|
||||
maxLastMessageLength: localSettings.maxLastMessageLength,
|
||||
};
|
||||
if (!settings.nativeNotificationsEnabled) {
|
||||
return;
|
||||
}
|
||||
const requireHidden = settings.notificationMode !== 'always';
|
||||
const messageId = getPayloadString(info?.id);
|
||||
const rawLastMessage = extractNotificationLastMessage(record) || await fetchLastAssistantMessageText(sessionId, messageId);
|
||||
const lastMessage = prepareNotificationLastMessage(
|
||||
rawLastMessage,
|
||||
settings,
|
||||
);
|
||||
const variables = buildNotificationVariables(record, sessionId, lastMessage);
|
||||
|
||||
if (type === 'message.updated' && getPayloadString(info?.role) === 'assistant') {
|
||||
const finish = getPayloadString(info?.finish);
|
||||
if (finish === 'stop') {
|
||||
if (!settings.notifyOnCompletion) return;
|
||||
const now = Date.now();
|
||||
const lastAt = readyNotificationCooldowns.get(sessionId) ?? 0;
|
||||
if (now - lastAt < READY_NOTIFICATION_COOLDOWN_MS) return;
|
||||
readyNotificationCooldowns.set(sessionId, now);
|
||||
const template = getNotificationTemplate(settings, 'completion', { title: '{agent_name} is ready', message: '{model_name} completed the task' });
|
||||
const title = resolveTemplate(template.title, variables) || 'Agent is ready';
|
||||
const body = resolveTemplate(template.message, variables);
|
||||
showOpenChamberNotification({
|
||||
title,
|
||||
body: shouldApplyTemplateMessage(template.message, body, variables) ? body : `${variables.model_name} completed the task`,
|
||||
sessionId,
|
||||
requireHidden,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (finish === 'error') {
|
||||
if (!settings.notifyOnError) return;
|
||||
const template = getNotificationTemplate(settings, 'error', { title: 'Tool error', message: '{last_message}' });
|
||||
const title = resolveTemplate(template.title, variables) || 'Tool error';
|
||||
const body = resolveTemplate(template.message, variables);
|
||||
showOpenChamberNotification({
|
||||
title,
|
||||
body: shouldApplyTemplateMessage(template.message, body, variables) ? body : 'An error occurred',
|
||||
sessionId,
|
||||
requireHidden,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 'question.asked') {
|
||||
if (!settings.notifyOnQuestion) return;
|
||||
const questions = Array.isArray(properties.questions) ? properties.questions : [];
|
||||
const firstQuestion = questions[0] as Record<string, unknown> | undefined;
|
||||
const header = getPayloadString(firstQuestion?.header);
|
||||
const questionText = getPayloadString(firstQuestion?.question);
|
||||
const questionVariables = { ...variables, last_message: questionText || header };
|
||||
const template = getNotificationTemplate(settings, 'question', { title: 'Input needed', message: '{last_message}' });
|
||||
const title = resolveTemplate(template.title, questionVariables) || (/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 = resolveTemplate(template.message, questionVariables);
|
||||
showOpenChamberNotification({
|
||||
title,
|
||||
body: shouldApplyTemplateMessage(template.message, body, questionVariables) ? body : questionText || 'Agent is waiting for your response',
|
||||
sessionId,
|
||||
requireHidden,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'permission.asked') {
|
||||
if (!settings.notifyOnQuestion) return;
|
||||
if (usePermissionStore.getState().isSessionAutoAccepting(sessionId)) return;
|
||||
const permission = getPayloadString(properties.permission);
|
||||
const sessionTitle = getPayloadString(properties.sessionTitle);
|
||||
const fallbackMessage = sessionTitle || permission || 'Agent is waiting for your approval';
|
||||
const permissionVariables = { ...variables, last_message: fallbackMessage };
|
||||
const template = getNotificationTemplate(settings, 'question', { title: 'Permission required', message: '{last_message}' });
|
||||
const title = resolveTemplate(template.title, permissionVariables) || 'Permission required';
|
||||
const body = resolveTemplate(template.message, permissionVariables);
|
||||
showOpenChamberNotification({
|
||||
title,
|
||||
body: shouldApplyTemplateMessage(template.message, body, permissionVariables) ? body : fallbackMessage,
|
||||
sessionId,
|
||||
requireHidden,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Listen for settings sync command from extension (broadcast to all VS Code webviews)
|
||||
onCommand('settingsSynced', () => {
|
||||
import('@openchamber/ui/lib/persistence').then(({ syncDesktopSettings }) => {
|
||||
|
||||
Reference in New Issue
Block a user