Skip to content
Open
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
19 changes: 14 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,8 +125,17 @@ MobileClaw supports three backend modes, selectable in the setup dialog:
MobileClaw connects to OpenClaw's gateway WebSocket. Protocol frames:

1. **Server sends** `event:connect.challenge` with nonce
2. **Client responds** with `req:connect` including auth token, capabilities
3. **Server responds** with `res:hello-ok` including server info, session snapshot
4. **Client requests** `req:chat.history` to load message history
5. **Messages flow** via `event:chat` (delta/final/aborted/error) and `event:agent` (content/tool/reasoning/lifecycle streams)
6. **Client sends** `req:chat.send` with user messages
2. **Client responds** with `req:connect` using protocol `3`, a v3 device-auth signature, and either a shared auth token or a cached `auth.deviceToken`
3. **Server responds** with `res:hello-ok` including server info, session snapshot, `features`, `policy`, and optional auth state
4. **Client persists** `hello-ok.auth.deviceToken` per normalized gateway URL and reuses it on later connects when the shared token hash still matches
5. **Client requests** `req:chat.history` to load message history
6. **Messages flow** via `event:chat` (delta/final/aborted/error) and `event:agent` (content/tool/reasoning/lifecycle streams)
7. **Client optionally subscribes** to `sessions.subscribe` and `sessions.messages.subscribe` when advertised, but still reconciles transcript state from `chat.history`
8. **Client sends** `req:chat.send` with user messages

### Protocol Notes

- Keep `lib/deviceIdentity.ts` and `ios/MobileClaw/Networking/DeviceIdentity.swift` aligned. They must sign the same v3 auth payload and normalize `platform` / `deviceFamily` the same way.
- Device-token cache is implemented in `lib/gatewayAuth.ts` under `mc-openclaw-device-auth-v1`. Native storage goes through `lib/nativeBridge.ts` and `ios/MobileClaw/Bridge/WebViewBridge.swift`.
- `session.message`, `session.tool`, and `sessions.changed` are used as invalidation signals. They should not directly mutate the rendered transcript.
- `AUTH_TOKEN_MISMATCH` may trigger one reconnect with cached device approval. `DEVICE_AUTH_*` errors should surface immediately.
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,19 @@

All notable changes to MobileClaw are documented in this file.

## 2026-04-07

### Added
- OpenClaw gateway-auth cache (`mc-openclaw-device-auth-v1`) for persisted `hello-ok.auth.deviceToken` reuse across reconnects on web and in the native iOS shell
- Native bridge support for gateway-auth storage (`gatewayAuth:get`, `gatewayAuth:set`, `gatewayAuth:delete`) backed by Keychain on iOS
- Runtime tests covering hello-ok auth persistence, cached-device-token reuse, bounded `AUTH_TOKEN_MISMATCH` retry, and session invalidation events

### Changed
- OpenClaw handshake now signs the v3 device-auth payload with `platform` and `deviceFamily` on both web and iOS; MobileClaw no longer silently downgrades when device signing fails
- `types/chat.ts` now models `hello-ok` auth/features/policy payloads plus newer gateway method and event names without hard-coding the full surface area
- `useOpenClawRuntime.ts` now capability-gates `sessions.subscribe` / `sessions.messages.subscribe`, treats `session.message` / `session.tool` / `sessions.changed` as invalidation signals, and surfaces gateway shutdown/auth guidance more explicitly
- Session switcher now marks session lists dirty and refreshes immediately when session-change events arrive

## 2026-03-03

