feat(ui): add session status notifications

This commit is contained in:
Shantur Rathore
2026-02-09 00:42:33 +00:00
parent 0d4a4ccad7
commit 5bde55f8d4
20 changed files with 1058 additions and 9 deletions

View File

@@ -1,11 +1,15 @@
import { Component, For, Show } from "solid-js"
import { Component, For, Show, createMemo, createSignal } from "solid-js"
import { Dynamic } from "solid-js/web"
import type { Instance } from "../types/instance"
import InstanceTab from "./instance-tab"
import KeyboardHint from "./keyboard-hint"
import { Plus, MonitorUp } from "lucide-solid"
import { Plus, MonitorUp, Bell, BellOff } from "lucide-solid"
import { keyboardRegistry } from "../lib/keyboard-registry"
import { useI18n } from "../lib/i18n"
import { ThemeModeToggle } from "./theme-mode-toggle"
import NotificationsSettingsModal from "./notifications-settings-modal"
import { isOsNotificationSupportedSync } from "../lib/os-notifications"
import { useConfig } from "../stores/preferences"
interface InstanceTabsProps {
instances: Map<string, Instance>
@@ -18,6 +22,21 @@ interface InstanceTabsProps {
const InstanceTabs: Component<InstanceTabsProps> = (props) => {
const { t } = useI18n()
const { preferences } = useConfig()
const [notificationsOpen, setNotificationsOpen] = createSignal(false)
const notificationsSupported = createMemo(() => isOsNotificationSupportedSync())
const notificationsEnabled = createMemo(() => Boolean(preferences().osNotificationsEnabled))
const notificationIcon = createMemo(() => {
if (!notificationsSupported()) return BellOff
return notificationsEnabled() ? Bell : BellOff
})
const notificationTitle = createMemo(() => {
if (!notificationsSupported()) return "Notifications unsupported"
return notificationsEnabled() ? "Notifications enabled" : "Notifications disabled"
})
return (
<div class="tab-bar tab-bar-instance">
<div class="tab-container" role="tablist">
@@ -54,6 +73,16 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
</div>
</Show>
<ThemeModeToggle class="new-tab-button" />
<button
class={`new-tab-button ${!notificationsSupported() ? "opacity-50" : ""}`}
onClick={() => setNotificationsOpen(true)}
title={notificationTitle()}
aria-label={notificationTitle()}
>
<Dynamic component={notificationIcon()} class="w-4 h-4" />
</button>
<Show when={Boolean(props.onOpenRemoteAccess)}>
<button
class="new-tab-button tab-remote-button"
@@ -67,6 +96,8 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
</div>
</div>
</div>
<NotificationsSettingsModal open={notificationsOpen()} onClose={() => setNotificationsOpen(false)} />
</div>
)

View File

@@ -0,0 +1,232 @@
import { Dialog } from "@kobalte/core/dialog"
import { Component, Show, createEffect, createResource } from "solid-js"
import { showToastNotification } from "../lib/notifications"
import {
getOsNotificationCapability,
requestOsNotificationPermission,
type OsNotificationPermission,
} from "../lib/os-notifications"
import { useConfig } from "../stores/preferences"
interface NotificationsSettingsModalProps {
open: boolean
onClose: () => void
}
function formatPermissionLabel(permission: OsNotificationPermission): string {
switch (permission) {
case "granted":
return "Granted"
case "denied":
return "Denied"
case "default":
return "Not granted"
case "unsupported":
return "Unsupported"
default:
return String(permission)
}
}
const NotificationsSettingsModal: Component<NotificationsSettingsModalProps> = (props) => {
const { preferences, updatePreferences } = useConfig()
const [capability, { refetch }] = createResource(() => getOsNotificationCapability())
createEffect(() => {
if (props.open) {
void refetch()
}
})
const handleEnableToggle = async (enabled: boolean) => {
if (!enabled) {
updatePreferences({ osNotificationsEnabled: false })
return
}
const cap = capability()
if (cap && !cap.supported) {
showToastNotification({
title: "Notifications",
message: cap.info ?? "OS notifications are not supported in this environment.",
variant: "warning",
})
updatePreferences({ osNotificationsEnabled: false })
return
}
const permission = await requestOsNotificationPermission()
if (permission !== "granted") {
showToastNotification({
title: "Notifications",
message:
permission === "denied"
? "Notification permission denied. Enable notifications in your system/browser settings."
: "Notification permission not granted.",
variant: "warning",
})
updatePreferences({ osNotificationsEnabled: false })
return
}
updatePreferences({ osNotificationsEnabled: true })
void refetch()
}
const handleRequestPermission = async () => {
const cap = capability()
if (cap && !cap.supported) {
showToastNotification({
title: "Notifications",
message: cap.info ?? "Notifications are not supported in this environment.",
variant: "warning",
})
return
}
const permission = await requestOsNotificationPermission()
if (permission === "granted") {
showToastNotification({
title: "Notifications",
message: "Permission granted. You can now enable notifications.",
variant: "success",
duration: 6000,
})
void refetch()
return
}
showToastNotification({
title: "Notifications",
message:
permission === "denied"
? "Permission denied. You may need to enable notifications in your system/browser settings."
: "Permission not granted.",
variant: "warning",
})
void refetch()
}
const supported = () => capability()?.supported ?? false
const permissionLabel = () => formatPermissionLabel(capability()?.permission ?? "unsupported")
const infoMessage = () => capability()?.info
return (
<Dialog open={props.open} onOpenChange={(open) => !open && props.onClose()}>
<Dialog.Portal>
<Dialog.Overlay class="modal-overlay" />
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<Dialog.Content class="modal-surface w-full max-w-xl max-h-[90vh] flex flex-col overflow-hidden">
<header class="px-6 py-4 border-b" style={{ "border-color": "var(--border-base)" }}>
<Dialog.Title class="text-xl font-semibold text-primary">Notifications</Dialog.Title>
</header>
<div class="flex-1 overflow-y-auto p-6 space-y-6">
<div class="panel">
<div class="panel-header">
<h3 class="panel-title">Session Status Notifications</h3>
</div>
<div class="panel-body space-y-4">
<div class="flex items-center justify-between gap-4">
<div>
<div class="text-sm font-semibold text-primary">Enable</div>
<div class="text-xs text-secondary">Permission: {permissionLabel()}</div>
</div>
<label class="inline-flex items-center gap-2">
<input
type="checkbox"
checked={Boolean(preferences().osNotificationsEnabled)}
disabled={!supported() && capability.state === "ready"}
onChange={(e) => void handleEnableToggle(e.currentTarget.checked)}
/>
<span class="text-sm">Enabled</span>
</label>
</div>
<Show when={supported() && (capability()?.permission ?? "unsupported") !== "granted"}>
<div class="flex items-center justify-between gap-4">
<div class="text-sm text-primary">Request permission</div>
<button
type="button"
class="selector-button selector-button-secondary w-auto whitespace-nowrap"
onClick={() => void handleRequestPermission()}
>
Request
</button>
</div>
</Show>
<div class="flex items-center justify-between gap-4">
<div>
<div class="text-sm font-semibold text-primary">Notify when app is focused</div>
</div>
<label class="inline-flex items-center gap-2">
<input
type="checkbox"
checked={Boolean(preferences().osNotificationsAllowWhenVisible)}
disabled={!preferences().osNotificationsEnabled}
onChange={(e) => updatePreferences({ osNotificationsAllowWhenVisible: e.currentTarget.checked })}
/>
<span class="text-sm">Enabled</span>
</label>
</div>
<Show when={Boolean(infoMessage())}>
<div class="text-xs text-secondary">{infoMessage()}</div>
</Show>
<Show when={!supported() && capability.state === "ready"}>
<div class="text-xs text-secondary">
Notifications are not supported in this environment. The bell icon stays disabled.
</div>
</Show>
<div class="border-t pt-4" style={{ "border-color": "var(--border-base)" }}>
<div class="text-sm font-semibold text-primary mb-2">Notify me when</div>
<div class="space-y-3">
<div class="flex items-center justify-between gap-4">
<div class="text-sm text-primary">Session needs input</div>
<label class="inline-flex items-center gap-2">
<input
type="checkbox"
checked={Boolean(preferences().notifyOnNeedsInput)}
disabled={!preferences().osNotificationsEnabled}
onChange={(e) => updatePreferences({ notifyOnNeedsInput: e.currentTarget.checked })}
/>
<span class="text-sm">Enabled</span>
</label>
</div>
<div class="flex items-center justify-between gap-4">
<div class="text-sm text-primary">Session becomes idle</div>
<label class="inline-flex items-center gap-2">
<input
type="checkbox"
checked={Boolean(preferences().notifyOnIdle)}
disabled={!preferences().osNotificationsEnabled}
onChange={(e) => updatePreferences({ notifyOnIdle: e.currentTarget.checked })}
/>
<span class="text-sm">Enabled</span>
</label>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="px-6 py-4 border-t flex justify-end" style={{ "border-color": "var(--border-base)" }}>
<button type="button" class="selector-button selector-button-secondary" onClick={props.onClose}>
Close
</button>
</div>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog>
)
}
export default NotificationsSettingsModal

View File

@@ -0,0 +1,204 @@
import { isElectronHost, isTauriHost } from "./runtime-env"
import { getLogger } from "./logger"
export type OsNotificationPermission = "granted" | "denied" | "default" | "unsupported"
export type OsNotificationCapability = {
supported: boolean
permission: OsNotificationPermission
info?: string
}
export type OsNotificationPayload = {
title: string
body: string
}
const log = getLogger("actions")
function hasWebNotificationApi(): boolean {
return typeof window !== "undefined" && typeof (window as any).Notification !== "undefined"
}
function getWebPermission(): OsNotificationPermission {
if (!hasWebNotificationApi()) return "unsupported"
const permission = (window as any).Notification.permission as string
if (permission === "granted") return "granted"
if (permission === "denied") return "denied"
return "default"
}
async function requestWebPermission(): Promise<OsNotificationPermission> {
if (!hasWebNotificationApi()) return "unsupported"
try {
const next = await (window as any).Notification.requestPermission()
if (next === "granted") return "granted"
if (next === "denied") return "denied"
return "default"
} catch (error) {
log.warn("[os-notifications] requestPermission failed", error)
return getWebPermission()
}
}
async function sendWebNotification(payload: OsNotificationPayload): Promise<void> {
if (!hasWebNotificationApi()) {
throw new Error("Web notifications not supported")
}
// Browsers generally require permission prior to sending.
if (getWebPermission() !== "granted") {
throw new Error("Web notification permission not granted")
}
// eslint-disable-next-line no-new
new (window as any).Notification(payload.title, { body: payload.body })
}
function hasElectronNotifier(): boolean {
if (typeof window === "undefined") return false
const api = (window as Window & { electronAPI?: any }).electronAPI
return Boolean(api && typeof api.showNotification === "function")
}
export function isOsNotificationSupportedSync(): boolean {
if (typeof window === "undefined") return false
if (isElectronHost()) {
return hasElectronNotifier()
}
if (isTauriHost()) {
// The authoritative check requires async import; treat Tauri as supported and let the
// settings modal surface missing plugin/capability errors.
return true
}
return hasWebNotificationApi()
}
async function sendElectronNotification(payload: OsNotificationPayload): Promise<void> {
const api = (window as Window & { electronAPI?: any }).electronAPI
if (!api || typeof api.showNotification !== "function") {
throw new Error("Electron notification bridge unavailable")
}
await api.showNotification(payload)
}
async function getTauriNotificationModule(): Promise<any | null> {
try {
const mod = await import("@tauri-apps/plugin-notification")
return mod
} catch (error) {
log.info("[os-notifications] tauri notification plugin not available", error as any)
return null
}
}
async function getTauriPermission(): Promise<OsNotificationPermission> {
const mod = await getTauriNotificationModule()
if (!mod) return "unsupported"
try {
const granted = await mod.isPermissionGranted()
return granted ? "granted" : "default"
} catch (error) {
log.warn("[os-notifications] failed to check tauri notification permission", error)
return "default"
}
}
async function requestTauriPermission(): Promise<OsNotificationPermission> {
const mod = await getTauriNotificationModule()
if (!mod) return "unsupported"
try {
const result = await mod.requestPermission()
if (result === "granted") return "granted"
if (result === "denied") return "denied"
return "default"
} catch (error) {
log.warn("[os-notifications] failed to request tauri notification permission", error)
return await getTauriPermission()
}
}
async function sendTauriNotification(payload: OsNotificationPayload): Promise<void> {
const mod = await getTauriNotificationModule()
if (!mod) {
throw new Error("Tauri notification plugin unavailable")
}
await mod.sendNotification({ title: payload.title, body: payload.body })
}
export async function getOsNotificationCapability(): Promise<OsNotificationCapability> {
if (typeof window === "undefined") {
return { supported: false, permission: "unsupported", info: "Not available in this environment." }
}
if (isElectronHost()) {
if (!hasElectronNotifier()) {
return {
supported: false,
permission: "unsupported",
info: "Electron notification bridge is not available.",
}
}
// Electron notifications are controlled by OS-level settings; Electron doesn't expose a reliable permission probe.
return {
supported: true,
permission: "granted",
info: "Notifications are managed by your OS notification settings.",
}
}
if (isTauriHost()) {
const permission = await getTauriPermission()
const supported = permission !== "unsupported"
return {
supported,
permission,
info: supported ? undefined : "Tauri notification support is not available in this build.",
}
}
// Web
const permission = getWebPermission()
const supported = permission !== "unsupported"
return {
supported,
permission,
info: supported
? undefined
: "This browser does not support OS notifications (or notifications are blocked by the environment).",
}
}
export async function requestOsNotificationPermission(): Promise<OsNotificationPermission> {
if (typeof window === "undefined") return "unsupported"
if (isElectronHost()) {
// Electron permissions are handled by the OS. No explicit request mechanism.
return hasElectronNotifier() ? "granted" : "unsupported"
}
if (isTauriHost()) {
return await requestTauriPermission()
}
return await requestWebPermission()
}
export async function sendOsNotification(payload: OsNotificationPayload): Promise<void> {
if (typeof window === "undefined") {
return
}
if (isElectronHost()) {
await sendElectronNotification(payload)
return
}
if (isTauriHost()) {
await sendTauriNotification(payload)
return
}
await sendWebNotification(payload)
}

View File

@@ -49,6 +49,12 @@ export interface Preferences {
showUsageMetrics: boolean
autoCleanupBlankSessions: boolean
listeningMode: ListeningMode
// OS notifications
osNotificationsEnabled: boolean
osNotificationsAllowWhenVisible: boolean
notifyOnNeedsInput: boolean
notifyOnIdle: boolean
}
@@ -85,6 +91,11 @@ const defaultPreferences: Preferences = {
showUsageMetrics: true,
autoCleanupBlankSessions: true,
listeningMode: "local",
osNotificationsEnabled: false,
osNotificationsAllowWhenVisible: false,
notifyOnNeedsInput: true,
notifyOnIdle: true,
}
@@ -135,6 +146,12 @@ function normalizePreferences(pref?: Partial<Preferences> & { agentModelSelectio
showUsageMetrics: sanitized.showUsageMetrics ?? defaultPreferences.showUsageMetrics,
autoCleanupBlankSessions: sanitized.autoCleanupBlankSessions ?? defaultPreferences.autoCleanupBlankSessions,
listeningMode: sanitized.listeningMode ?? defaultPreferences.listeningMode,
osNotificationsEnabled: sanitized.osNotificationsEnabled ?? defaultPreferences.osNotificationsEnabled,
osNotificationsAllowWhenVisible:
sanitized.osNotificationsAllowWhenVisible ?? defaultPreferences.osNotificationsAllowWhenVisible,
notifyOnNeedsInput: sanitized.notifyOnNeedsInput ?? defaultPreferences.notifyOnNeedsInput,
notifyOnIdle: sanitized.notifyOnIdle ?? defaultPreferences.notifyOnIdle,
}
}

View File

@@ -16,12 +16,19 @@ import type { MessageStatus } from "./message-v2/types"
import { getLogger } from "../lib/logger"
import { requestData } from "../lib/opencode-api"
import { getPermissionId, getPermissionKind, getRequestIdFromPermissionReply } from "../types/permission"
import {
getPermissionId,
getPermissionKind,
getPermissionSessionId,
getRequestIdFromPermissionReply,
} from "../types/permission"
import type { PermissionReplyEventPropertiesLike, PermissionRequestLike } from "../types/permission"
import { getQuestionId, getRequestIdFromQuestionReply } from "../types/question"
import { getQuestionId, getQuestionSessionId, getRequestIdFromQuestionReply } from "../types/question"
import type { QuestionRequest } from "../types/question"
import type { EventQuestionReplied, EventQuestionRejected } from "@opencode-ai/sdk/v2"
import { showToastNotification, ToastVariant } from "../lib/notifications"
import { sendOsNotification } from "../lib/os-notifications"
import { preferences } from "./preferences"
import {
instances,
addPermissionToQueue,
@@ -57,6 +64,34 @@ import type { InstanceMessageStore } from "./message-v2/instance-store"
const log = getLogger("sse")
const pendingSessionFetches = new Map<string, Promise<void>>()
function shouldSendOsNotification(kind: "needsInput" | "idle"): boolean {
if (typeof document === "undefined") return false
const pref = preferences()
if (!pref.osNotificationsEnabled) return false
if (!pref.osNotificationsAllowWhenVisible && document.visibilityState === "visible") return false
if (kind === "needsInput") return Boolean(pref.notifyOnNeedsInput)
if (kind === "idle") return Boolean(pref.notifyOnIdle)
return false
}
function getInstanceDisplayName(instanceId: string): string {
const instanceFolder = instances().get(instanceId)?.folder ?? instanceId
return instanceFolder.split(/[\\/]/).filter(Boolean).pop() ?? instanceFolder
}
function getSessionTitle(instanceId: string, sessionId: string | undefined | null): string {
if (!sessionId) return ""
const session = sessions().get(instanceId)?.get(sessionId)
const title = session?.title?.trim()
return title && title.length > 0 ? title : sessionId
}
function fireOsNotification(payload: { title: string; body: string }) {
void sendOsNotification(payload).catch((error) => {
log.warn("Failed to send OS notification", error)
})
}
interface TuiToastEvent {
type: "tui.toast.show"
properties: {
@@ -397,6 +432,13 @@ function handleSessionIdle(instanceId: string, event: EventSessionIdle): void {
const sessionId = event.properties?.sessionID
if (!sessionId) return
if (shouldSendOsNotification("idle")) {
const title = getInstanceDisplayName(instanceId)
const label = getSessionTitle(instanceId, sessionId)
const body = label ? `Session "${label}" is idle` : "Session is idle"
fireOsNotification({ title, body })
}
ensureSessionStatus(instanceId, sessionId, "idle", (event as any)?.directory)
log.info(`[SSE] Session idle: ${sessionId}`)
}
@@ -504,6 +546,14 @@ function handlePermissionUpdated(instanceId: string, event: { type: string; prop
log.info(`[SSE] Permission request: ${getPermissionId(permission)} (${getPermissionKind(permission)})`)
addPermissionToQueue(instanceId, permission)
upsertPermissionV2(instanceId, permission)
if (shouldSendOsNotification("needsInput")) {
const title = getInstanceDisplayName(instanceId)
const sessionId = getPermissionSessionId(permission)
const label = getSessionTitle(instanceId, sessionId)
const body = label ? `Session "${label}" needs permission` : "Session needs permission"
fireOsNotification({ title, body })
}
}
function handlePermissionReplied(instanceId: string, event: { type: string; properties?: PermissionReplyEventPropertiesLike } | any): void {
@@ -523,6 +573,14 @@ function handleQuestionAsked(instanceId: string, event: { type: string; properti
log.info(`[SSE] Question asked: ${getQuestionId(request)}`)
addQuestionToQueue(instanceId, request)
upsertQuestionV2(instanceId, request)
if (shouldSendOsNotification("needsInput")) {
const title = getInstanceDisplayName(instanceId)
const sessionId = getQuestionSessionId(request)
const label = getSessionTitle(instanceId, sessionId)
const body = label ? `Session "${label}" needs input` : "Session needs input"
fireOsNotification({ title, body })
}
}
function handleQuestionAnswered(

View File

@@ -25,8 +25,11 @@ declare global {
onCliStatus?: (callback: (data: unknown) => void) => () => void
onCliError?: (callback: (data: unknown) => void) => () => void
getCliStatus?: () => Promise<unknown>
restartCli?: () => Promise<unknown>
openDialog?: (options: ElectronDialogOptions) => Promise<ElectronDialogResult>
setWakeLock?: (enabled: boolean) => Promise<{ enabled: boolean }>
showNotification?: (payload: { title: string; body: string }) => Promise<{ ok: boolean; reason?: string }>
}
interface TauriDialogModule {
@@ -47,4 +50,3 @@ declare global {
codenomadLogger?: LoggerControls
}
}