Skip to content

Commit 4156140

Browse files
fix: IME composition Enter key + notification click navigation
- Add useIMEComposition hook to prevent Enter-to-send during Chinese IME confirmation across all input components - WKWebView capture-phase IME fix injected via WKUserScript - SW notificationclick now passes sessionKey via postMessage for correct session navigation on notification tap - Add macOS native notification bridge (notifyIfBackground) for background message alerts via UNUserNotificationCenter - Listen for SW messages and URL push_session param for deep-linking
1 parent 1a9783f commit 4156140

10 files changed

Lines changed: 176 additions & 10 deletions

File tree

packages/web/public/sw.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ self.addEventListener("push", (event) => {
178178

179179
self.addEventListener("notificationclick", (event) => {
180180
event.notification.close();
181+
const sessionKey = event.notification.data?.sessionKey;
181182

182183
event.waitUntil(
183184
clients
@@ -188,10 +189,17 @@ self.addEventListener("notificationclick", (event) => {
188189
client.url.includes("console.botschat.app") ||
189190
client.url.includes("localhost")
190191
) {
192+
if (sessionKey) {
193+
client.postMessage({ type: "push-nav", sessionKey });
194+
}
191195
return client.focus();
192196
}
193197
}
194-
return clients.openWindow("https://console.botschat.app");
198+
// No existing window — open a new one with the sessionKey hint
199+
const url = sessionKey
200+
? "https://console.botschat.app/?push_session=" + encodeURIComponent(sessionKey)
201+
: "https://console.botschat.app";
202+
return clients.openWindow(url);
195203
})
196204
);
197205
});

