From ded31078d454905383391fd75df69fcfbaf60c80 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Sun, 8 Feb 2026 19:45:27 +0000 Subject: [PATCH 01/10] fix(opencode-config): tolerate self-signed HTTPS for plugin bridge --- .../opencode-config/plugin/lib/request.ts | 100 +++++++++++++++++- 1 file changed, 95 insertions(+), 5 deletions(-) diff --git a/packages/opencode-config/plugin/lib/request.ts b/packages/opencode-config/plugin/lib/request.ts index 90df50fe..5025a501 100644 --- a/packages/opencode-config/plugin/lib/request.ts +++ b/packages/opencode-config/plugin/lib/request.ts @@ -1,3 +1,7 @@ +import http from "http" +import https from "https" +import { Readable } from "stream" + export type PluginEvent = { type: string properties?: Record @@ -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 (path: string, init?: RequestInit): Promise => { @@ -87,6 +92,91 @@ export function createCodeNomadRequester(config: CodeNomadConfig) { } } +async function nodeFetch( + url: string, + init: RequestInit & { headers?: Record }, + tls: { rejectUnauthorized: boolean }, +): Promise { + 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((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 + 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()) { From 322a880a02a20920ef94b2d2c6c43fd33c7c3866 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Sun, 8 Feb 2026 20:44:43 +0000 Subject: [PATCH 02/10] fix(dev): avoid localhost dual-stack collisions --- packages/electron-app/electron/main/process-manager.ts | 3 +++ packages/server/src/index.ts | 6 +++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/electron-app/electron/main/process-manager.ts b/packages/electron-app/electron/main/process-manager.ts index bcf84b46..2079667f 100644 --- a/packages/electron-app/electron/main/process-manager.ts +++ b/packages/electron-app/electron/main/process-manager.ts @@ -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") diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index e29313de..38b07d80 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -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) From 2a5bb6304d7899b12b0bbbc4640205c284db150a Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Sun, 8 Feb 2026 21:06:32 +0000 Subject: [PATCH 03/10] fix(ui): keep timeline preview tooltip interactive Allow pointer interaction with the message preview tooltip and delay hover dismissal so users can move from the timeline segment onto the preview to copy or delete. --- .../ui/src/components/message-timeline.tsx | 32 ++++++++++++++++--- .../src/styles/messaging/message-timeline.css | 3 +- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/components/message-timeline.tsx b/packages/ui/src/components/message-timeline.tsx index 7faaca97..c0e1d9d7 100644 --- a/packages/ui/src/components/message-timeline.tsx +++ b/packages/ui/src/components/message-timeline.tsx @@ -276,6 +276,7 @@ const MessageTimeline: Component = (props) => { const [tooltipSize, setTooltipSize] = createSignal<{ width: number; height: number }>({ width: 360, height: 420 }) const [tooltipElement, setTooltipElement] = createSignal(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 = (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 = (props) => { } const handleMouseLeave = () => { - clearHoverTimer() - setHoveredSegment(null) - setHoverAnchorRect(null) + scheduleClose() } createEffect(() => { @@ -326,7 +345,10 @@ const MessageTimeline: Component = (props) => { setTooltipCoords({ top: clampedTop, left: clampedLeft }) }) - onCleanup(() => clearHoverTimer()) + onCleanup(() => { + clearHoverTimer() + clearCloseTimer() + }) createEffect(() => { const activeId = props.activeMessageId @@ -432,6 +454,8 @@ const MessageTimeline: Component = (props) => { ref={(element) => setTooltipElement(element)} class="message-timeline-tooltip" style={{ top: `${tooltipCoords().top}px`, left: `${tooltipCoords().left}px` }} + onMouseEnter={() => clearCloseTimer()} + onMouseLeave={() => scheduleClose()} > Date: Sun, 8 Feb 2026 21:32:35 +0000 Subject: [PATCH 04/10] fix(ui): refresh timeline when parts change Track per-message part count changes and rebuild timeline segments so deletions or streaming updates don't leave stale entries in the message timeline. --- .../ui/src/components/message-section.tsx | 130 ++++++++++++++---- 1 file changed, 106 insertions(+), 24 deletions(-) diff --git a/packages/ui/src/components/message-section.tsx b/packages/ui/src/components/message-section.tsx index 19f611fd..48716dfc 100644 --- a/packages/ui/src/components/message-section.tsx +++ b/packages/ui/src/components/message-section.tsx @@ -96,6 +96,9 @@ export default function MessageSection(props: MessageSectionProps) { const seenTimelineMessageIds = new Set() const seenTimelineSegmentKeys = new Set() + const timelinePartCountsByMessageId = new Map() + let pendingTimelineMessagePartUpdates = new Set() + 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() + + 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() } From 0d4a4ccad78710181178dd1b6132c5563243ea3e Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Sun, 8 Feb 2026 21:46:36 +0000 Subject: [PATCH 05/10] fix(ui): expand launch error modal Let the 'Unable to launch OpenCode' dialog grow up to 80vh and keep only the error output pane scrollable so longer stderr is visible without cramped nested scrolling. --- packages/ui/src/App.tsx | 50 +++++++++++++++++++++-------------------- 1 file changed, 26 insertions(+), 24 deletions(-) diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 80d24f44..f4092ad3 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -354,32 +354,34 @@ const App: Component = () => { -
- -
- {t("app.launchError.title")} - - {t("app.launchError.description")} - -
+
+ +
+ {t("app.launchError.title")} + + {t("app.launchError.description")} + +
-
-

{t("app.launchError.binaryPathLabel")}

-

{launchErrorPath()}

-
+
+
+

{t("app.launchError.binaryPathLabel")}

+

{launchErrorPath()}

+
+ + +
+

{t("app.launchError.errorOutputLabel")}

+
{launchErrorMessage()}
+
+
+
- -
-

{t("app.launchError.errorOutputLabel")}

-
{launchErrorMessage()}
-
-
- -
- - +
+ + setNotificationsOpen(false)} /> ) diff --git a/packages/ui/src/components/notifications-settings-modal.tsx b/packages/ui/src/components/notifications-settings-modal.tsx new file mode 100644 index 00000000..fc32465b --- /dev/null +++ b/packages/ui/src/components/notifications-settings-modal.tsx @@ -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 = (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 ( + !open && props.onClose()}> + + +
+ +
+ Notifications +
+ +
+
+
+

Session Status Notifications

+
+ +
+
+
+
Enable
+
Permission: {permissionLabel()}
+
+ +
+ + +
+
Request permission
+ +
+
+ +
+
+
Notify when app is focused
+
+ +
+ + +
{infoMessage()}
+
+ + +
+ Notifications are not supported in this environment. The bell icon stays disabled. +
+
+ +
+
Notify me when
+
+
+
Session needs input
+ +
+ +
+
Session becomes idle
+ +
+
+
+
+
+
+ +
+ +
+
+
+
+
+ ) +} + +export default NotificationsSettingsModal diff --git a/packages/ui/src/lib/os-notifications.ts b/packages/ui/src/lib/os-notifications.ts new file mode 100644 index 00000000..2ffe9f1a --- /dev/null +++ b/packages/ui/src/lib/os-notifications.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + if (typeof window === "undefined") { + return + } + + if (isElectronHost()) { + await sendElectronNotification(payload) + return + } + + if (isTauriHost()) { + await sendTauriNotification(payload) + return + } + + await sendWebNotification(payload) +} diff --git a/packages/ui/src/stores/preferences.tsx b/packages/ui/src/stores/preferences.tsx index 2d28160f..5907e78e 100644 --- a/packages/ui/src/stores/preferences.tsx +++ b/packages/ui/src/stores/preferences.tsx @@ -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 & { 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, } } diff --git a/packages/ui/src/stores/session-events.ts b/packages/ui/src/stores/session-events.ts index 1004579c..44a0ecbe 100644 --- a/packages/ui/src/stores/session-events.ts +++ b/packages/ui/src/stores/session-events.ts @@ -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>() +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( diff --git a/packages/ui/src/types/global.d.ts b/packages/ui/src/types/global.d.ts index 3c6cf623..e0558004 100644 --- a/packages/ui/src/types/global.d.ts +++ b/packages/ui/src/types/global.d.ts @@ -25,8 +25,11 @@ declare global { onCliStatus?: (callback: (data: unknown) => void) => () => void onCliError?: (callback: (data: unknown) => void) => () => void getCliStatus?: () => Promise + restartCli?: () => Promise openDialog?: (options: ElectronDialogOptions) => Promise 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 } } - From 4cf980fb97fd6c675c5e1bbff10b3215be150260 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Mon, 9 Feb 2026 00:56:20 +0000 Subject: [PATCH 07/10] fix(permissions): reply in originating worktree Track the worktree slug when permissions are enqueued and send permission replies through a worktree-scoped client so x-opencode-directory matches the originating context. --- packages/ui/src/stores/instances.ts | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/stores/instances.ts b/packages/ui/src/stores/instances.ts index a9d14ebf..f3e5c56e 100644 --- a/packages/ui/src/stores/instances.ts +++ b/packages/ui/src/stores/instances.ts @@ -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>(new Map()) const [activePermissionId, setActivePermissionId] = createSignal>(new Map()) const permissionSessionCounts = new Map>() +// Track which worktree a permission was enqueued under (by permission request id). +const permissionWorktreeSlugByInstance = new Map>() const [questionQueues, setQuestionQueues] = createSignal>(new Map()) const [activeQuestionId, setActiveQuestionId] = createSignal>(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, }), From 9e3dbc5dfb72f48f74a9a406f3305ac160b2ca3f Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Mon, 9 Feb 2026 00:57:30 +0000 Subject: [PATCH 08/10] Bump v0.10.2 --- package-lock.json | 12 ++++++------ package.json | 2 +- packages/electron-app/package.json | 2 +- packages/server/package-lock.json | 4 ++-- packages/server/package.json | 2 +- packages/tauri-app/package.json | 2 +- packages/ui/package.json | 2 +- 7 files changed, 13 insertions(+), 13 deletions(-) diff --git a/package-lock.json b/package-lock.json index acf4b6fc..84f11fc4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", @@ -11964,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", @@ -11999,7 +11999,7 @@ }, "packages/server": { "name": "@neuralnomads/codenomad", - "version": "0.10.1", + "version": "0.10.2", "license": "MIT", "dependencies": { "@fastify/cors": "^8.5.0", @@ -12039,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" @@ -12047,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", diff --git a/package.json b/package.json index 951bdcef..a116049b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codenomad-workspace", - "version": "0.10.1", + "version": "0.10.2", "private": true, "description": "CodeNomad monorepo workspace", "license": "MIT", diff --git a/packages/electron-app/package.json b/packages/electron-app/package.json index bdea6c21..c7874888 100644 --- a/packages/electron-app/package.json +++ b/packages/electron-app/package.json @@ -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": { diff --git a/packages/server/package-lock.json b/packages/server/package-lock.json index b035dc22..98c265a7 100644 --- a/packages/server/package-lock.json +++ b/packages/server/package-lock.json @@ -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", diff --git a/packages/server/package.json b/packages/server/package.json index e6ab400d..ef52a55e 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@neuralnomads/codenomad", - "version": "0.10.1", + "version": "0.10.2", "description": "CodeNomad Server", "license": "MIT", "author": { diff --git a/packages/tauri-app/package.json b/packages/tauri-app/package.json index a1dd47d7..b24d0c4f 100644 --- a/packages/tauri-app/package.json +++ b/packages/tauri-app/package.json @@ -1,6 +1,6 @@ { "name": "@codenomad/tauri-app", - "version": "0.10.1", + "version": "0.10.2", "private": true, "license": "MIT", "scripts": { diff --git a/packages/ui/package.json b/packages/ui/package.json index a3752a6f..9101436f 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@codenomad/ui", - "version": "0.10.1", + "version": "0.10.2", "private": true, "license": "MIT", "type": "module", From b244d9f98c07cbb785ca52cadd7abb3a2f9eed11 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Mon, 9 Feb 2026 00:58:28 +0000 Subject: [PATCH 09/10] Min version 0.10.2 --- packages/cloudflare/release-config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cloudflare/release-config.json b/packages/cloudflare/release-config.json index 0bad2647..5ff3c319 100644 --- a/packages/cloudflare/release-config.json +++ b/packages/cloudflare/release-config.json @@ -1,4 +1,4 @@ { - "minServerVersion": "0.10.1", + "minServerVersion": "0.10.2", "latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest" } From 0e755b721c08d0fe4058d19ab3da9db8a9a82297 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Mon, 9 Feb 2026 01:04:15 +0000 Subject: [PATCH 10/10] fix(ui): exclude routes from service worker cache Configure Workbox to precache only static UI assets and ignore HTML documents, preventing route responses like / and /login from being served out of cache. --- packages/ui/vite.config.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/ui/vite.config.ts b/packages/ui/vite.config.ts index d41b34c3..6e231b07 100644 --- a/packages/ui/vite.config.ts +++ b/packages/ui/vite.config.ts @@ -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",