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

10
package-lock.json generated
View File

@@ -3305,6 +3305,15 @@
"node": ">= 10" "node": ">= 10"
} }
}, },
"node_modules/@tauri-apps/plugin-notification": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz",
"integrity": "sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-opener": { "node_modules/@tauri-apps/plugin-opener": {
"version": "2.5.3", "version": "2.5.3",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.3.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.3.tgz",
@@ -12048,6 +12057,7 @@
"@suid/icons-material": "^0.9.0", "@suid/icons-material": "^0.9.0",
"@suid/material": "^0.19.0", "@suid/material": "^0.19.0",
"@suid/system": "^0.14.0", "@suid/system": "^0.14.0",
"@tauri-apps/plugin-notification": "^2.3.3",
"@tauri-apps/plugin-opener": "^2.5.3", "@tauri-apps/plugin-opener": "^2.5.3",
"ansi-sequence-parser": "^1.1.3", "ansi-sequence-parser": "^1.1.3",
"debug": "^4.4.3", "debug": "^4.4.3",

View File

@@ -1,4 +1,4 @@
import { BrowserWindow, dialog, ipcMain, powerSaveBlocker, type OpenDialogOptions } from "electron" import { BrowserWindow, Notification, dialog, ipcMain, powerSaveBlocker, type OpenDialogOptions } from "electron"
import type { CliProcessManager, CliStatus } from "./process-manager" import type { CliProcessManager, CliStatus } from "./process-manager"
let wakeLockId: number | null = null let wakeLockId: number | null = null
@@ -91,4 +91,23 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
} }
return { enabled: false } return { enabled: false }
}) })
ipcMain.handle(
"notifications:show",
async (_event, payload: { title?: unknown; body?: unknown }): Promise<{ ok: boolean; reason?: string }> => {
if (!Notification.isSupported()) {
return { ok: false, reason: "unsupported" }
}
const title = typeof payload?.title === "string" ? payload.title : "CodeNomad"
const body = typeof payload?.body === "string" ? payload.body : ""
try {
const notification = new Notification({ title, body })
notification.show()
return { ok: true }
} catch (error) {
return { ok: false, reason: error instanceof Error ? error.message : String(error) }
}
},
)
} }

View File

