feat(ui): add session status notifications
This commit is contained in:
@@ -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>
|
||||
|
||||
)
|
||||
|
||||
232
packages/ui/src/components/notifications-settings-modal.tsx
Normal file
232
packages/ui/src/components/notifications-settings-modal.tsx
Normal 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
|
||||
204
packages/ui/src/lib/os-notifications.ts
Normal file
204
packages/ui/src/lib/os-notifications.ts
Normal 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)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
4
packages/ui/src/types/global.d.ts
vendored
4
packages/ui/src/types/global.d.ts
vendored
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user