From 06f053c83cc6d47676e91be9bfb8dc3167dfc7d7 Mon Sep 17 00:00:00 2001 From: Brian Leach Date: Sun, 22 Feb 2026 12:34:24 -0600 Subject: [PATCH 1/3] fix(ios): derive APNs environment from provisioning profile The #if DEBUG check was unreliable for determining the APNs environment because Xcode development provisioning profiles always produce sandbox device tokens, even for Release builds. Now reads the aps-environment entitlement from the embedded provisioning profile at runtime. Co-Authored-By: Claude Opus 4.6 --- apps/ios/Sources/Model/NodeAppModel.swift | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index d763a3b908f9..cab27b00622b 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -510,11 +510,19 @@ final class NodeAppModel { private static let deepLinkKeyUserDefaultsKey = "deeplink.agent.key" private static let canvasUnattendedDeepLinkKey: String = NodeAppModel.generateDeepLinkKey() private static var apnsEnvironment: String { -#if DEBUG - "sandbox" -#else - "production" -#endif + // Determine the actual APNs environment from the embedded provisioning + // profile rather than the build configuration. Xcode development + // provisioning profiles always produce sandbox device tokens, even for + // Release builds, so #if DEBUG is unreliable. + if let path = Bundle.main.path(forResource: "embedded", ofType: "mobileprovision"), + let data = try? Data(contentsOf: URL(fileURLWithPath: path)), + let contents = String(data: data, encoding: .ascii), + contents.contains("aps-environment") { + if contents.contains("production") { + return "production" + } + } + return "sandbox" } private func refreshBrandingFromGateway() async { From 1dacdb83bbac4f76449eadbbad646fcad4704609 Mon Sep 17 00:00:00 2001 From: Brian Leach Date: Sun, 22 Feb 2026 12:28:17 -0600 Subject: [PATCH 2/3] feat(ios): prompt for notification permission at launch Previously the app only requested notification authorization lazily when the gateway tried to deliver a push. Now it prompts immediately on first launch so the user grants permission before notifications are needed. Co-Authored-By: Claude Opus 4.6 --- apps/ios/Sources/OpenClawApp.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/ios/Sources/OpenClawApp.swift b/apps/ios/Sources/OpenClawApp.swift index 0dc0c4cac26f..a175d5d4e75d 100644 --- a/apps/ios/Sources/OpenClawApp.swift +++ b/apps/ios/Sources/OpenClawApp.swift @@ -55,6 +55,13 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc self.registerBackgroundWakeRefreshTask() UNUserNotificationCenter.current().delegate = self application.registerForRemoteNotifications() + Task { + let center = UNUserNotificationCenter.current() + let status = await center.notificationSettings() + if status.authorizationStatus == .notDetermined { + _ = try? await center.requestAuthorization(options: [.alert, .sound, .badge]) + } + } return true } From 2d4bb44c0c773185509a25043e9b96b1fa657307 Mon Sep 17 00:00:00 2001 From: Brian Leach Date: Sat, 7 Mar 2026 16:01:28 -0600 Subject: [PATCH 3/3] feat(ui): auto-refresh chat on event gap with visual marker When the web UI detects an event sequence gap (common after returning from a backgrounded tab), automatically reload chat history instead of showing an error. A visual gap divider marks where new messages begin, showing the count of messages that arrived while the user was away. The gap marker can be dismissed via its X button and is automatically cleared on manual refresh, session switch, or reconnect. --- ui/src/styles/chat/grouped.css | 41 +++++++++++++++++++++++++ ui/src/ui/app-gateway.node.test.ts | 48 ++++++++++++++++++++++++++++-- ui/src/ui/app-gateway.ts | 18 +++++++++-- ui/src/ui/app-render.helpers.ts | 1 + ui/src/ui/app-render.ts | 6 ++++ ui/src/ui/app-view-state.ts | 1 + ui/src/ui/app.ts | 1 + ui/src/ui/types/chat-types.ts | 1 + ui/src/ui/views/chat.ts | 46 ++++++++++++++++++++++++++++ 9 files changed, 158 insertions(+), 5 deletions(-) diff --git a/ui/src/styles/chat/grouped.css b/ui/src/styles/chat/grouped.css index c43743267a9f..6d15308c68a4 100644 --- a/ui/src/styles/chat/grouped.css +++ b/ui/src/styles/chat/grouped.css @@ -81,6 +81,47 @@ background: rgba(255, 255, 255, 0.02); } +/* Gap divider — marks where new activity began while user was away */ +.chat-divider--gap { + color: var(--accent, #58a6ff); +} + +.chat-divider__line--gap { + background: var(--accent, #58a6ff); + opacity: 0.5; +} + +.chat-divider__label--gap { + display: inline-flex; + align-items: center; + gap: 6px; + border-color: var(--accent, #58a6ff); + background: color-mix(in srgb, var(--accent, #58a6ff) 8%, transparent); +} + +.chat-divider__dismiss { + display: inline-flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + padding: 0; + border: none; + background: none; + color: inherit; + cursor: pointer; + opacity: 0.6; +} + +.chat-divider__dismiss:hover { + opacity: 1; +} + +.chat-divider__dismiss svg { + width: 10px; + height: 10px; +} + /* Avatar Styles */ .chat-avatar { width: 40px; diff --git a/ui/src/ui/app-gateway.node.test.ts b/ui/src/ui/app-gateway.node.test.ts index 6915a30f9999..7e44264b3f00 100644 --- a/ui/src/ui/app-gateway.node.test.ts +++ b/ui/src/ui/app-gateway.node.test.ts @@ -1,7 +1,26 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; + +// Stub localStorage before any module resolution (i18n accesses it at load time). +// Node 22+ has a built-in localStorage that throws without --localstorage-file. +vi.hoisted(() => { + (globalThis as Record).localStorage = { + getItem: () => null, + setItem: () => {}, + removeItem: () => {}, + clear: () => {}, + length: 0, + key: () => null, + }; +}); + import { GATEWAY_EVENT_UPDATE_AVAILABLE } from "../../../src/gateway/events.js"; import { connectGateway, resolveControlUiClientVersion } from "./app-gateway.ts"; +vi.mock("./controllers/chat.ts", () => ({ + loadChatHistory: vi.fn().mockResolvedValue(undefined), + handleChatEvent: vi.fn().mockReturnValue(null), +})); + type GatewayClientMock = { start: ReturnType; stop: ReturnType; @@ -106,6 +125,9 @@ function createHost() { serverVersion: null, sessionKey: "main", chatRunId: null, + chatMessages: [], + chatLoading: false, + chatGapIndex: null, refreshSessionsAfterChat: new Set(), execApprovalQueue: [], execApprovalError: null, @@ -129,13 +151,33 @@ describe("connectGateway", () => { const secondClient = gatewayClientInstances[1]; expect(secondClient).toBeDefined(); + // Stale client gap should be ignored (no error, no side effects). firstClient.emitGap(10, 13); expect(host.lastError).toBeNull(); + // Active client gap should not set lastError (auto-refresh handles it). secondClient.emitGap(20, 24); - expect(host.lastError).toBe( - "event gap detected (expected seq 20, got 24); refresh recommended", - ); + expect(host.lastError).toBeNull(); + }); + + it("auto-refreshes chat history on gap detection", async () => { + const { loadChatHistory } = await import("./controllers/chat.ts"); + const host = createHost(); + (host as unknown as { chatMessages: unknown[] }).chatMessages = [ + { role: "user", content: "hello" }, + { role: "assistant", content: "hi" }, + ]; + + connectGateway(host); + const client = gatewayClientInstances[0]; + expect(client).toBeDefined(); + + client.emitGap(5, 8); + + // loadChatHistory should have been called (auto-refresh) + expect(loadChatHistory).toHaveBeenCalled(); + // No error message set — gap is handled silently via auto-refresh + expect(host.lastError).toBeNull(); }); it("ignores stale client onEvent callbacks after reconnect", () => { diff --git a/ui/src/ui/app-gateway.ts b/ui/src/ui/app-gateway.ts index 15b885be26a7..d8be81f51411 100644 --- a/ui/src/ui/app-gateway.ts +++ b/ui/src/ui/app-gateway.ts @@ -197,6 +197,7 @@ export function connectGateway(host: GatewayHost) { // Reset orphaned chat run state from before disconnect. // Any in-flight run's final event was lost during the disconnect window. host.chatRunId = null; + (host as unknown as OpenClawApp).chatGapIndex = null; (host as unknown as { chatStream: string | null }).chatStream = null; (host as unknown as { chatStreamStartedAt: number | null }).chatStreamStartedAt = null; resetToolStream(host as unknown as Parameters[0]); @@ -237,8 +238,21 @@ export function connectGateway(host: GatewayHost) { if (host.client !== client) { return; } - host.lastError = `event gap detected (expected seq ${expected}, got ${received}); refresh recommended`; - host.lastErrorCode = null; + const app = host as unknown as OpenClawApp; + const preGapCount = app.chatMessages.length; + console.info( + `[gateway] event gap detected (expected seq ${expected}, got ${received}); auto-refreshing`, + ); + // Auto-refresh chat history and mark the gap boundary. + void loadChatHistory(app).then(() => { + if (host.client !== client) { + return; + } + // Only set the gap marker if new messages appeared after the gap. + if (app.chatMessages.length > preGapCount) { + app.chatGapIndex = preGapCount; + } + }); }, }); host.client = client; diff --git a/ui/src/ui/app-render.helpers.ts b/ui/src/ui/app-render.helpers.ts index 68dfbe5e76de..7f9a93a8146c 100644 --- a/ui/src/ui/app-render.helpers.ts +++ b/ui/src/ui/app-render.helpers.ts @@ -218,6 +218,7 @@ export function renderChatControls(state: AppViewState) { const app = state as unknown as OpenClawApp; app.chatManualRefreshInFlight = true; app.chatNewMessagesBelow = false; + app.chatGapIndex = null; await app.updateComplete; app.resetToolStream(); try { diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 97b2271b1bf4..2ef8155aaa3f 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -1009,6 +1009,7 @@ export function renderApp(state: AppViewState) { state.chatStreamStartedAt = null; state.chatRunId = null; state.chatQueue = []; + (state as unknown as import("./app.js").OpenClawApp).chatGapIndex = null; state.resetToolStream(); state.resetChatScroll(); state.applySettings({ @@ -1039,8 +1040,13 @@ export function renderApp(state: AppViewState) { error: state.lastError, sessions: state.sessionsResult, focusMode: chatFocus, + gapIndex: state.chatGapIndex, + onDismissGap: () => { + (state as unknown as import("./app.js").OpenClawApp).chatGapIndex = null; + }, onRefresh: () => { state.resetToolStream(); + (state as unknown as import("./app.js").OpenClawApp).chatGapIndex = null; return Promise.all([loadChatHistory(state), refreshChatAvatar(state)]); }, onToggleFocusMode: () => { diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index c5cf3573ac4d..229d5dc80068 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -66,6 +66,7 @@ export type AppViewState = { chatThinkingLevel: string | null; chatQueue: ChatQueueItem[]; chatManualRefreshInFlight: boolean; + chatGapIndex: number | null; nodesLoading: boolean; nodes: Array>; chatNewMessagesBelow: boolean; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 799ea9100c64..dbadf1912150 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -154,6 +154,7 @@ export class OpenClawApp extends LitElement { @state() chatQueue: ChatQueueItem[] = []; @state() chatAttachments: ChatAttachment[] = []; @state() chatManualRefreshInFlight = false; + @state() chatGapIndex: number | null = null; // Sidebar state for tool output viewing @state() sidebarOpen = false; @state() sidebarContent: string | null = null; diff --git a/ui/src/ui/types/chat-types.ts b/ui/src/ui/types/chat-types.ts index aba1b17301e5..848bb3265b61 100644 --- a/ui/src/ui/types/chat-types.ts +++ b/ui/src/ui/types/chat-types.ts @@ -6,6 +6,7 @@ export type ChatItem = | { kind: "message"; key: string; message: unknown } | { kind: "divider"; key: string; label: string; timestamp: number } + | { kind: "gap"; key: string; newCount: number; timestamp: number } | { kind: "stream"; key: string; text: string; startedAt: number } | { kind: "reading-indicator"; key: string }; diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index e63f56c25fa7..28fa6195f347 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -65,6 +65,9 @@ export type ChatProps = { // Image attachments attachments?: ChatAttachment[]; onAttachmentsChange?: (attachments: ChatAttachment[]) => void; + // Gap marker (auto-refresh on event gap) + gapIndex?: number | null; + onDismissGap?: () => void; // Scroll control showNewMessages?: boolean; onScrollToBottom?: () => void; @@ -286,6 +289,37 @@ export function renderChat(props: ChatProps) { `; } + if (item.kind === "gap") { + const label = + item.newCount > 0 + ? `${item.newCount} new message${item.newCount !== 1 ? "s" : ""} since you were away` + : "New activity since you were away"; + return html` + + `; + } + if (item.kind === "reading-indicator") { return renderReadingIndicatorGroup(assistantIdentity); } @@ -538,7 +572,19 @@ function buildChatItems(props: ChatProps): Array { }, }); } + const gapIndex = props.gapIndex ?? null; for (let i = historyStart; i < history.length; i++) { + // Insert gap divider at the boundary between old and new messages. + if (gapIndex !== null && i === gapIndex && i > historyStart) { + const newCount = history.length - gapIndex; + items.push({ + kind: "gap", + key: "divider:gap", + newCount, + timestamp: Date.now(), + }); + } + const msg = history[i]; const normalized = normalizeMessage(msg); const raw = msg as Record;