Skip to content

Commit fe0994c

Browse files
SWangHashSWangHash
authored andcommitted
!86 merge wc/doc into main
fix(ohos): fire dialog-completion notification when the desktop app is minimized Created-by: SWangHash Commit-by: SWangHash Merged-by: SWangHash Description: ## Summary <!-- Briefly describe what changed. --> Fixes # ## Type and Areas Type: <!-- Feature / bug fix / regression fix / refactor / UI/UX / docs / test / CI / dependency / other. --> Areas: <!-- Rust core, desktop/Tauri, web UI, mobile web, server/relay, AI adapters, installer, docs, etc. --> ## Motivation / Impact <!-- What problem does this solve, and what changes for users or developers? Write "No direct user-facing change" if applicable. --> ## Verification <!-- List exact commands, manual checks, and outcomes. For docs-only or template-only changes, use the lightest relevant checks or explain why runtime checks were skipped. --> ## Reviewer Notes <!-- Optional: screenshots, architecture notes, compatibility risks, migration notes, or rollback guidance. --> ## Checklist - [ ] This PR is focused and does not include secrets, temporary prompts, generated scratch files, or unrelated artifacts. - [ ] Relevant verification is recorded above, or skipped checks are explained. - [ ] User-facing strings, docs, and locales are updated where applicable. See merge request: OpenHarmonyPCDeveloper/BitFun!86
2 parents 78fd7ff + 0297a48 commit fe0994c

8 files changed

Lines changed: 138 additions & 20 deletions

File tree

‎src/apps/desktop/src/api/system_api.rs‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1114,6 +1114,32 @@ pub async fn send_system_notification(
11141114
Err("No notification provided".to_string())
11151115
}
11161116

