Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 13 additions & 5 deletions apps/ios/Sources/Model/NodeAppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -517,11 +517,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("<key>aps-environment</key>") {
if contents.contains("<string>production</string>") {
return "production"
}
}
return "sandbox"
}

private func refreshBrandingFromGateway() async {
Expand Down
7 changes: 7 additions & 0 deletions apps/ios/Sources/OpenClawApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
41 changes: 41 additions & 0 deletions ui/src/styles/chat/grouped.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
48 changes: 45 additions & 3 deletions ui/src/ui/app-gateway.node.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>).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<typeof vi.fn>;
stop: ReturnType<typeof vi.fn>;
Expand Down Expand Up @@ -106,6 +125,9 @@ function createHost() {
serverVersion: null,
sessionKey: "main",
chatRunId: null,
chatMessages: [],
chatLoading: false,
chatGapIndex: null,
refreshSessionsAfterChat: new Set<string>(),
execApprovalQueue: [],
execApprovalError: null,
Expand All @@ -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", () => {
Expand Down
18 changes: 16 additions & 2 deletions ui/src/ui/app-gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof resetToolStream>[0]);
Expand Down Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions ui/src/ui/app-render.helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
6 changes: 6 additions & 0 deletions ui/src/ui/app-render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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: () => {
Expand Down
1 change: 1 addition & 0 deletions ui/src/ui/app-view-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export type AppViewState = {
chatThinkingLevel: string | null;
chatQueue: ChatQueueItem[];
chatManualRefreshInFlight: boolean;
chatGapIndex: number | null;
nodesLoading: boolean;
nodes: Array<Record<string, unknown>>;
chatNewMessagesBelow: boolean;
Expand Down
1 change: 1 addition & 0 deletions ui/src/ui/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions ui/src/ui/types/chat-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand Down
46 changes: 46 additions & 0 deletions ui/src/ui/views/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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`
<div
class="chat-divider chat-divider--gap"
id="chat-gap-marker"
role="separator"
aria-label=${label}
>
<span class="chat-divider__line chat-divider__line--gap"></span>
<span class="chat-divider__label chat-divider__label--gap">
${label}
${
props.onDismissGap
? html`<button
class="chat-divider__dismiss"
type="button"
aria-label="Dismiss"
@click=${props.onDismissGap}
>${icons.x}</button>`
: nothing
}
</span>
<span class="chat-divider__line chat-divider__line--gap"></span>
</div>
`;
}

if (item.kind === "reading-indicator") {
return renderReadingIndicatorGroup(assistantIdentity);
}
Expand Down Expand Up @@ -538,7 +572,19 @@ function buildChatItems(props: ChatProps): Array<ChatItem | MessageGroup> {
},
});
}
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<string, unknown>;
Expand Down