### Added
Expand Down
19 changes: 14 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,20 @@ MobileClaw has a native iOS app that wraps the webapp in a WKWebView. See [`ios/
MobileClaw connects to OpenClaw's gateway WebSocket. Protocol frames:

1. **Server sends** `event:connect.challenge` with nonce
2. **Client responds** with `req:connect` including auth token, capabilities
3. **Server responds** with `res:hello-ok` including server info, session snapshot
4. **Client requests** `req:chat.history` to load message history
5. **Messages flow** via `event:chat` (delta/final/aborted/error) and `event:agent` (content/tool/reasoning/lifecycle streams)
6. **Client sends** `req:chat.send` with user messages
2. **Client responds** with `req:connect` using protocol `3`, a v3 device-auth signature, and either a shared auth token or a cached `auth.deviceToken`
3. **Server responds** with `res:hello-ok` including server info, session snapshot, `features`, `policy`, and optional `auth.deviceToken` state
4. **Client persists** `hello-ok.auth.deviceToken` per normalized gateway URL and reuses it on later connects when the current shared token hash still matches
5. **Client requests** `req:chat.history` to load message history
6. **Messages flow** via `event:chat` (delta/final/aborted/error) and `event:agent` (content/tool/reasoning/lifecycle streams)
7. **Client optionally subscribes** to `sessions.subscribe` and `sessions.messages.subscribe` when the gateway advertises those methods, but still uses `chat.history` as the source of truth for transcript reconciliation
8. **Client sends** `req:chat.send` with user messages

### Protocol Notes

- `lib/deviceIdentity.ts` and `ios/MobileClaw/Networking/DeviceIdentity.swift` must stay aligned. Both build the same v3 auth payload: `v3|deviceId|clientId|clientMode|role|scopes|signedAtMs|token|nonce|platform|deviceFamily`.
- Device-token cache lives in `lib/gatewayAuth.ts` under `mc-openclaw-device-auth-v1`. Web uses `localStorage`; native uses the bridge-backed Keychain store.
- MobileClaw treats `session.message`, `session.tool`, and `sessions.changed` as invalidation signals only. The realtime UI still renders from `chat` and `agent` events.
- On `AUTH_TOKEN_MISMATCH`, MobileClaw retries once with a cached device token if the gateway says `canRetryWithDeviceToken`. `DEVICE_AUTH_*` errors are treated as client bugs and are surfaced directly.

<cicada>
**ALWAYS use cicada-mcp tools for Elixir and Python code searches. NEVER use Grep/Find for these tasks.**
Expand Down
5 changes: 4 additions & 1 deletion CONCEPTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
- **Hook**: `hooks/useAppMode.ts` — provides `isNative`, `isNativeRef`
- **Activation**: URL param `?native=true` or `window.__nativeMode === true`
- **Effect**: Adds `"native"` class to body, hides chrome, transparent background, uses `lib/nativeBridge.ts` for Swift ↔ WebView communication
- **Auth/storage bridge**: Native handles device signing and gateway-auth cache persistence through `identity:sign`, `gatewayAuth:get`, `gatewayAuth:set`, and `gatewayAuth:delete`
- **Bootstrap**: Native bridge handler registered in `hooks/chat/useModeBootstrap.ts`

---
Expand Down Expand Up @@ -142,7 +143,9 @@ All blocks are rendered inside `MessageRow.tsx`. Block data is represented as `C
### OpenClaw (Default)
- **Runtime**: `hooks/chat/useOpenClawRuntime.ts`
- **Protocol**: WebSocket (`lib/useWebSocket.ts`)
- **Flow**: Challenge → connect → hello-ok → history → event streams (`event:chat`, `event:agent`)
- **Flow**: Challenge → signed protocol-3 connect → hello-ok → history → event streams (`event:chat`, `event:agent`)
- **Auth**: `lib/deviceIdentity.ts` signs v3 connect challenges; `lib/gatewayAuth.ts` persists per-gateway device tokens and handles one-shot retry on `AUTH_TOKEN_MISMATCH`
- **Session freshness**: Uses `sessions.subscribe` / `sessions.messages.subscribe` when the gateway advertises them, but only as invalidation signals for `sessions.list` and `chat.history`
- **Features**: Session switching, model selection, slash commands, full agent protocol

### LM Studio
Expand Down
13 changes: 13 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,16 @@
--color-sidebar-ring: var(--sidebar-ring);
}

[data-mobileclaw-embedded] {
--primary: oklch(0.313 0 0);
--primary-foreground: oklch(0.985 0 0);
}