1117+
/// When a system-level error occurs while the desktop window is minimized,
1118+
/// surface it as an OS notification directly from the host (Rust -> ArkTS),
1119+
/// bypassing the web-ui. `AgenticEvent::SystemError` is filtered out of the
1120+
/// frontend projection, so the web-ui's dialog-completion notification path
1121+
/// never sees it; this is the only path that reflects system errors while the
1122+
/// app is minimized. On non-OHOS hosts the window-state query returns an error
1123+
/// (no ArkTS function registered) and this no-ops, matching the stub
1124+
/// `send_system_notification` Tauri path.
1125+
pub async fn notify_system_error_if_minimized(error: &str) {
1126+
let Ok(minimized) = crate::api::ohos::window::window_is_minimized().await else {
1127+
return;
1128+
};
1129+
if !minimized {
1130+
return;
1131+
}
1132+
// The error string may carry a multi-line trace; the notification body
1133+
// only needs the first line to be useful.
1134+
let body = error.lines().next().unwrap_or(error).to_string();
1135+
let payload = serde_json::json!({ "title": "BitFun system error", "body": body });
1136+
if let Err(e) =
1137+
crate::api::ohos::ohos_file_system::send_system_notification_ohos(payload.to_string()).await
1138+
{
1139+
log::warn!("Failed to send system-error notification: {e}");
1140+
}
1141+
}
1142+
11171143
#[cfg(test)]
11181144
mod tests {
11191145
#[test]

‎src/apps/desktop/src/lib.rs‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2524,6 +2524,17 @@ async fn deliver_event_to_webview(
25242524
session_event_journal: &SessionEventJournal,
25252525
) {
25262526
let cursor = session_event_journal.record(&event);
2527+
// SystemError is filtered out of the frontend projection, so the web-ui's
2528+
// dialog-completion notification path never sees it. Surface it directly
2529+
// from the host when the window is minimized, so system-level errors are
2530+
// still reflected via an OS notification. Fire-and-forget so the event
2531+
// delivery loop is not blocked on the ArkTS round-trip.
2532+
if let AgenticEvent::SystemError { error, .. } = &event {
2533+
let error_text = error.clone();
2534+
tokio::spawn(async move {
2535+
crate::api::system_api::notify_system_error_if_minimized(&error_text).await;
2536+
});
2537+
}
25272538
let Some(mut projected) = bitfun_events::project_agentic_frontend_event(event) else {
25282539
log::warn!("Unhandled AgenticEvent type in desktop delivery");
25292540
return;

‎src/web-ui/src/app/hooks/dialogCompletionNotifyPolicy.ts‎

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ interface DialogCompletionNotificationInput {
1111
interface DialogCompletionNotificationCopyInput {
1212
sessionTitle?: string | null;
1313
success?: boolean | null;
14+
cancelled?: boolean;
1415
finishReason?: string | null;
1516
t: (key: string, options?: Record<string, unknown>) => string;
1617
}
@@ -40,22 +41,36 @@ export function shouldSendDialogCompletionNotification({
4041
export function buildDialogCompletionNotificationCopy({
4142
sessionTitle,
4243
success,
44+
cancelled,
4345
finishReason,
4446
t,
4547
}: DialogCompletionNotificationCopyInput): { title: string; body: string } {
4648
const trimmedTitle = sessionTitle?.trim();
4749
const failed = success === false;
50+
// Cancelled takes precedence over failed/completed: a cancelled turn is a
51+
// distinct terminal state the user should see as "cancelled", not "stopped".
52+
const titleKey = cancelled
53+
? 'notify.dialogCancelledTitle'
54+
: failed
55+
? 'notify.dialogFailedTitle'
56+
: 'notify.dialogCompletedTitle';
57+
const withSessionKey = cancelled
58+
? 'notify.dialogCancelledWithSession'
59+
: failed
60+
? 'notify.dialogFailedWithSession'
61+
: 'notify.dialogCompletedWithSession';
62+
const fallbackKey = cancelled
63+
? 'notify.dialogCancelledFallback'
64+
: failed
65+
? 'notify.dialogFailedFallback'
66+
: 'notify.dialogCompletedFallback';
4867
const options = {
4968
sessionTitle: trimmedTitle,
5069
finishReason,
5170
};
5271

5372
return {
54-
title: failed
55-
? t('notify.dialogFailedTitle')
56-
: t('notify.dialogCompletedTitle'),
57-
body: trimmedTitle
58-
? t(failed ? 'notify.dialogFailedWithSession' : 'notify.dialogCompletedWithSession', options)
59-
: t(failed ? 'notify.dialogFailedFallback' : 'notify.dialogCompletedFallback', options),
73+
title: t(titleKey),
74+
body: trimmedTitle ? t(withSessionKey, options) : t(fallbackKey, options),
6075
};
6176
}

‎src/web-ui/src/app/hooks/useDialogCompletionNotify.test.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,10 @@ describe('buildDialogCompletionNotificationCopy', () => {
126126
if (key === 'notify.dialogFailedWithSession') {
127127
return `${options?.sessionTitle} stopped unexpectedly.`;
128128
}
129+
if (key === 'notify.dialogCancelledTitle') return 'BitFun task cancelled';
130+
if (key === 'notify.dialogCancelledWithSession') {
131+
return `${options?.sessionTitle} was cancelled.`;
132+
}
129133
return 'A BitFun session is ready.';
130134
};
131135

@@ -166,4 +170,17 @@ describe('buildDialogCompletionNotificationCopy', () => {
166170
body: 'Browser control fix stopped unexpectedly.',
167171
});
168172
});
173+
174+
it('uses cancelled copy when the turn was cancelled', () => {
175+
expect(
176+
buildDialogCompletionNotificationCopy({
177+
sessionTitle: 'Refactor plan',
178+
cancelled: true,
179+
t,
180+
}),
181+
).toEqual({
182+
title: 'BitFun task cancelled',
183+
body: 'Refactor plan was cancelled.',
184+
});
185+
});
169186
});

‎src/web-ui/src/app/hooks/useDialogCompletionNotify.ts‎

Lines changed: 54 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import { useEffect, useRef } from 'react';
22
import { agentAPI } from '@/infrastructure/api';
3+
import type { AgenticEvent } from '@/infrastructure/api/service-api/AgentAPI';
34
import { systemAPI } from '@/infrastructure/api/service-api/SystemAPI';
45
import { configManager } from '@/infrastructure/config/services/ConfigManager';
56
import { flowChatStore } from '@/flow_chat/store/FlowChatStore';
67
import { useI18n } from '@/infrastructure/i18n';
78
import { createLogger } from '@/shared/utils/logger';
89
import { isOpenHarmonyRuntime } from '@/infrastructure/runtime';
10+
import { workspaceAPI } from '@/infrastructure';
911
import {
1012
buildDialogCompletionNotificationCopy,
1113
shouldSendDialogCompletionNotification,
@@ -14,15 +16,18 @@ import {
1416
const log = createLogger('useDialogCompletionNotify');
1517

1618
/**
17-
* Listens for dialog turn completion events and sends an OS-level desktop
18-
* notification (Windows toast / macOS notification center) when the window
19-
* is not focused and the feature is enabled in config.
19+
* Listens for dialog turn completion AND failure events and sends an OS-level
20+
* desktop notification (Windows toast / macOS notification center / HarmonyOS
21+
* Notification Kit) when the window is not focused and the feature is enabled
22+
* in config.
2023
*
21-
* Notification title = session title (or short session id fallback).
22-
* Notification body = fixed "task completed" message.
24+
* Notification title = product title, success vs. failure variant.
25+
* Notification body = session-aware "is ready" / "stopped unexpectedly" copy.
2326
*
24-
* "Not focused" means: the page is hidden (minimized / tab switched) OR
25-
* the window has lost focus to another OS-level application.
27+
* "Not focused" means: the page is hidden (minimized / tab switched) OR the
28+
* window has lost focus to another OS-level application. On HarmonyOS the
29+
* webview does not reliably update document.hidden / fire window blur on
30+
* minimize, so the real minimized state is queried from the host instead.
2631
*/
2732
export const useDialogCompletionNotify = () => {
2833
const { t } = useI18n('common');
@@ -36,9 +41,26 @@ export const useDialogCompletionNotify = () => {
3641
window.addEventListener('focus', handleFocus);
3742
window.addEventListener('blur', handleBlur);
3843

39-
const unlisten = agentAPI.onDialogTurnCompleted(async (event) => {
40-
// Send notification if page is hidden OR window lost OS focus
41-
const isBackground = document.hidden || !windowFocusedRef.current;
44+
const resolveIsBackground = async (): Promise<boolean> => {
45+
// On HarmonyOS the webview does not reliably update document.hidden or
46+
// fire window blur on minimize, so query the real window status from the
47+
// host. The diagnostic log below still captures document.hidden /
48+
// windowFocused for comparison.
49+
if (isOpenHarmonyRuntime()) {
50+
try {
51+
return await workspaceAPI.window_is_minimized();
52+
} catch {
53+
return document.hidden || !windowFocusedRef.current;
54+
}
55+
}
56+
return document.hidden || !windowFocusedRef.current;
57+
};
58+
59+
const notify = async (
60+
event: AgenticEvent,
61+
outcome: { success?: boolean; cancelled?: boolean; finishReason?: string | null },
62+
): Promise<void> => {
63+
const isBackground = await resolveIsBackground();
4264

4365
let enabled = true;
4466
try {
@@ -82,8 +104,9 @@ export const useDialogCompletionNotify = () => {
82104

83105
const notificationCopy = buildDialogCompletionNotificationCopy({
84106
sessionTitle: session?.title,
85-
success: event?.success,
86-
finishReason: event?.finishReason ?? event?.finish_reason,
107+
success: outcome.success,
108+
cancelled: outcome.cancelled,
109+
finishReason: outcome.finishReason ?? undefined,
87110
t,
88111
});
89112

@@ -94,12 +117,29 @@ export const useDialogCompletionNotify = () => {
94117
notificationCopy.title,
95118
notificationCopy.body,
96119
);
97-
});
120+
};
121+
122+
// A completed turn may itself be unsuccessful (success === false). A
123+
// DialogTurnFailed event carries an explicit error string, surfaced here
124+
// as the finish reason so the failure copy reflects what happened. A
125+
// DialogTurnCancelled event is a distinct terminal state shown as
126+
// "cancelled" rather than "stopped".
127+
const unlistenCompleted = agentAPI.onDialogTurnCompleted((event) =>
128+
notify(event, { success: event?.success ?? true, finishReason: event?.finishReason ?? event?.finish_reason })
129+
);
130+
const unlistenFailed = agentAPI.onDialogTurnFailed((event) =>
131+
notify(event, { success: false, finishReason: event?.error })
132+
);
133+
const unlistenCancelled = agentAPI.onDialogTurnCancelled((event) =>
134+
notify(event, { cancelled: true })
135+
);
98136

99137
return () => {
100138
window.removeEventListener('focus', handleFocus);
101139
window.removeEventListener('blur', handleBlur);
102-
unlisten();
140+
unlistenCompleted();
141+
unlistenFailed();
142+
unlistenCancelled();
103143
};
104144
}, [t]);
105145
};

‎src/web-ui/src/locales/en-US/common.json‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1650,6 +1650,9 @@
16501650
"dialogFailedTitle": "BitFun task stopped",
16511651
"dialogFailedWithSession": "\"{{sessionTitle}}\" stopped unexpectedly. Return to BitFun to review the reason.",
16521652
"dialogFailedFallback": "A BitFun session stopped unexpectedly. Return to BitFun to review the reason.",
1653+
"dialogCancelledTitle": "BitFun task cancelled",
1654+
"dialogCancelledWithSession": "\"{{sessionTitle}}\" was cancelled. Return to BitFun to continue.",
1655+
"dialogCancelledFallback": "A BitFun session was cancelled. Return to BitFun to continue.",
16531656
"permissionRequestTitle": "BitFun requires approval",
16541657
"permissionRequestBody": "An action is waiting for your approval.",
16551658
"permissionRequestBatchBody": "{{count}} actions are waiting for your approval."

‎src/web-ui/src/locales/zh-CN/common.json‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1650,6 +1650,9 @@
16501650
"dialogFailedTitle": "BitFun 任务已中断",
16511651
"dialogFailedWithSession": "“{{sessionTitle}}” 已异常停止,可以回到 BitFun 查看原因。",
16521652
"dialogFailedFallback": "有一个 BitFun 会话已异常停止,可以回到应用查看原因。",
1653+
"dialogCancelledTitle": "BitFun 任务已取消",
1654+
"dialogCancelledWithSession": "“{{sessionTitle}}” 已取消,可以回到 BitFun 继续。",
1655+
"dialogCancelledFallback": "有一个 BitFun 会话已取消,可以回到应用继续。",
16531656
"permissionRequestTitle": "BitFun 需要你的审批",
16541657
"permissionRequestBody": "有一项操作正在等待你的审批。",
16551658
"permissionRequestBatchBody": "有 {{count}} 项操作正在等待你的审批。"

‎src/web-ui/src/locales/zh-TW/common.json‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1650,6 +1650,9 @@
16501650
"dialogFailedTitle": "BitFun 任務已中斷",
16511651
"dialogFailedWithSession": "「{{sessionTitle}}」已異常停止,可以回到 BitFun 查看原因。",
16521652
"dialogFailedFallback": "有一個 BitFun 會話已異常停止,可以回到應用查看原因。",
1653+
"dialogCancelledTitle": "BitFun 任務已取消",
1654+
"dialogCancelledWithSession": "「{{sessionTitle}}」已取消,可以回到 BitFun 繼續。",
1655+
"dialogCancelledFallback": "有一個 BitFun 會話已取消,可以回到應用繼續。",
16531656
"permissionRequestTitle": "BitFun 需要你的審批",
16541657
"permissionRequestBody": "有一項操作正在等待你的審批。",
16551658
"permissionRequestBatchBody": "有 {{count}} 項操作正在等待你的審批。"

0 commit comments

Comments
 (0)