@@ -473,6 +473,14 @@ if (isMac) {
} }
app.whenReady().then(() => { app.whenReady().then(() => {
// Required for Windows notifications / taskbar grouping.
// Keep in sync with desktop app identifier.
try {
app.setAppUserModelId("ai.neuralnomads.codenomad.client")
} catch {
// ignore
}
startCli() startCli()
if (isMac) { if (isMac) {

View File

@@ -13,6 +13,7 @@ const electronAPI = {
restartCli: () => ipcRenderer.invoke("cli:restart"), restartCli: () => ipcRenderer.invoke("cli:restart"),
openDialog: (options) => ipcRenderer.invoke("dialog:open", options), openDialog: (options) => ipcRenderer.invoke("dialog:open", options),
setWakeLock: (enabled) => ipcRenderer.invoke("power:setWakeLock", Boolean(enabled)), setWakeLock: (enabled) => ipcRenderer.invoke("power:setWakeLock", Boolean(enabled)),
showNotification: (payload) => ipcRenderer.invoke("notifications:show", payload),
} }
contextBridge.exposeInMainWorld("electronAPI", electronAPI) contextBridge.exposeInMainWorld("electronAPI", electronAPI)

View File

@@ -25,6 +25,12 @@ const PreferencesSchema = z.object({
showUsageMetrics: z.boolean().default(true), showUsageMetrics: z.boolean().default(true),
autoCleanupBlankSessions: z.boolean().default(true), autoCleanupBlankSessions: z.boolean().default(true),
listeningMode: z.enum(["local", "all"]).default("local"), listeningMode: z.enum(["local", "all"]).default("local"),
// OS notifications
osNotificationsEnabled: z.boolean().default(false),
osNotificationsAllowWhenVisible: z.boolean().default(false),
notifyOnNeedsInput: z.boolean().default(true),
notifyOnIdle: z.boolean().default(true),
}) })
const RecentFolderSchema = z.object({ const RecentFolderSchema = z.object({

View File

@@ -640,6 +640,7 @@ dependencies = [
"tauri-build", "tauri-build",
"tauri-plugin-dialog", "tauri-plugin-dialog",
"tauri-plugin-keepawake", "tauri-plugin-keepawake",
"tauri-plugin-notification",
"tauri-plugin-opener", "tauri-plugin-opener",
"thiserror 1.0.69", "thiserror 1.0.69",
"url", "url",
@@ -1033,7 +1034,7 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412" checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412"
dependencies = [ dependencies = [
"libloading 0.7.4", "libloading 0.8.9",
] ]
[[package]] [[package]]
@@ -2336,6 +2337,18 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "mac-notification-sys"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65fd3f75411f4725061682ed91f131946e912859d0044d39c4ec0aac818d7621"
dependencies = [
"cc",
"objc2 0.6.3",
"objc2-foundation 0.3.2",
"time",
]
[[package]] [[package]]
name = "malloc_buf" name = "malloc_buf"
version = "0.0.6" version = "0.0.6"
@@ -2540,6 +2553,20 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "notify-rust"
version = "4.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21af20a1b50be5ac5861f74af1a863da53a11c38684d9818d82f1c42f7fdc6c2"
dependencies = [
"futures-lite 2.6.1",
"log",
"mac-notification-sys",
"serde",
"tauri-winrt-notification",
"zbus 5.12.0",
]
[[package]] [[package]]
name = "num-conv" name = "num-conv"
version = "0.1.0" version = "0.1.0"
@@ -4390,6 +4417,25 @@ dependencies = [
"thiserror 2.0.17", "thiserror 2.0.17",
] ]
[[package]]
name = "tauri-plugin-notification"
version = "2.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc"
dependencies = [
"log",
"notify-rust",
"rand 0.9.2",
"serde",
"serde_json",
"serde_repr",
"tauri",
"tauri-plugin",
"thiserror 2.0.17",
"time",
"url",
]
[[package]] [[package]]
name = "tauri-plugin-opener" name = "tauri-plugin-opener"
version = "2.5.2" version = "2.5.2"
@@ -4513,6 +4559,18 @@ dependencies = [
"toml 0.9.8", "toml 0.9.8",
] ]
[[package]]
name = "tauri-winrt-notification"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9"
dependencies = [
"quick-xml 0.37.5",
"thiserror 2.0.17",
"windows 0.61.3",
"windows-version",
]
[[package]] [[package]]
name = "tempfile" name = "tempfile"
version = "3.23.0" version = "3.23.0"

View File

@@ -23,3 +23,4 @@ dirs = "5"
tauri-plugin-opener = "2" tauri-plugin-opener = "2"
url = "2" url = "2"
tauri-plugin-keepawake = "0.1.1" tauri-plugin-keepawake = "0.1.1"
tauri-plugin-notification = "2"

View File

@@ -11,6 +11,10 @@
"core:menu:default", "core:menu:default",
"dialog:allow-open", "dialog:allow-open",
"opener:allow-default-urls", "opener:allow-default-urls",
"notification:allow-is-permission-granted",
"notification:allow-request-permission",
"notification:allow-notify",
"notification:allow-show",
"core:webview:allow-set-webview-zoom" "core:webview:allow-set-webview-zoom"
] ]
} }

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
{"main-window-native-dialogs":{"identifier":"main-window-native-dialogs","description":"Grant the main window access to required core features and native dialog commands.","remote":{"urls":["http://127.0.0.1:*","http://localhost:*","http://tauri.localhost/*","https://tauri.localhost/*"]},"local":true,"windows":["main"],"permissions":["core:default","core:menu:default","dialog:allow-open","opener:allow-default-urls","core:webview:allow-set-webview-zoom"]}} {"main-window-native-dialogs":{"identifier":"main-window-native-dialogs","description":"Grant the main window access to required core features and native dialog commands.","remote":{"urls":["http://127.0.0.1:*","http://localhost:*","http://tauri.localhost/*","https://tauri.localhost/*"]},"local":true,"windows":["main"],"permissions":["core:default","core:menu:default","dialog:allow-open","opener:allow-default-urls","notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify","notification:allow-show","core:webview:allow-set-webview-zoom"]}}

View File

@@ -2408,6 +2408,204 @@
"const": "keepawake:deny-stop", "const": "keepawake:deny-stop",
"markdownDescription": "Denies the stop command without any pre-configured scope." "markdownDescription": "Denies the stop command without any pre-configured scope."
}, },
{
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
"type": "string",
"const": "notification:default",
"markdownDescription": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`"
},
{
"description": "Enables the batch command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-batch",
"markdownDescription": "Enables the batch command without any pre-configured scope."
},
{
"description": "Enables the cancel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-cancel",
"markdownDescription": "Enables the cancel command without any pre-configured scope."
},
{
"description": "Enables the check_permissions command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-check-permissions",
"markdownDescription": "Enables the check_permissions command without any pre-configured scope."
},
{
"description": "Enables the create_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-create-channel",
"markdownDescription": "Enables the create_channel command without any pre-configured scope."
},
{
"description": "Enables the delete_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-delete-channel",
"markdownDescription": "Enables the delete_channel command without any pre-configured scope."
},
{
"description": "Enables the get_active command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-get-active",
"markdownDescription": "Enables the get_active command without any pre-configured scope."
},
{
"description": "Enables the get_pending command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-get-pending",
"markdownDescription": "Enables the get_pending command without any pre-configured scope."
},
{
"description": "Enables the is_permission_granted command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-is-permission-granted",
"markdownDescription": "Enables the is_permission_granted command without any pre-configured scope."
},
{
"description": "Enables the list_channels command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-list-channels",
"markdownDescription": "Enables the list_channels command without any pre-configured scope."
},
{
"description": "Enables the notify command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-notify",
"markdownDescription": "Enables the notify command without any pre-configured scope."
},
{
"description": "Enables the permission_state command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-permission-state",
"markdownDescription": "Enables the permission_state command without any pre-configured scope."
},
{
"description": "Enables the register_action_types command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-register-action-types",
"markdownDescription": "Enables the register_action_types command without any pre-configured scope."
},
{
"description": "Enables the register_listener command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-register-listener",
"markdownDescription": "Enables the register_listener command without any pre-configured scope."
},
{
"description": "Enables the remove_active command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-remove-active",
"markdownDescription": "Enables the remove_active command without any pre-configured scope."
},
{
"description": "Enables the request_permission command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-request-permission",
"markdownDescription": "Enables the request_permission command without any pre-configured scope."
},
{
"description": "Enables the show command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-show",
"markdownDescription": "Enables the show command without any pre-configured scope."
},
{
"description": "Denies the batch command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-batch",
"markdownDescription": "Denies the batch command without any pre-configured scope."
},
{
"description": "Denies the cancel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-cancel",
"markdownDescription": "Denies the cancel command without any pre-configured scope."
},
{
"description": "Denies the check_permissions command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-check-permissions",
"markdownDescription": "Denies the check_permissions command without any pre-configured scope."
},
{
"description": "Denies the create_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-create-channel",
"markdownDescription": "Denies the create_channel command without any pre-configured scope."
},
{
"description": "Denies the delete_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-delete-channel",
"markdownDescription": "Denies the delete_channel command without any pre-configured scope."
},
{
"description": "Denies the get_active command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-get-active",
"markdownDescription": "Denies the get_active command without any pre-configured scope."
},
{
"description": "Denies the get_pending command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-get-pending",
"markdownDescription": "Denies the get_pending command without any pre-configured scope."
},
{
"description": "Denies the is_permission_granted command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-is-permission-granted",
"markdownDescription": "Denies the is_permission_granted command without any pre-configured scope."
},
{
"description": "Denies the list_channels command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-list-channels",
"markdownDescription": "Denies the list_channels command without any pre-configured scope."
},
{
"description": "Denies the notify command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-notify",
"markdownDescription": "Denies the notify command without any pre-configured scope."
},
{
"description": "Denies the permission_state command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-permission-state",
"markdownDescription": "Denies the permission_state command without any pre-configured scope."
},
{
"description": "Denies the register_action_types command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-register-action-types",
"markdownDescription": "Denies the register_action_types command without any pre-configured scope."
},
{
"description": "Denies the register_listener command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-register-listener",
"markdownDescription": "Denies the register_listener command without any pre-configured scope."
},
{
"description": "Denies the remove_active command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-remove-active",
"markdownDescription": "Denies the remove_active command without any pre-configured scope."
},
{
"description": "Denies the request_permission command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-request-permission",
"markdownDescription": "Denies the request_permission command without any pre-configured scope."
},
{
"description": "Denies the show command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-show",
"markdownDescription": "Denies the show command without any pre-configured scope."
},
{ {
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`", "description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
"type": "string", "type": "string",

View File

@@ -2408,6 +2408,204 @@
"const": "keepawake:deny-stop", "const": "keepawake:deny-stop",
"markdownDescription": "Denies the stop command without any pre-configured scope." "markdownDescription": "Denies the stop command without any pre-configured scope."
}, },
{
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
"type": "string",
"const": "notification:default",
"markdownDescription": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`"
},
{
"description": "Enables the batch command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-batch",
"markdownDescription": "Enables the batch command without any pre-configured scope."
},
{
"description": "Enables the cancel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-cancel",
"markdownDescription": "Enables the cancel command without any pre-configured scope."
},
{
"description": "Enables the check_permissions command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-check-permissions",
"markdownDescription": "Enables the check_permissions command without any pre-configured scope."
},
{
"description": "Enables the create_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-create-channel",
"markdownDescription": "Enables the create_channel command without any pre-configured scope."
},
{
"description": "Enables the delete_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-delete-channel",
"markdownDescription": "Enables the delete_channel command without any pre-configured scope."
},
{
"description": "Enables the get_active command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-get-active",
"markdownDescription": "Enables the get_active command without any pre-configured scope."
},
{
"description": "Enables the get_pending command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-get-pending",
"markdownDescription": "Enables the get_pending command without any pre-configured scope."
},
{
"description": "Enables the is_permission_granted command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-is-permission-granted",
"markdownDescription": "Enables the is_permission_granted command without any pre-configured scope."
},
{
"description": "Enables the list_channels command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-list-channels",
"markdownDescription": "Enables the list_channels command without any pre-configured scope."
},
{
"description": "Enables the notify command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-notify",
"markdownDescription": "Enables the notify command without any pre-configured scope."
},
{
"description": "Enables the permission_state command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-permission-state",
"markdownDescription": "Enables the permission_state command without any pre-configured scope."
},
{
"description": "Enables the register_action_types command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-register-action-types",
"markdownDescription": "Enables the register_action_types command without any pre-configured scope."
},
{
"description": "Enables the register_listener command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-register-listener",
"markdownDescription": "Enables the register_listener command without any pre-configured scope."
},
{
"description": "Enables the remove_active command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-remove-active",
"markdownDescription": "Enables the remove_active command without any pre-configured scope."
},
{
"description": "Enables the request_permission command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-request-permission",
"markdownDescription": "Enables the request_permission command without any pre-configured scope."
},
{
"description": "Enables the show command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-show",
"markdownDescription": "Enables the show command without any pre-configured scope."
},
{
"description": "Denies the batch command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-batch",
"markdownDescription": "Denies the batch command without any pre-configured scope."
},
{
"description": "Denies the cancel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-cancel",
"markdownDescription": "Denies the cancel command without any pre-configured scope."
},
{
"description": "Denies the check_permissions command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-check-permissions",
"markdownDescription": "Denies the check_permissions command without any pre-configured scope."
},
{
"description": "Denies the create_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-create-channel",
"markdownDescription": "Denies the create_channel command without any pre-configured scope."
},
{
"description": "Denies the delete_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-delete-channel",
"markdownDescription": "Denies the delete_channel command without any pre-configured scope."
},
{
"description": "Denies the get_active command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-get-active",
"markdownDescription": "Denies the get_active command without any pre-configured scope."
},
{
"description": "Denies the get_pending command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-get-pending",
"markdownDescription": "Denies the get_pending command without any pre-configured scope."
},
{
"description": "Denies the is_permission_granted command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-is-permission-granted",
"markdownDescription": "Denies the is_permission_granted command without any pre-configured scope."
},
{
"description": "Denies the list_channels command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-list-channels",
"markdownDescription": "Denies the list_channels command without any pre-configured scope."
},
{
"description": "Denies the notify command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-notify",
"markdownDescription": "Denies the notify command without any pre-configured scope."
},
{
"description": "Denies the permission_state command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-permission-state",
"markdownDescription": "Denies the permission_state command without any pre-configured scope."
},
{
"description": "Denies the register_action_types command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-register-action-types",
"markdownDescription": "Denies the register_action_types command without any pre-configured scope."
},
{
"description": "Denies the register_listener command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-register-listener",
"markdownDescription": "Denies the register_listener command without any pre-configured scope."
},
{
"description": "Denies the remove_active command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-remove-active",
"markdownDescription": "Denies the remove_active command without any pre-configured scope."
},
{
"description": "Denies the request_permission command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-request-permission",
"markdownDescription": "Denies the request_permission command without any pre-configured scope."
},
{
"description": "Denies the show command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-show",
"markdownDescription": "Denies the show command without any pre-configured scope."
},
{ {
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`", "description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
"type": "string", "type": "string",

View File

@@ -75,6 +75,7 @@ fn main() {
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_keepawake::init()) .plugin(tauri_plugin_keepawake::init())
.plugin(tauri_plugin_notification::init())
.plugin(navigation_guard) .plugin(navigation_guard)
.manage(AppState { .manage(AppState {
manager: CliProcessManager::new(), manager: CliProcessManager::new(),

View File

@@ -19,6 +19,7 @@
"@suid/material": "^0.19.0", "@suid/material": "^0.19.0",
"@suid/system": "^0.14.0", "@suid/system": "^0.14.0",
"@tauri-apps/plugin-opener": "^2.5.3", "@tauri-apps/plugin-opener": "^2.5.3",
"@tauri-apps/plugin-notification": "^2.3.3",
"ansi-sequence-parser": "^1.1.3", "ansi-sequence-parser": "^1.1.3",
"debug": "^4.4.3", "debug": "^4.4.3",
"github-markdown-css": "^5.8.1", "github-markdown-css": "^5.8.1",

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 type { Instance } from "../types/instance"
import InstanceTab from "./instance-tab" import InstanceTab from "./instance-tab"
import KeyboardHint from "./keyboard-hint" 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 { keyboardRegistry } from "../lib/keyboard-registry"
import { useI18n } from "../lib/i18n" import { useI18n } from "../lib/i18n"
import { ThemeModeToggle } from "./theme-mode-toggle" 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 { interface InstanceTabsProps {
instances: Map<string, Instance> instances: Map<string, Instance>
@@ -18,6 +22,21 @@ interface InstanceTabsProps {
const InstanceTabs: Component<InstanceTabsProps> = (props) => { const InstanceTabs: Component<InstanceTabsProps> = (props) => {
const { t } = useI18n() 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 ( return (
<div class="tab-bar tab-bar-instance"> <div class="tab-bar tab-bar-instance">
<div class="tab-container" role="tablist"> <div class="tab-container" role="tablist">
@@ -54,6 +73,16 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
</div> </div>
</Show> </Show>
<ThemeModeToggle class="new-tab-button" /> <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)}> <Show when={Boolean(props.onOpenRemoteAccess)}>
<button <button
class="new-tab-button tab-remote-button" class="new-tab-button tab-remote-button"
@@ -67,6 +96,8 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
</div> </div>
</div> </div>
</div> </div>
<NotificationsSettingsModal open={notificationsOpen()} onClose={() => setNotificationsOpen(false)} />
</div> </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 showUsageMetrics: boolean
autoCleanupBlankSessions: boolean autoCleanupBlankSessions: boolean
listeningMode: ListeningMode listeningMode: ListeningMode
// OS notifications
osNotificationsEnabled: boolean
osNotificationsAllowWhenVisible: boolean
notifyOnNeedsInput: boolean
notifyOnIdle: boolean
} }
@@ -85,6 +91,11 @@ const defaultPreferences: Preferences = {
showUsageMetrics: true, showUsageMetrics: true,
autoCleanupBlankSessions: true, autoCleanupBlankSessions: true,
listeningMode: "local", 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, showUsageMetrics: sanitized.showUsageMetrics ?? defaultPreferences.showUsageMetrics,
autoCleanupBlankSessions: sanitized.autoCleanupBlankSessions ?? defaultPreferences.autoCleanupBlankSessions, autoCleanupBlankSessions: sanitized.autoCleanupBlankSessions ?? defaultPreferences.autoCleanupBlankSessions,
listeningMode: sanitized.listeningMode ?? defaultPreferences.listeningMode, 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 { getLogger } from "../lib/logger"
import { requestData } from "../lib/opencode-api" 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 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 { QuestionRequest } from "../types/question"
import type { EventQuestionReplied, EventQuestionRejected } from "@opencode-ai/sdk/v2" import type { EventQuestionReplied, EventQuestionRejected } from "@opencode-ai/sdk/v2"
import { showToastNotification, ToastVariant } from "../lib/notifications" import { showToastNotification, ToastVariant } from "../lib/notifications"
import { sendOsNotification } from "../lib/os-notifications"
import { preferences } from "./preferences"
import { import {
instances, instances,
addPermissionToQueue, addPermissionToQueue,
@@ -57,6 +64,34 @@ import type { InstanceMessageStore } from "./message-v2/instance-store"
const log = getLogger("sse") const log = getLogger("sse")
const pendingSessionFetches = new Map<string, Promise<void>>() 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 { interface TuiToastEvent {
type: "tui.toast.show" type: "tui.toast.show"
properties: { properties: {
@@ -397,6 +432,13 @@ function handleSessionIdle(instanceId: string, event: EventSessionIdle): void {
const sessionId = event.properties?.sessionID const sessionId = event.properties?.sessionID
if (!sessionId) return 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) ensureSessionStatus(instanceId, sessionId, "idle", (event as any)?.directory)
log.info(`[SSE] Session idle: ${sessionId}`) 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)})`) log.info(`[SSE] Permission request: ${getPermissionId(permission)} (${getPermissionKind(permission)})`)
addPermissionToQueue(instanceId, permission) addPermissionToQueue(instanceId, permission)
upsertPermissionV2(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 { 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)}`) log.info(`[SSE] Question asked: ${getQuestionId(request)}`)
addQuestionToQueue(instanceId, request) addQuestionToQueue(instanceId, request)
upsertQuestionV2(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( function handleQuestionAnswered(

View File

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