.dark [data-mobileclaw-embedded] {
--primary: oklch(0.258 0 0);
--primary-foreground: oklch(0.911 0 0);
}

@theme {
/* Font-size scale — boosted so text-sm = 16px body baseline */
--text-2xs: 0.75rem; /* 12px */
Expand All @@ -148,6 +158,9 @@
* {
@apply border-border outline-ring/50;
}
html {
@apply bg-background text-foreground;
}
body {
@apply bg-background text-foreground;
overflow: hidden;
Expand Down
21 changes: 19 additions & 2 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,15 @@ export const metadata: Metadata = {
// Also registers the service worker for PWA support
const headScript = `
(function() {
var isDev = ${JSON.stringify(process.env.NODE_ENV !== 'production')};
var params = new URLSearchParams(location.search);
var detached = params.has('detached');
var detachedMode = detached ? params.get('mode') : null;
var host = location.hostname;
var isIpV4 = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/.test(host);
var isLocalHost = host === 'localhost' || host === '127.0.0.1' || host === '0.0.0.0' || host.endsWith('.local');
var isPrivateLanIp = /^10\\./.test(host) || /^192\\.168\\./.test(host) || /^172\\.(1[6-9]|2\\d|3[0-1])\\./.test(host);
var shouldDisableServiceWorker = isDev || isLocalHost || (isIpV4 && isPrivateLanIp);
try {
if (detachedMode === 'dark') {
document.documentElement.classList.add('dark');
Expand All @@ -53,8 +59,19 @@ const headScript = `
if (detached) {
document.documentElement.classList.add('detached-loading');
}
if ('serviceWorker' in navigator && location.hostname !== 'localhost' && !window.__nativeMode) {
if ('serviceWorker' in navigator && !window.__nativeMode) {
window.addEventListener('load', function() {
if (shouldDisableServiceWorker) {
navigator.serviceWorker.getRegistrations().then(function(registrations) {
registrations.forEach(function(registration) { registration.unregister(); });
});
if (window.caches && caches.keys) {
caches.keys().then(function(keys) {
keys.forEach(function(key) { caches.delete(key); });
});
}
return;
}
navigator.serviceWorker.register('/sw.js');
});
}
Expand All @@ -67,7 +84,7 @@ export default function RootLayout({
children: React.ReactNode
}>) {
return (
<html lang="en" suppressHydrationWarning>
<html lang="en" suppressHydrationWarning className="bg-background text-foreground">
<head>
<script dangerouslySetInnerHTML={{ __html: headScript }} />
</head>
Expand Down
96 changes: 88 additions & 8 deletions app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import { buildDisplayMessages } from "@mc/lib/chat/messageTransforms";
import { applyNativeZenMode } from "@mc/lib/chat/zenBridge";
import { DEFAULT_INPUT_ZONE_HEIGHT, getChatBottomPad } from "@mc/lib/chat/layout";
import { getChatLayoutConfig } from "@mc/lib/chat/layoutMode";

import type {
BackendMode,
Expand All @@ -51,6 +52,8 @@
import { useSubagentStore } from "@mc/hooks/useSubagentStore";
import { formatSessionName } from "@mc/hooks/useSessionSwitcher";
import { useAppMode } from "@mc/hooks/useAppMode";
import { useIsMobileViewport } from "@mc/hooks/useIsMobileViewport";
import { useWidgetContext } from "@mc/lib/widgetContext";

import { useModeBootstrap } from "@mc/hooks/chat/useModeBootstrap";
import { useOpenClawRuntime } from "@mc/hooks/chat/useOpenClawRuntime";
Expand All @@ -76,19 +79,56 @@
const isStreamingRef = useRef(false);
const [streamingId, setStreamingId] = useState<string | null>(null);
const [sentAnimId, setSentAnimId] = useState<string | null>(null);
const { isDetached, detachedNoBorder, isNative, uploadDisabled, hideChrome, isDetachedRef, isNativeRef } = useAppMode();
const { isDetached, detachedNoBorder, detachedNoShell, detachedSurface, isNative, uploadDisabled, hideChrome, isDetachedRef, isNativeRef } = useAppMode();
const widgetCtx = useWidgetContext();
const isMobileViewport = useIsMobileViewport();
const {
useDocumentScroll,
shellHeight,
useKeyboardLayout: shouldUseKeyboardLayout,
} = getChatLayoutConfig({
isDetached,
detachedNoShell,
isNative,
isMobileViewport,
detachedSurface,
});

useEffect(() => {
if (!useDocumentScroll) return;

const prevHtmlOverflow = document.documentElement.style.overflow;
const prevBodyOverflow = document.body.style.overflow;
const prevBodyOverscroll = document.body.style.overscrollBehavior;

document.documentElement.style.overflow = "visible";
document.body.style.overflow = "visible";
document.body.style.overscrollBehavior = "auto";

return () => {
document.documentElement.style.overflow = prevHtmlOverflow;
document.body.style.overflow = prevBodyOverflow;
document.body.style.overscrollBehavior = prevBodyOverscroll;
};
}, [useDocumentScroll]);

const {
scrollRef,
bottomRef,
footerReserveRef,
morphRef,
scrollPhase,
pinnedToBottomRef,
pinLockUntilRef,
handleScroll,
scrollToBottom,
updateGraceForStreamingChange,
} = useScrollManager(messages, isStreamingRef, isNativeRef);
} = useScrollManager({
messages,
isStreamingRef,
isNativeRef,
useDocumentScroll,
});

const setIsStreaming = useCallback((value: boolean) => {
const wasStreaming = isStreamingRef.current;
Expand Down Expand Up @@ -151,7 +191,7 @@
}
}
} catch {}
}, []);

Check warning on line 194 in app/page.tsx

View workflow job for this annotation

GitHub Actions / test

React Hook useEffect has a missing dependency: 'isNativeRef'. Either include it or remove the dependency array

const handlePinSubagent = useCallback((info: { toolCallId: string | null; childSessionKey: string | null; taskName: string; model: string | null }) => {
setPinnedSubagent((prev) => {
Expand Down Expand Up @@ -247,7 +287,7 @@
const preview = getTextFromContent(msg.content);
if (hasHeartbeatOnOwnLine(preview) || hasUnquotedMarker(preview, NO_REPLY_MARKER)) return;
notifyMessageComplete(preview);
}, []);

Check warning on line 290 in app/page.tsx

View workflow job for this annotation

GitHub Actions / test

React Hook useCallback has a missing dependency: 'isDetachedRef'. Either include it or remove the dependency array

const [turnstileVerified, setTurnstileVerified] = useState(!TURNSTILE_SITE_KEY);
const [turnstileChecked, setTurnstileChecked] = useState(!TURNSTILE_SITE_KEY);
Expand All @@ -260,7 +300,7 @@
setTurnstileChecked(true);
}, []);

useKeyboardLayout(appRef, floatingBarRef, bottomRef, !isNative);
useKeyboardLayout(appRef, floatingBarRef, bottomRef, shouldUseKeyboardLayout);

const appendContentDelta = useCallback((runId: string, delta: string, ts: number) => {
beginContentArrival();
Expand Down Expand Up @@ -289,10 +329,10 @@
});
}, [beginContentArrival]);

const addToolCall = useCallback((runId: string, name: string, ts: number, toolCallId?: string, args?: string) => {
const addToolCall = useCallback((runId: string, name: string, ts: number, toolCallId?: string, args?: string, narration?: string) => {
beginContentArrival();
setMessages((prev) => {
const next = addToolCallToMessages(prev, runId, name, ts, toolCallId, args);
const next = addToolCallToMessages(prev, runId, name, ts, toolCallId, args, narration);
if (next.created) setStreamingId(runId);
return next.messages;
});
Expand Down Expand Up @@ -361,6 +401,7 @@
} = useOpenClawRuntime({
backendMode,
isNative,
useDocumentScroll,
isDetachedRef,
isNativeRef,
scrollRef,
Expand Down Expand Up @@ -395,6 +436,7 @@
replacePluginPart,
removePluginPart,
upsertCanvasPluginByMessageId,
onTokenRefresh: widgetCtx?.onTokenRefresh,
});

const { setQuoteText, quotePopup, quotePopupRef, handleAcceptQuote: rawAcceptQuote } = useQuoteSelection({ scrollRef });
Expand Down Expand Up @@ -512,7 +554,28 @@

useImperativeHandle(forwardedRef, () => ({
setValue: (v: string) => chatInputRef.current?.setValue(v),
}), []);
addInputAttachment: (kind: string, data: unknown) => add(kind, data),
sendCommand: (text: string) => void sendMessage(text),
switchSession: (key: string) => handleSessionSelect(key),
}), [add, sendMessage, handleSessionSelect]);

// ── Bridge session state to host via widget context callback ──────────────
const onSessionsChangeRef = useRef(widgetCtx?.onSessionsChange);
onSessionsChangeRef.current = widgetCtx?.onSessionsChange;

// Fire callback whenever session state changes
useEffect(() => {
onSessionsChangeRef.current?.(sessions, currentSessionKey, sessionsLoading);
}, [sessions, currentSessionKey, sessionsLoading]);

// Eagerly fetch session list when host provides callback (so sidebar has data immediately)
const didEagerFetchRef = useRef(false);
useEffect(() => {
if (onSessionsChangeRef.current && !didEagerFetchRef.current && backendMode === "openclaw" && isConnected) {
didEagerFetchRef.current = true;
requestSessionsList();
}
}, [backendMode, isConnected, requestSessionsList]);

const {
queuedMessage,
Expand Down Expand Up @@ -702,11 +765,11 @@
const bottomPad = getChatBottomPad({
isNative,
isDetached,
useDocumentScroll,
inputZoneHeight,
hasQueued: !!queuedMessage,
hasPinnedSubagent: !!pinnedSubagent,
});

const lastUserMessage = useMemo(() => {
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === "user" && !messages[i].isContext) {
Expand All @@ -716,6 +779,9 @@
return "";
}, [messages]);

const showAppBackground = useDocumentScroll || !hideChrome;
const shellStyle = shellHeight ? { height: shellHeight } : undefined;

if (!turnstileChecked) return null;
if (!turnstileVerified && TURNSTILE_SITE_KEY) {
return (
Expand All @@ -730,7 +796,11 @@
}

return (
<div ref={appRef} className={`relative flex flex-col overflow-hidden ${hideChrome ? "" : "bg-background"}`} style={{ height: isDetached ? "100%" : "100dvh" }}>
<div
ref={appRef}
className={`relative flex flex-col ${useDocumentScroll ? "min-h-svh overflow-visible" : "min-h-0 overflow-hidden"} ${showAppBackground ? "bg-background" : ""}`}
style={shellStyle}
>
<ChatChrome
hideChrome={hideChrome}
openclawUrl={openclawUrl}
Expand Down Expand Up @@ -770,6 +840,7 @@
isDetached={isDetached}
detachedNoBorder={detachedNoBorder}
isNative={isNative}
useDocumentScroll={useDocumentScroll}
historyLoaded={historyLoaded}
inputZoneHeight={inputZoneHeight}
bottomPad={bottomPad}
Expand Down Expand Up @@ -815,7 +886,9 @@
<ChatComposerBar
isNative={isNative}
isDetached={isDetached}
useDocumentScroll={useDocumentScroll}
floatingBarRef={floatingBarRef}
footerReserveRef={footerReserveRef}
morphRef={morphRef}
pinnedSubagent={pinnedSubagent}
subagentStore={subagentStore}
Expand Down Expand Up @@ -848,6 +921,13 @@
lastUserMessage={lastUserMessage}
uploadDisabled={uploadDisabled}
/>

{useDocumentScroll && (
<div
aria-hidden="true"
style={{ height: "calc(env(safe-area-inset-bottom, 0px) + 10dvh)", flexShrink: 0 }}
/>
)}
</div>
);
});
Loading
Loading