packages/web/src/App.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
import { getToken, setToken, setRefreshToken, agentsApi, channelsApi, tasksApi, jobsApi, authApi, messagesApi, modelsApi, meApi, sessionsApi, type ModelInfo } from "./api";
1414
import { ModelSelect } from "./components/ModelSelect";
1515
import { BotsChatWSClient, type WSMessage } from "./ws";
16-
import { initPushNotifications, getPendingPushNav, clearPendingPushNav } from "./push";
16+
import { initPushNotifications, getPendingPushNav, clearPendingPushNav, notifyIfBackground } from "./push";
1717
import { setupForegroundDetection, sendFocusUpdate } from "./foreground";
1818
import { IconRail } from "./components/IconRail";
1919
import { Sidebar } from "./components/Sidebar";
@@ -603,6 +603,15 @@ export default function App() {
603603
// Log every incoming WS message
604604
dlog.wsIn("WS", `${msg.type}`, msg);
605605

606+
// macOS native notification for background messages
607+
notifyIfBackground({
608+
type: msg.type,
609+
text: msg.text as string | undefined,
610+
caption: msg.caption as string | undefined,
611+
sessionKey: sessionKey,
612+
agentName: msg.agentName as string | undefined,
613+
});
614+
606615
// Helper: extract base sessionKey (strip ":thread:*" suffix) for comparison
607616
const getBaseSessionKey = (sk: string | undefined): string | undefined => {
608617
if (!sk) return undefined;

packages/web/src/components/ChatWindow.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { WSMessage } from "../ws";
44
import { MessageContent } from "./MessageContent";
55
import { SessionTabs } from "./SessionTabs";
66
import { useIsMobile } from "../hooks/useIsMobile";
7+
import { useIMEComposition } from "../hooks/useIMEComposition";
78
import { dlog } from "../debug-log";
89
import { randomUUID } from "../utils/uuid";
910
import { E2eService } from "../e2e";
@@ -158,6 +159,7 @@ export function ChatWindow({ sendMessage }: ChatWindowProps) {
158159
const state = useAppState();
159160
const dispatch = useAppDispatch();
160161
const isMobile = useIsMobile();
162+
const { onCompositionStart, onCompositionEnd, isIMEActive } = useIMEComposition();
161163
const [input, setInput] = useState("");
162164
const [skillVersion, setSkillVersion] = useState(0); // bump to re-sort skills
163165
const [pendingImage, setPendingImage] = useState<{ file: File; preview: string } | null>(null);
@@ -902,11 +904,13 @@ export function ChatWindow({ sendMessage }: ChatWindowProps) {
902904
value={input}
903905
onChange={(e) => setInput(e.target.value)}
904906
onKeyDown={(e) => {
905-
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
907+
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing && !isIMEActive()) {
906908
e.preventDefault();
907909
handleSend();
908910
}
909911
}}
912+
onCompositionStart={onCompositionStart}
913+
onCompositionEnd={onCompositionEnd}
910914
onPaste={handlePaste}
911915
placeholder={
912916
state.openclawConnected

packages/web/src/components/CronSidebar.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import React, { useState } from "react";
22
import { useAppState, useAppDispatch } from "../store";
33
import { tasksApi, channelsApi } from "../api";
44
import { dlog } from "../debug-log";
5+
import { useIMEComposition } from "../hooks/useIMEComposition";
56

67
function relativeTime(ts: number): string {
78
const now = Date.now() / 1000;
@@ -17,6 +18,7 @@ export function CronSidebar({ onNavigate }: { onNavigate?: () => void } = {}) {
1718
const dispatch = useAppDispatch();
1819
const [showCreate, setShowCreate] = useState(false);
1920
const [newName, setNewName] = useState("");
21+
const { onCompositionStart, onCompositionEnd, isIMEActive } = useIMEComposition();
2022

2123
const handleSelect = (taskId: string) => {
2224
// Ensure activeView is "automations" so cron data loads correctly
@@ -93,7 +95,9 @@ export function CronSidebar({ onNavigate }: { onNavigate?: () => void } = {}) {
9395
placeholder="Automation name"
9496
value={newName}
9597
onChange={(e) => setNewName(e.target.value)}
96-
onKeyDown={(e) => e.key === "Enter" && !e.nativeEvent.isComposing && handleCreateTask()}
98+
onKeyDown={(e) => e.key === "Enter" && !e.nativeEvent.isComposing && !isIMEActive() && handleCreateTask()}
99+
onCompositionStart={onCompositionStart}
100+
onCompositionEnd={onCompositionEnd}
97101
className="w-full px-2 py-1.5 text-caption text-[--text-sidebar] rounded-sm focus:outline-none placeholder:text-[--text-muted]"
98102
style={{ background: "var(--sidebar-hover)", border: "1px solid var(--sidebar-border)" }}
99103
autoFocus

packages/web/src/components/SessionTabs.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import React, { useState, useRef, useEffect, useCallback } from "react";
22
import { useAppState, useAppDispatch } from "../store";
33
import { sessionsApi, channelsApi, agentsApi } from "../api";
44
import { dlog } from "../debug-log";
5+
import { useIMEComposition } from "../hooks/useIMEComposition";
56

67
// ---------------------------------------------------------------------------
78
// Session history — tracks per-channel usage order in localStorage
@@ -88,6 +89,7 @@ export function SessionTabs({ channelId }: SessionTabsProps) {
8889
const state = useAppState();
8990
const dispatch = useAppDispatch();
9091
const [editingId, setEditingId] = useState<string | null>(null);
92+
const { onCompositionStart, onCompositionEnd, isIMEActive } = useIMEComposition();
9193
const [editValue, setEditValue] = useState("");
9294
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
9395
const editRef = useRef<HTMLInputElement>(null);
@@ -267,14 +269,16 @@ export function SessionTabs({ channelId }: SessionTabsProps) {
267269
onChange={(e) => setEditValue(e.target.value)}
268270
onBlur={commitRename}
269271
onKeyDown={(e) => {
270-
if (e.key === "Enter" && !e.nativeEvent.isComposing) {
272+
if (e.key === "Enter" && !e.nativeEvent.isComposing && !isIMEActive()) {
271273
e.preventDefault();
272274
commitRename();
273275
}
274276
if (e.key === "Escape") {
275277
setEditingId(null);
276278
}
277279
}}
280+
onCompositionStart={onCompositionStart}
281+
onCompositionEnd={onCompositionEnd}
278282
className="px-2.5 py-1 text-caption rounded-t-md focus:outline-none"
279283
style={{
280284
background: "var(--bg-hover)",

packages/web/src/components/Sidebar.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@ import React, { useState, useRef, useEffect } from "react";
22
import { useAppState, useAppDispatch } from "../store";
33
import { agentsApi, channelsApi } from "../api";
44
import { dlog } from "../debug-log";
5+
import { useIMEComposition } from "../hooks/useIMEComposition";
56

67
export function Sidebar({ onOpenSettings, onNavigate }: { onOpenSettings?: () => void; onNavigate?: () => void } = {}) {
78
const state = useAppState();
89
const dispatch = useAppDispatch();
910
const [showCreate, setShowCreate] = useState(false);
1011
const [newName, setNewName] = useState("");
1112
const [newDesc, setNewDesc] = useState("");
13+
const { onCompositionStart, onCompositionEnd, isIMEActive } = useIMEComposition();
1214
const [channelsExpanded, setChannelsExpanded] = useState(true);
1315

1416
const handleCreate = async () => {
@@ -189,7 +191,9 @@ export function Sidebar({ onOpenSettings, onNavigate }: { onOpenSettings?: () =>
189191
placeholder="Channel name"
190192
value={newName}
191193
onChange={(e) => setNewName(e.target.value)}
192-
onKeyDown={(e) => e.key === "Enter" && !e.nativeEvent.isComposing && handleCreate()}
194+
onKeyDown={(e) => e.key === "Enter" && !e.nativeEvent.isComposing && !isIMEActive() && handleCreate()}
195+
onCompositionStart={onCompositionStart}
196+
onCompositionEnd={onCompositionEnd}
193197
className="w-full px-2 py-1.5 text-caption text-[--text-sidebar] rounded-sm focus:outline-none placeholder:text-[--text-muted]"
194198
style={{ background: "var(--sidebar-hover)", border: "1px solid var(--sidebar-border)" }}
195199
autoFocus
@@ -199,7 +203,9 @@ export function Sidebar({ onOpenSettings, onNavigate }: { onOpenSettings?: () =>
199203
placeholder="Description (optional)"
200204
value={newDesc}
201205
onChange={(e) => setNewDesc(e.target.value)}
202-
onKeyDown={(e) => e.key === "Enter" && !e.nativeEvent.isComposing && handleCreate()}
206+
onKeyDown={(e) => e.key === "Enter" && !e.nativeEvent.isComposing && !isIMEActive() && handleCreate()}
207+
onCompositionStart={onCompositionStart}
208+
onCompositionEnd={onCompositionEnd}
203209
className="w-full px-2 py-1.5 text-caption text-[--text-sidebar] rounded-sm focus:outline-none placeholder:text-[--text-muted]"
204210
style={{ background: "var(--sidebar-hover)", border: "1px solid var(--sidebar-border)" }}
205211
/>

packages/web/src/components/TaskBar.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import React, { useState } from "react";
22
import { useAppState, useAppDispatch } from "../store";
33
import { tasksApi, type Task } from "../api";
4+
import { useIMEComposition } from "../hooks/useIMEComposition";
45

56
const SCHEDULE_PRESETS = [
67
{ label: "Every 30 min", value: "every 30m" },
@@ -18,6 +19,7 @@ export function TaskBar() {
1819
const state = useAppState();
1920
const dispatch = useAppDispatch();
2021
const [showCreate, setShowCreate] = useState(false);
22+
const { onCompositionStart, onCompositionEnd, isIMEActive } = useIMEComposition();
2123
const [newTaskName, setNewTaskName] = useState("");
2224
const [newTaskKind, setNewTaskKind] = useState<"adhoc" | "background">("adhoc");
2325
const [newSchedule, setNewSchedule] = useState("");
@@ -200,7 +202,9 @@ export function TaskBar() {
200202
placeholder="Task name"
201203
value={newTaskName}
202204
onChange={(e) => setNewTaskName(e.target.value)}
203-
onKeyDown={(e) => e.key === "Enter" && !e.nativeEvent.isComposing && handleCreate()}
205+
onKeyDown={(e) => e.key === "Enter" && !e.nativeEvent.isComposing && !isIMEActive() && handleCreate()}
206+
onCompositionStart={onCompositionStart}
207+
onCompositionEnd={onCompositionEnd}
204208
className="px-2 py-1 text-caption rounded-sm focus:outline-none w-36 placeholder:text-[--text-muted]"
205209
style={{
206210
background: "var(--bg-hover)",

packages/web/src/components/ThreadPanel.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { E2eService } from "../e2e";
77
import { dlog } from "../debug-log";
88
import { randomUUID } from "../utils/uuid";
99
import { formatMessageTime, formatFullDateTime } from "../utils/time";
10+
import { useIMEComposition } from "../hooks/useIMEComposition";
1011

1112
/** Simple string hash for action prompt keys (matches MessageContent / ChatWindow) */
1213
function simpleHash(str: string): string {
@@ -26,6 +27,7 @@ export function ThreadPanel({ sendMessage }: ThreadPanelProps) {
2627
const state = useAppState();
2728
const dispatch = useAppDispatch();
2829
const [input, setInput] = useState("");
30+
const { onCompositionStart, onCompositionEnd, isIMEActive } = useIMEComposition();
2931

3032
// Load thread message history when a thread is opened
3133
useEffect(() => {
@@ -334,11 +336,13 @@ export function ThreadPanel({ sendMessage }: ThreadPanelProps) {
334336
value={input}
335337
onChange={(e) => setInput(e.target.value)}
336338
onKeyDown={(e) => {
337-
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
339+
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing && !isIMEActive()) {
338340
e.preventDefault();
339341
handleSend();
340342
}
341343
}}
344+
onCompositionStart={onCompositionStart}
345+
onCompositionEnd={onCompositionEnd}
342346
placeholder="Reply…"
343347
rows={1}
344348
className="w-full px-3 py-2 text-body bg-transparent resize-none focus:outline-none placeholder:text-[--text-muted]"
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { useRef, useCallback } from "react";
2+
3+
/**
4+
* Tracks IME composition state to prevent Enter-to-send during
5+
* Chinese/Japanese/Korean input confirmation.
6+
*
7+
* In WebKit/WKWebView, when a user presses Enter to accept an IME candidate,
8+
* the event order is: compositionend → keydown(Enter, isComposing=false).
9+
* The native `isComposing` flag is already false by the time keydown fires,
10+
* so checking it alone is insufficient. This hook keeps a flag active for one
11+
* animation frame after compositionend to swallow that trailing Enter.
12+
*/
13+
export function useIMEComposition() {
14+
const composingRef = useRef(false);
15+
const justEndedRef = useRef(false);
16+
17+
const onCompositionStart = useCallback(() => {
18+
composingRef.current = true;
19+
justEndedRef.current = false;
20+
}, []);
21+
22+
const onCompositionEnd = useCallback(() => {
23+
composingRef.current = false;
24+
justEndedRef.current = true;
25+
requestAnimationFrame(() => {
26+
justEndedRef.current = false;
27+
});
28+
}, []);
29+
30+
const isIMEActive = useCallback(
31+
() => composingRef.current || justEndedRef.current,
32+
[],
33+
);
34+
35+
return { onCompositionStart, onCompositionEnd, isIMEActive } as const;
36+
}

packages/web/src/push.ts

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,11 +96,96 @@ export async function clearE2eKeyFromSW(): Promise<void> {
9696
}
9797
}
9898

99+
// ---- macOS native notification bridge ----
100+
101+
declare global {
102+
interface Window {
103+
__BOTSCHAT_NATIVE__?: boolean;
104+
__BOTSCHAT_PLATFORM__?: string;
105+
__BOTSCHAT_NATIVE_NOTIFY__?: (payload: {
106+
title: string;
107+
body: string;
108+
sessionKey?: string;
109+
}) => void;
110+
__BOTSCHAT_NATIVE_REQUEST_PERMISSION__?: () => void;
111+
}
112+
}
113+
114+
function isMacOSNative(): boolean {
115+
return !!(window.__BOTSCHAT_NATIVE__ && window.__BOTSCHAT_PLATFORM__ === "macos");
116+
}
117+
118+
async function initMacOSPush(): Promise<void> {
119+
try {
120+
window.__BOTSCHAT_NATIVE_REQUEST_PERMISSION__?.();
121+
dlog.info("Push", "macOS native notification permission requested");
122+
} catch (err) {
123+
dlog.error("Push", "macOS notification init failed", err);
124+
}
125+
}
126+
127+
/**
128+
* Show a native macOS notification when a message arrives via WS and
129+
* the window is not focused. Call this from the WS message handler.
130+
*/
131+
export function notifyIfBackground(msg: {
132+
type: string;
133+
text?: string;
134+
caption?: string;
135+
sessionKey?: string;
136+
agentName?: string;
137+
}): void {
138+
if (!isMacOSNative()) return;
139+
if (!document.hidden && document.hasFocus()) return;
140+
if (!window.__BOTSCHAT_NATIVE_NOTIFY__) return;
141+
142+
let body = "";
143+
const title = msg.agentName || "BotsChat";
144+
145+
if (msg.type === "agent.text" && msg.text) {
146+
body = msg.text.length > 200 ? msg.text.slice(0, 200) + "…" : msg.text;
147+
} else if (msg.type === "agent.media") {
148+
body = msg.caption || "Sent a media file";
149+
} else {
150+
return;
151+
}
152+
153+
window.__BOTSCHAT_NATIVE_NOTIFY__({ title, body, sessionKey: msg.sessionKey });
154+
}
155+
156+
// ---- Service Worker message listener (notification click → navigation) ----
157+
158+
function setupSWMessageListener(): void {
159+
if (!("serviceWorker" in navigator)) return;
160+
navigator.serviceWorker.addEventListener("message", (event) => {
161+
if (event.data?.type === "push-nav" && event.data.sessionKey) {
162+
dlog.info("Push", `SW postMessage push-nav: ${event.data.sessionKey}`);
163+
firePushNav(event.data.sessionKey);
164+
}
165+
});
166+
167+
// Also check URL for push_session param (when SW opens a new window)
168+
const params = new URLSearchParams(window.location.search);
169+
const pushSession = params.get("push_session");
170+
if (pushSession) {
171+
dlog.info("Push", `URL push_session param: ${pushSession}`);
172+
firePushNav(pushSession);
173+
// Clean up the URL parameter
174+
params.delete("push_session");
175+
const clean = params.toString();
176+
const newUrl = window.location.pathname + (clean ? "?" + clean : "") + window.location.hash;
177+
window.history.replaceState({}, "", newUrl);
178+
}
179+
}
180+
99181
// ---- Push initialization ----
100182

101183
export async function initPushNotifications(): Promise<void> {
102184
if (initialized) return;
103185

186+
// Listen for SW notification-click messages (must be before any early return)
187+
setupSWMessageListener();
188+
104189
// Sync E2E key so push notifications can be decrypted
105190
await syncE2eKeyToSW();
106191

@@ -109,7 +194,9 @@ export async function initPushNotifications(): Promise<void> {
109194
syncE2eKeyToSW().catch(() => {});
110195
});
111196

112-
if (Capacitor.isNativePlatform()) {
197+
if (isMacOSNative()) {
198+
await initMacOSPush();
199+
} else if (Capacitor.isNativePlatform()) {
113200
await initNativePush();
114201
} else {
115202
await initWebPush();

0 commit comments

Comments
 (0)