Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e755b721c | ||
|
|
b244d9f98c | ||
|
|
9e3dbc5dfb | ||
|
|
4cf980fb97 | ||
|
|
5bde55f8d4 | ||
|
|
0d4a4ccad7 | ||
|
|
56a0e8aa6e | ||
|
|
2a5bb6304d | ||
|
|
322a880a02 | ||
|
|
ded31078d4 |
22
package-lock.json
generated
22
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.10.1",
|
||||
"version": "0.10.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.10.1",
|
||||
"version": "0.10.2",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"7zip-bin": "^5.2.0",
|
||||
@@ -3305,6 +3305,15 @@
|
||||
"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": {
|
||||
"version": "2.5.3",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.3.tgz",
|
||||
@@ -11955,7 +11964,7 @@
|
||||
},
|
||||
"packages/electron-app": {
|
||||
"name": "@neuralnomads/codenomad-electron-app",
|
||||
"version": "0.10.1",
|
||||
"version": "0.10.2",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codenomad/ui": "file:../ui",
|
||||
@@ -11990,7 +11999,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.10.1",
|
||||
"version": "0.10.2",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^8.5.0",
|
||||
@@ -12030,7 +12039,7 @@
|
||||
},
|
||||
"packages/tauri-app": {
|
||||
"name": "@codenomad/tauri-app",
|
||||
"version": "0.10.1",
|
||||
"version": "0.10.2",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.9.4"
|
||||
@@ -12038,7 +12047,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@codenomad/ui",
|
||||
"version": "0.10.1",
|
||||
"version": "0.10.2",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@git-diff-view/solid": "^0.0.8",
|
||||
@@ -12048,6 +12057,7 @@
|
||||
"@suid/icons-material": "^0.9.0",
|
||||
"@suid/material": "^0.19.0",
|
||||
"@suid/system": "^0.14.0",
|
||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||
"ansi-sequence-parser": "^1.1.3",
|
||||
"debug": "^4.4.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.10.1",
|
||||
"version": "0.10.2",
|
||||
"private": true,
|
||||
"description": "CodeNomad monorepo workspace",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"minServerVersion": "0.10.1",
|
||||
"minServerVersion": "0.10.2",
|
||||
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
let wakeLockId: number | null = null
|
||||
@@ -91,4 +91,23 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
|
||||
}
|
||||
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) }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -473,6 +473,14 @@ if (isMac) {
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
if (isMac) {
|
||||
|
||||
@@ -381,6 +381,9 @@ export class CliProcessManager extends EventEmitter {
|
||||
if (options.dev) {
|
||||
// Dev: run plain HTTP + Vite dev server proxy.
|
||||
args.push("--https", "false", "--http", "true")
|
||||
// Avoid collisions with an already-running server (and dual-stack ::/0.0.0.0 quirks)
|
||||
// by forcing an ephemeral port in dev.
|
||||
args.push("--http-port", "0")
|
||||
} else {
|
||||
// Prod desktop: always keep loopback HTTP enabled.
|
||||
args.push("--https", "true", "--http", "true")
|
||||
|
||||
@@ -13,6 +13,7 @@ const electronAPI = {
|
||||
restartCli: () => ipcRenderer.invoke("cli:restart"),
|
||||
openDialog: (options) => ipcRenderer.invoke("dialog:open", options),
|
||||
setWakeLock: (enabled) => ipcRenderer.invoke("power:setWakeLock", Boolean(enabled)),
|
||||
showNotification: (payload) => ipcRenderer.invoke("notifications:show", payload),
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld("electronAPI", electronAPI)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad-electron-app",
|
||||
"version": "0.10.1",
|
||||
"version": "0.10.2",
|
||||
"description": "CodeNomad - AI coding assistant",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import http from "http"
|
||||
import https from "https"
|
||||
import { Readable } from "stream"
|
||||
|
||||
export type PluginEvent = {
|
||||
type: string
|
||||
properties?: Record<string, unknown>
|
||||
@@ -16,7 +20,8 @@ export function getCodeNomadConfig(): CodeNomadConfig {
|
||||
}
|
||||
|
||||
export function createCodeNomadRequester(config: CodeNomadConfig) {
|
||||
const baseUrl = config.baseUrl.replace(/\/+$/, "")
|
||||
const rawBaseUrl = (config.baseUrl ?? "").trim()
|
||||
const baseUrl = rawBaseUrl.replace(/\/+$/, "")
|
||||
const pluginBase = `${baseUrl}/workspaces/${encodeURIComponent(config.instanceId)}/plugin`
|
||||
const authorization = buildInstanceAuthorizationHeader()
|
||||
|
||||
@@ -42,10 +47,10 @@ export function createCodeNomadRequester(config: CodeNomadConfig) {
|
||||
const hasBody = init?.body !== undefined
|
||||
const headers = buildHeaders(init?.headers, hasBody)
|
||||
|
||||
return fetch(url, {
|
||||
...init,
|
||||
headers,
|
||||
})
|
||||
// The CodeNomad plugin only talks to the local CodeNomad server.
|
||||
// Use a single request implementation that tolerates custom/self-signed certs
|
||||
// without disabling TLS verification for the whole Node process.
|
||||
return nodeFetch(url, { ...init, headers }, { rejectUnauthorized: false })
|
||||
}
|
||||
|
||||
const requestJson = async <T>(path: string, init?: RequestInit): Promise<T> => {
|
||||
@@ -87,6 +92,91 @@ export function createCodeNomadRequester(config: CodeNomadConfig) {
|
||||
}
|
||||
}
|
||||
|
||||
async function nodeFetch(
|
||||
url: string,
|
||||
init: RequestInit & { headers?: Record<string, string> },
|
||||
tls: { rejectUnauthorized: boolean },
|
||||
): Promise<Response> {
|
||||
const parsed = new URL(url)
|
||||
const isHttps = parsed.protocol === "https:"
|
||||
const requestFn = isHttps ? https.request : http.request
|
||||
|
||||
const method = (init.method ?? "GET").toUpperCase()
|
||||
const headers = init.headers ?? {}
|
||||
const body = init.body
|
||||
|
||||
return await new Promise<Response>((resolve, reject) => {
|
||||
const req = requestFn(
|
||||
{
|
||||
protocol: parsed.protocol,
|
||||
hostname: parsed.hostname,
|
||||
port: parsed.port ? Number(parsed.port) : undefined,
|
||||
path: `${parsed.pathname}${parsed.search}`,
|
||||
method,
|
||||
headers,
|
||||
...(isHttps ? { rejectUnauthorized: tls.rejectUnauthorized } : {}),
|
||||
},
|
||||
(res) => {
|
||||
const responseHeaders = new Headers()
|
||||
for (const [key, value] of Object.entries(res.headers)) {
|
||||
if (value === undefined) continue
|
||||
if (Array.isArray(value)) {
|
||||
responseHeaders.set(key, value.join(", "))
|
||||
} else {
|
||||
responseHeaders.set(key, String(value))
|
||||
}
|
||||
}
|
||||
|
||||
// Convert Node stream -> Web ReadableStream for Response.
|
||||
const webBody = Readable.toWeb(res) as unknown as ReadableStream<Uint8Array>
|
||||
resolve(new Response(webBody, { status: res.statusCode ?? 0, headers: responseHeaders }))
|
||||
},
|
||||
)
|
||||
|
||||
const signal = init.signal
|
||||
const abort = () => {
|
||||
const err = new Error("Request aborted")
|
||||
;(err as any).name = "AbortError"
|
||||
req.destroy(err)
|
||||
reject(err)
|
||||
}
|
||||
|
||||
if (signal) {
|
||||
if (signal.aborted) {
|
||||
abort()
|
||||
return
|
||||
}
|
||||
signal.addEventListener("abort", abort, { once: true })
|
||||
req.once("close", () => signal.removeEventListener("abort", abort))
|
||||
}
|
||||
|
||||
req.once("error", reject)
|
||||
|
||||
if (body === undefined || body === null) {
|
||||
req.end()
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof body === "string") {
|
||||
req.end(body)
|
||||
return
|
||||
}
|
||||
|
||||
if (body instanceof Uint8Array) {
|
||||
req.end(Buffer.from(body))
|
||||
return
|
||||
}
|
||||
|
||||
if (body instanceof ArrayBuffer) {
|
||||
req.end(Buffer.from(new Uint8Array(body)))
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback for less common BodyInit types.
|
||||
req.end(String(body))
|
||||
})
|
||||
}
|
||||
|
||||
function requireEnv(key: string): string {
|
||||
const value = process.env[key]
|
||||
if (!value || !value.trim()) {
|
||||
|
||||
4
packages/server/package-lock.json
generated
4
packages/server/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.10.1",
|
||||
"version": "0.10.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.10.1",
|
||||
"version": "0.10.2",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^8.5.0",
|
||||
"@fastify/reply-from": "^9.8.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.10.1",
|
||||
"version": "0.10.2",
|
||||
"description": "CodeNomad Server",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
|
||||
@@ -25,6 +25,12 @@ const PreferencesSchema = z.object({
|
||||
showUsageMetrics: z.boolean().default(true),
|
||||
autoCleanupBlankSessions: z.boolean().default(true),
|
||||
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({
|
||||
|
||||
@@ -423,7 +423,11 @@ async function main() {
|
||||
const localProtocol: "http" | "https" = httpStart ? "http" : "https"
|
||||
const remoteProtocol: "http" | "https" = httpsStart ? "https" : "http"
|
||||
|
||||
const localUrl = `${localProtocol}://localhost:${localStart.port}`
|
||||
// Use an explicit IPv4 loopback address for the "local" URL.
|
||||
// On macOS, `localhost` often resolves to ::1 first, and it is possible to have
|
||||
// another instance bound on IPv6 while this instance binds IPv4 (or vice versa),
|
||||
// which can lead clients to talk to the wrong process.
|
||||
const localUrl = `${localProtocol}://127.0.0.1:${localStart.port}`
|
||||
let remoteUrl: string | undefined
|
||||
if (remoteStart) {
|
||||
const wantsAll = options.host === "0.0.0.0" || !isLoopbackHost(options.host)
|
||||
|
||||
60
packages/tauri-app/Cargo.lock
generated
60
packages/tauri-app/Cargo.lock
generated
@@ -640,6 +640,7 @@ dependencies = [
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-keepawake",
|
||||
"tauri-plugin-notification",
|
||||
"tauri-plugin-opener",
|
||||
"thiserror 1.0.69",
|
||||
"url",
|
||||
@@ -1033,7 +1034,7 @@ version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412"
|
||||
dependencies = [
|
||||
"libloading 0.7.4",
|
||||
"libloading 0.8.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2336,6 +2337,18 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "malloc_buf"
|
||||
version = "0.0.6"
|
||||
@@ -2540,6 +2553,20 @@ dependencies = [
|
||||
"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]]
|
||||
name = "num-conv"
|
||||
version = "0.1.0"
|
||||
@@ -4390,6 +4417,25 @@ dependencies = [
|
||||
"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]]
|
||||
name = "tauri-plugin-opener"
|
||||
version = "2.5.2"
|
||||
@@ -4513,6 +4559,18 @@ dependencies = [
|
||||
"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]]
|
||||
name = "tempfile"
|
||||
version = "3.23.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@codenomad/tauri-app",
|
||||
"version": "0.10.1",
|
||||
"version": "0.10.2",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -23,3 +23,4 @@ dirs = "5"
|
||||
tauri-plugin-opener = "2"
|
||||
url = "2"
|
||||
tauri-plugin-keepawake = "0.1.1"
|
||||
tauri-plugin-notification = "2"
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
"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"
|
||||
]
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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"]}}
|
||||
@@ -2408,6 +2408,204 @@
|
||||
"const": "keepawake:deny-stop",
|
||||
"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`",
|
||||
"type": "string",
|
||||
|
||||
@@ -2408,6 +2408,204 @@
|
||||
"const": "keepawake:deny-stop",
|
||||
"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`",
|
||||
"type": "string",
|
||||
|
||||
@@ -75,6 +75,7 @@ fn main() {
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_keepawake::init())
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.plugin(navigation_guard)
|
||||
.manage(AppState {
|
||||
manager: CliProcessManager::new(),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@codenomad/ui",
|
||||
"version": "0.10.1",
|
||||
"version": "0.10.2",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
@@ -19,6 +19,7 @@
|
||||
"@suid/material": "^0.19.0",
|
||||
"@suid/system": "^0.14.0",
|
||||
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||
"ansi-sequence-parser": "^1.1.3",
|
||||
"debug": "^4.4.3",
|
||||
"github-markdown-css": "^5.8.1",
|
||||
|
||||
@@ -354,32 +354,34 @@ const App: Component = () => {
|
||||
<Dialog open={Boolean(launchError())} modal>
|
||||
<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-md p-6 flex flex-col gap-6">
|
||||
<div>
|
||||
<Dialog.Title class="text-xl font-semibold text-primary">{t("app.launchError.title")}</Dialog.Title>
|
||||
<Dialog.Description class="text-sm text-secondary mt-2 break-words">
|
||||
{t("app.launchError.description")}
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<Dialog.Content class="modal-surface w-full max-w-3xl p-6 flex flex-col gap-6 max-h-[80vh] min-h-0 overflow-hidden">
|
||||
<div>
|
||||
<Dialog.Title class="text-xl font-semibold text-primary">{t("app.launchError.title")}</Dialog.Title>
|
||||
<Dialog.Description class="text-sm text-secondary mt-2 break-words">
|
||||
{t("app.launchError.description")}
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-base bg-surface-secondary p-4">
|
||||
<p class="text-xs font-medium text-muted uppercase tracking-wide mb-1">{t("app.launchError.binaryPathLabel")}</p>
|
||||
<p class="text-sm font-mono text-primary break-all">{launchErrorPath()}</p>
|
||||
</div>
|
||||
<div class={`flex flex-col gap-4 ${launchErrorMessage() ? "flex-1 min-h-0" : ""}`}>
|
||||
<div class="rounded-lg border border-base bg-surface-secondary p-4 flex-shrink-0">
|
||||
<p class="text-xs font-medium text-muted uppercase tracking-wide mb-1">{t("app.launchError.binaryPathLabel")}</p>
|
||||
<p class="text-sm font-mono text-primary break-all">{launchErrorPath()}</p>
|
||||
</div>
|
||||
|
||||
<Show when={launchErrorMessage()}>
|
||||
<div class="rounded-lg border border-base bg-surface-secondary p-4 flex flex-col gap-2 flex-1 min-h-0">
|
||||
<p class="text-xs font-medium text-muted uppercase tracking-wide">{t("app.launchError.errorOutputLabel")}</p>
|
||||
<pre class="text-sm font-mono text-primary whitespace-pre-wrap break-words overflow-auto flex-1 min-h-0">{launchErrorMessage()}</pre>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={launchErrorMessage()}>
|
||||
<div class="rounded-lg border border-base bg-surface-secondary p-4">
|
||||
<p class="text-xs font-medium text-muted uppercase tracking-wide mb-1">{t("app.launchError.errorOutputLabel")}</p>
|
||||
<pre class="text-sm font-mono text-primary whitespace-pre-wrap break-words max-h-48 overflow-y-auto">{launchErrorMessage()}</pre>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<Show when={launchError()?.missingBinary}>
|
||||
<button
|
||||
type="button"
|
||||
class="selector-button selector-button-secondary"
|
||||
<div class="flex justify-end gap-2">
|
||||
<Show when={launchError()?.missingBinary}>
|
||||
<button
|
||||
type="button"
|
||||
class="selector-button selector-button-secondary"
|
||||
onClick={handleLaunchErrorAdvanced}
|
||||
>
|
||||
{t("app.launchError.openAdvancedSettings")}
|
||||
|
||||
@@ -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>
|
||||
|
||||
)
|
||||
|
||||
@@ -96,6 +96,9 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
|
||||
const seenTimelineMessageIds = new Set<string>()
|
||||
const seenTimelineSegmentKeys = new Set<string>()
|
||||
const timelinePartCountsByMessageId = new Map<string, number>()
|
||||
let pendingTimelineMessagePartUpdates = new Set<string>()
|
||||
let pendingTimelinePartUpdateFrame: number | null = null
|
||||
|
||||
function makeTimelineKey(segment: TimelineSegment) {
|
||||
return `${segment.messageId}:${segment.id}:${segment.type}`
|
||||
@@ -104,6 +107,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
function seedTimeline() {
|
||||
seenTimelineMessageIds.clear()
|
||||
seenTimelineSegmentKeys.clear()
|
||||
timelinePartCountsByMessageId.clear()
|
||||
const ids = untrack(messageIds)
|
||||
const resolvedStore = untrack(store)
|
||||
const segments: TimelineSegment[] = []
|
||||
@@ -111,6 +115,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
const record = resolvedStore.getMessage(messageId)
|
||||
if (!record) return
|
||||
seenTimelineMessageIds.add(messageId)
|
||||
timelinePartCountsByMessageId.set(messageId, record.partIds.length)
|
||||
const built = buildTimelineSegments(props.instanceId, record, t)
|
||||
built.forEach((segment) => {
|
||||
const key = makeTimelineKey(segment)
|
||||
@@ -125,6 +130,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
function appendTimelineForMessage(messageId: string) {
|
||||
const record = untrack(() => store().getMessage(messageId))
|
||||
if (!record) return
|
||||
timelinePartCountsByMessageId.set(messageId, record.partIds.length)
|
||||
const built = buildTimelineSegments(props.instanceId, record, t)
|
||||
if (built.length === 0) return
|
||||
const newSegments: TimelineSegment[] = []
|
||||
@@ -490,8 +496,6 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
})
|
||||
|
||||
let previousTimelineIds: string[] = []
|
||||
let previousLastTimelineMessageId: string | null = null
|
||||
let previousLastTimelinePartCount = 0
|
||||
|
||||
createEffect(() => {
|
||||
const loading = Boolean(props.loading)
|
||||
@@ -499,11 +503,15 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
|
||||
if (loading) {
|
||||
previousTimelineIds = []
|
||||
previousLastTimelineMessageId = null
|
||||
previousLastTimelinePartCount = 0
|
||||
setTimelineSegments([])
|
||||
seenTimelineMessageIds.clear()
|
||||
seenTimelineSegmentKeys.clear()
|
||||
timelinePartCountsByMessageId.clear()
|
||||
pendingTimelineMessagePartUpdates.clear()
|
||||
if (pendingTimelinePartUpdateFrame !== null) {
|
||||
cancelAnimationFrame(pendingTimelinePartUpdateFrame)
|
||||
pendingTimelinePartUpdateFrame = null
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -545,6 +553,14 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
next.forEach((segment) => seenTimelineSegmentKeys.add(makeTimelineKey(segment)))
|
||||
return next
|
||||
})
|
||||
|
||||
// Keep part count tracking in sync with id replacement.
|
||||
const existingPartCount = timelinePartCountsByMessageId.get(oldId)
|
||||
if (existingPartCount !== undefined) {
|
||||
timelinePartCountsByMessageId.delete(oldId)
|
||||
timelinePartCountsByMessageId.set(newId, existingPartCount)
|
||||
}
|
||||
|
||||
previousTimelineIds = ids.slice()
|
||||
return
|
||||
}
|
||||
@@ -568,30 +584,95 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
previousTimelineIds = ids.slice()
|
||||
})
|
||||
|
||||
function clearPendingTimelinePartUpdateFrame() {
|
||||
if (pendingTimelinePartUpdateFrame !== null) {
|
||||
cancelAnimationFrame(pendingTimelinePartUpdateFrame)
|
||||
pendingTimelinePartUpdateFrame = null
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleTimelinePartUpdateFlush() {
|
||||
if (pendingTimelinePartUpdateFrame !== null) return
|
||||
pendingTimelinePartUpdateFrame = requestAnimationFrame(() => {
|
||||
pendingTimelinePartUpdateFrame = null
|
||||
if (pendingTimelineMessagePartUpdates.size === 0) return
|
||||
const changedIds = Array.from(pendingTimelineMessagePartUpdates)
|
||||
pendingTimelineMessagePartUpdates = new Set<string>()
|
||||
|
||||
const ids = messageIds()
|
||||
const resolvedStore = store()
|
||||
|
||||
setTimelineSegments((prev) => {
|
||||
let next = prev
|
||||
|
||||
for (const changedId of changedIds) {
|
||||
// Remove old segments for this message.
|
||||
next = next.filter((segment) => segment.messageId !== changedId)
|
||||
|
||||
const record = resolvedStore.getMessage(changedId)
|
||||
const rebuilt = record ? buildTimelineSegments(props.instanceId, record, t) : []
|
||||
|
||||
// Insert rebuilt segments in the correct place based on session message order.
|
||||
if (rebuilt.length > 0) {
|
||||
let insertAt = next.length
|
||||
const changedIndex = ids.indexOf(changedId)
|
||||
if (changedIndex >= 0) {
|
||||
for (let i = changedIndex + 1; i < ids.length; i++) {
|
||||
const followingId = ids[i]
|
||||
const existingIndex = next.findIndex((segment) => segment.messageId === followingId)
|
||||
if (existingIndex >= 0) {
|
||||
insertAt = existingIndex
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
next = [...next.slice(0, insertAt), ...rebuilt, ...next.slice(insertAt)]
|
||||
}
|
||||
}
|
||||
|
||||
// Rebuild the segment key set since we may have removed/replaced segments.
|
||||
seenTimelineSegmentKeys.clear()
|
||||
next.forEach((segment) => seenTimelineSegmentKeys.add(makeTimelineKey(segment)))
|
||||
return next
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Keep timeline segments in sync when message parts are added/removed.
|
||||
// Part deletion does not remove message ids from the session, so we must
|
||||
// explicitly replace segments for messages whose part count changed.
|
||||
createEffect(() => {
|
||||
if (props.loading) return
|
||||
const ids = messageIds()
|
||||
if (ids.length === 0) return
|
||||
const lastId = ids[ids.length - 1]
|
||||
if (!lastId) return
|
||||
const record = store().getMessage(lastId)
|
||||
if (!record) return
|
||||
const partCount = record.partIds.length
|
||||
if (lastId === previousLastTimelineMessageId && partCount === previousLastTimelinePartCount) {
|
||||
return
|
||||
const resolvedStore = store()
|
||||
|
||||
let hasChanges = false
|
||||
for (const messageId of ids) {
|
||||
const record = resolvedStore.getMessage(messageId)
|
||||
const partCount = record?.partIds.length ?? 0
|
||||
const previousCount = timelinePartCountsByMessageId.get(messageId)
|
||||
|
||||
if (previousCount === undefined) {
|
||||
timelinePartCountsByMessageId.set(messageId, partCount)
|
||||
continue
|
||||
}
|
||||
|
||||
if (previousCount !== partCount) {
|
||||
timelinePartCountsByMessageId.set(messageId, partCount)
|
||||
pendingTimelineMessagePartUpdates.add(messageId)
|
||||
hasChanges = true
|
||||
}
|
||||
}
|
||||
previousLastTimelineMessageId = lastId
|
||||
previousLastTimelinePartCount = partCount
|
||||
const built = buildTimelineSegments(props.instanceId, record, t)
|
||||
const newSegments: TimelineSegment[] = []
|
||||
built.forEach((segment) => {
|
||||
const key = makeTimelineKey(segment)
|
||||
if (seenTimelineSegmentKeys.has(key)) return
|
||||
seenTimelineSegmentKeys.add(key)
|
||||
newSegments.push(segment)
|
||||
})
|
||||
if (newSegments.length > 0) {
|
||||
setTimelineSegments((prev) => [...prev, ...newSegments])
|
||||
|
||||
// Drop tracking for ids that are no longer present.
|
||||
for (const trackedId of Array.from(timelinePartCountsByMessageId.keys())) {
|
||||
if (!ids.includes(trackedId)) {
|
||||
timelinePartCountsByMessageId.delete(trackedId)
|
||||
}
|
||||
}
|
||||
|
||||
if (hasChanges) {
|
||||
scheduleTimelinePartUpdateFlush()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -758,6 +839,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
cancelAnimationFrame(pendingAnchorScroll)
|
||||
}
|
||||
clearScrollToBottomFrames()
|
||||
clearPendingTimelinePartUpdateFrame()
|
||||
if (detachScrollIntentListeners) {
|
||||
detachScrollIntentListeners()
|
||||
}
|
||||
|
||||
@@ -276,6 +276,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
const [tooltipSize, setTooltipSize] = createSignal<{ width: number; height: number }>({ width: 360, height: 420 })
|
||||
const [tooltipElement, setTooltipElement] = createSignal<HTMLDivElement | null>(null)
|
||||
let hoverTimer: number | null = null
|
||||
let closeTimer: number | null = null
|
||||
const showTools = () => props.showToolSegments ?? true
|
||||
|
||||
const registerButtonRef = (segmentId: string, element: HTMLButtonElement | null) => {
|
||||
@@ -292,10 +293,30 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
hoverTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const clearCloseTimer = () => {
|
||||
if (closeTimer !== null && typeof window !== "undefined") {
|
||||
window.clearTimeout(closeTimer)
|
||||
closeTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const scheduleClose = () => {
|
||||
if (typeof window === "undefined") return
|
||||
clearHoverTimer()
|
||||
clearCloseTimer()
|
||||
// Small delay so the pointer can travel from the segment to the tooltip.
|
||||
closeTimer = window.setTimeout(() => {
|
||||
closeTimer = null
|
||||
setHoveredSegment(null)
|
||||
setHoverAnchorRect(null)
|
||||
}, 160)
|
||||
}
|
||||
|
||||
const handleMouseEnter = (segment: TimelineSegment, event: MouseEvent) => {
|
||||
if (typeof window === "undefined") return
|
||||
clearHoverTimer()
|
||||
clearCloseTimer()
|
||||
const target = event.currentTarget as HTMLButtonElement
|
||||
hoverTimer = window.setTimeout(() => {
|
||||
const rect = target.getBoundingClientRect()
|
||||
@@ -305,9 +326,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
clearHoverTimer()
|
||||
setHoveredSegment(null)
|
||||
setHoverAnchorRect(null)
|
||||
scheduleClose()
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
@@ -326,7 +345,10 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
setTooltipCoords({ top: clampedTop, left: clampedLeft })
|
||||
})
|
||||
|
||||
onCleanup(() => clearHoverTimer())
|
||||
onCleanup(() => {
|
||||
clearHoverTimer()
|
||||
clearCloseTimer()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const activeId = props.activeMessageId
|
||||
@@ -432,6 +454,8 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
ref={(element) => setTooltipElement(element)}
|
||||
class="message-timeline-tooltip"
|
||||
style={{ top: `${tooltipCoords().top}px`, left: `${tooltipCoords().left}px` }}
|
||||
onMouseEnter={() => clearCloseTimer()}
|
||||
onMouseLeave={() => scheduleClose()}
|
||||
>
|
||||
<MessagePreview
|
||||
messageId={data().messageId}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
fetchProviders,
|
||||
clearInstanceDraftPrompts,
|
||||
} from "./sessions"
|
||||
import { ensureWorktreesLoaded, ensureWorktreeMapLoaded } from "./worktrees"
|
||||
import { ensureWorktreesLoaded, ensureWorktreeMapLoaded, getOrCreateWorktreeClient, getWorktreeSlugForSession } from "./worktrees"
|
||||
import { fetchCommands, clearCommands } from "./commands"
|
||||
import { preferences } from "./preferences"
|
||||
import { setSessionPendingPermission, setSessionPendingQuestion } from "./session-state"
|
||||
@@ -41,6 +41,8 @@ const [logStreamingState, setLogStreamingState] = createSignal<Map<string, boole
|
||||
const [permissionQueues, setPermissionQueues] = createSignal<Map<string, PermissionRequestLike[]>>(new Map())
|
||||
const [activePermissionId, setActivePermissionId] = createSignal<Map<string, string | null>>(new Map())
|
||||
const permissionSessionCounts = new Map<string, Map<string, number>>()
|
||||
// Track which worktree a permission was enqueued under (by permission request id).
|
||||
const permissionWorktreeSlugByInstance = new Map<string, Map<string, string>>()
|
||||
|
||||
const [questionQueues, setQuestionQueues] = createSignal<Map<string, QuestionRequest[]>>(new Map())
|
||||
const [activeQuestionId, setActiveQuestionId] = createSignal<Map<string, string | null>>(new Map())
|
||||
@@ -676,6 +678,16 @@ function addPermissionToQueue(instanceId: string, permission: PermissionRequestL
|
||||
if (sessionId) {
|
||||
incrementSessionPendingCount(instanceId, sessionId)
|
||||
setSessionPendingPermission(instanceId, sessionId, true)
|
||||
|
||||
// Record the worktree slug at the time the permission is enqueued.
|
||||
// This is used to respond in the same worktree context even from the global permission center.
|
||||
const slug = getWorktreeSlugForSession(instanceId, sessionId)
|
||||
let byPermissionId = permissionWorktreeSlugByInstance.get(instanceId)
|
||||
if (!byPermissionId) {
|
||||
byPermissionId = new Map()
|
||||
permissionWorktreeSlugByInstance.set(instanceId, byPermissionId)
|
||||
}
|
||||
byPermissionId.set(permission.id, slug)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -709,6 +721,8 @@ function removePermissionFromQueue(instanceId: string, permissionId: string): vo
|
||||
|
||||
const removed = removedPermission
|
||||
if (removed) {
|
||||
// Use the id we were asked to remove (avoids type inference edge cases).
|
||||
permissionWorktreeSlugByInstance.get(instanceId)?.delete(permissionId)
|
||||
const removedSessionId = getPermissionSessionId(removed)
|
||||
if (removedSessionId) {
|
||||
const remaining = decrementSessionPendingCount(instanceId, removedSessionId)
|
||||
@@ -729,6 +743,7 @@ function clearPermissionQueue(instanceId: string): void {
|
||||
return next
|
||||
})
|
||||
clearSessionPendingCounts(instanceId)
|
||||
permissionWorktreeSlugByInstance.delete(instanceId)
|
||||
recomputeActiveInterruption(instanceId)
|
||||
}
|
||||
|
||||
@@ -877,8 +892,13 @@ async function sendPermissionResponse(
|
||||
}
|
||||
|
||||
try {
|
||||
const stored = permissionWorktreeSlugByInstance.get(instanceId)?.get(requestId)
|
||||
const fallback = sessionId ? getWorktreeSlugForSession(instanceId, sessionId) : "root"
|
||||
const worktreeSlug = stored ?? fallback
|
||||
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
|
||||
|
||||
await requestData(
|
||||
instance.client.permission.reply({
|
||||
client.permission.reply({
|
||||
requestID: requestId,
|
||||
reply,
|
||||
}),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -211,7 +211,8 @@
|
||||
.message-timeline-tooltip {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
/* Allow interacting with the preview (copy/delete/etc). */
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.message-preview {
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -53,11 +53,15 @@ export default defineConfig({
|
||||
workbox: {
|
||||
// Preserve server-side auth redirects (e.g., /login) instead of serving cached index.html.
|
||||
navigateFallback: null,
|
||||
// Only precache static assets (avoid caching HTML documents / routes).
|
||||
globPatterns: ["**/*.{js,css,png,jpg,jpeg,svg,webp,ico,woff,woff2,ttf,eot,json,webmanifest}"],
|
||||
globIgnores: ["**/*.html"],
|
||||
// Only cache static UI assets; never cache API traffic.
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: ({ url, request }) => {
|
||||
if (url.pathname.startsWith("/api/")) return false
|
||||
if (request.destination === "document") return false
|
||||
return ["script", "style", "image", "font"].includes(request.destination)
|
||||
},
|
||||
handler: "CacheFirst",
|
||||
|
||||
Reference in New Issue
Block a user