From c2393700963930768316cd51064a3608de5cfbd6 Mon Sep 17 00:00:00 2001 From: qer Date: Mon, 6 Jul 2026 21:14:58 +0800 Subject: [PATCH] fix(kimi-web): clarify desktop notification title and icon --- .changeset/web-notification-title-icon.md | 5 ++ .../src/composables/client/useNotification.ts | 73 ++++++++++++++++--- apps/kimi-web/src/i18n/locales/en/settings.ts | 6 +- apps/kimi-web/src/i18n/locales/zh/settings.ts | 6 +- apps/kimi-web/test/notification-logic.test.ts | 54 +++++++++++++- 5 files changed, 127 insertions(+), 17 deletions(-) create mode 100644 .changeset/web-notification-title-icon.md diff --git a/.changeset/web-notification-title-icon.md b/.changeset/web-notification-title-icon.md new file mode 100644 index 0000000000..0b817363b8 --- /dev/null +++ b/.changeset/web-notification-title-icon.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Show the Kimi icon and clearer titles in web desktop notifications. diff --git a/apps/kimi-web/src/composables/client/useNotification.ts b/apps/kimi-web/src/composables/client/useNotification.ts index 29b6b3034d..64a7558bd6 100644 --- a/apps/kimi-web/src/composables/client/useNotification.ts +++ b/apps/kimi-web/src/composables/client/useNotification.ts @@ -26,6 +26,8 @@ const notifyPermission = ref( typeof Notification !== 'undefined' ? Notification.permission : 'denied', ); +const NOTIFICATION_ICON = '/favicon.ico'; + /** Shared setter: disabling is instant; enabling requests OS permission first and stays off if the user blocks it. */ async function setNotifyPref(pref: Ref, key: string, on: boolean): Promise { @@ -63,7 +65,7 @@ export interface NotifyCompletionCtx { /** True when the target session is the active one and the page is visible — in which case we suppress the notification. */ isActiveAndVisible: boolean; - /** Session title used as the notification title. */ + /** Session title used as the completion notification body and a question-body fallback. */ sessionTitle: string; /** Called when the user clicks the notification (e.g. select the session). */ onClick: () => void; @@ -71,14 +73,53 @@ export interface NotifyCompletionCtx { export interface NotifyQuestionCtx extends NotifyCompletionCtx { /** Short preview of the question, used as the notification body. Falls back - to a generic line when empty. */ + to the session title, then to a generic line when empty. */ questionPreview: string; } +export interface NotificationCopy { + readonly title: string; + readonly body: string; +} + +function firstText(...values: Array): string { + for (const value of values) { + const trimmed = value?.trim(); + if (trimmed) return trimmed; + } + return ''; +} + +export function completionNotificationCopy(sessionTitle: string): NotificationCopy { + return { + title: i18n.global.t('settings.notifyTitle'), + body: firstText(sessionTitle, i18n.global.t('settings.notifyFallback')), + }; +} + +export function questionNotificationCopy( + sessionTitle: string, + questionPreview: string, +): NotificationCopy { + return { + title: i18n.global.t('settings.notifyQuestionTitle'), + body: firstText( + questionPreview, + sessionTitle, + i18n.global.t('settings.notifyQuestionFallback'), + ), + }; +} + /** Shared permission gate + fire. `enabled` is the caller's per-kind preference; - `body` and `tag` let each kind carry its own text and a per-kind dedup tag + `copy` and `tag` let each kind carry its own text and a per-kind dedup tag so a completion and a question don't collapse into one notification. */ -function maybeNotify(enabled: boolean, ctx: NotifyCompletionCtx, body: string, tag: string): void { +function maybeNotify( + enabled: boolean, + ctx: NotifyCompletionCtx, + copy: NotificationCopy, + tag: string, +): void { if (!enabled) return; if (typeof Notification === 'undefined') return; const perm = Notification.permission; @@ -87,18 +128,17 @@ function maybeNotify(enabled: boolean, ctx: NotifyCompletionCtx, body: string, t // Request permission asynchronously; if granted, fire the notification. void Notification.requestPermission().then((p) => { notifyPermission.value = p; - if (p === 'granted') fire(ctx, body, tag); + if (p === 'granted') fire(ctx, copy, tag); }); return; } - fire(ctx, body, tag); + fire(ctx, copy, tag); } -function fire(ctx: NotifyCompletionCtx, body: string, tag: string): void { +function fire(ctx: NotifyCompletionCtx, copy: NotificationCopy, tag: string): void { if (ctx.isActiveAndVisible) return; - const title = ctx.sessionTitle.trim() || 'Kimi Code'; try { - const n = new Notification(title, { body, tag }); + const n = new Notification(copy.title, { body: copy.body, tag, icon: NOTIFICATION_ICON }); n.onclick = () => { try { window.focus(); @@ -116,14 +156,23 @@ function fire(ctx: NotifyCompletionCtx, body: string, tag: string): void { /** Fire a completion notification for a finished session, but only when the caller says the user isn't already looking at it. */ function maybeNotifyCompletion(sid: string, ctx: NotifyCompletionCtx): void { - maybeNotify(notifyOnComplete.value, ctx, i18n.global.t('settings.notifyBody'), `kimi-complete-${sid}`); + maybeNotify( + notifyOnComplete.value, + ctx, + completionNotificationCopy(ctx.sessionTitle), + `kimi-complete-${sid}`, + ); } /** Fire a notification when a session asks a question, but only when the user explicitly opted into question notifications and isn't already looking. */ function maybeNotifyQuestion(sid: string, ctx: NotifyQuestionCtx): void { - const body = ctx.questionPreview || i18n.global.t('settings.notifyQuestionBody'); - maybeNotify(notifyOnQuestion.value, ctx, body, `kimi-question-${sid}`); + maybeNotify( + notifyOnQuestion.value, + ctx, + questionNotificationCopy(ctx.sessionTitle, ctx.questionPreview), + `kimi-question-${sid}`, + ); } export function useNotification() { diff --git a/apps/kimi-web/src/i18n/locales/en/settings.ts b/apps/kimi-web/src/i18n/locales/en/settings.ts index a452bc7b11..ec2c8d7707 100644 --- a/apps/kimi-web/src/i18n/locales/en/settings.ts +++ b/apps/kimi-web/src/i18n/locales/en/settings.ts @@ -14,8 +14,10 @@ export default { notifyOnQuestion: 'Notify when a question needs an answer', soundOnComplete: 'Play a sound when a turn completes or needs an answer', notifyDenied: 'Blocked in browser settings', - notifyBody: 'Finished a turn', - notifyQuestionBody: 'A question is waiting for your answer', + notifyTitle: 'Kimi Code · Turn finished', + notifyQuestionTitle: 'Kimi Code · Needs answer', + notifyFallback: 'View result', + notifyQuestionFallback: 'A question is waiting for your answer', account: 'Account', uiFontSize: 'Font size', agentDefaults: 'Agent defaults', diff --git a/apps/kimi-web/src/i18n/locales/zh/settings.ts b/apps/kimi-web/src/i18n/locales/zh/settings.ts index 34aa2f2939..a06a14bdda 100644 --- a/apps/kimi-web/src/i18n/locales/zh/settings.ts +++ b/apps/kimi-web/src/i18n/locales/zh/settings.ts @@ -14,8 +14,10 @@ export default { notifyOnQuestion: '待回答时通知', soundOnComplete: '会话完成或待回答时播放提示音', notifyDenied: '已在浏览器设置中被阻止', - notifyBody: '已完成一轮', - notifyQuestionBody: '有提问等待你回答', + notifyTitle: 'Kimi Code · 回合完成', + notifyQuestionTitle: 'Kimi Code · 待回答', + notifyFallback: '点击查看结果', + notifyQuestionFallback: '有提问等待你回答', account: '账户', uiFontSize: '字体大小', agentDefaults: 'Agent 默认值', diff --git a/apps/kimi-web/test/notification-logic.test.ts b/apps/kimi-web/test/notification-logic.test.ts index 73a8619f6d..f10a31d7d4 100644 --- a/apps/kimi-web/test/notification-logic.test.ts +++ b/apps/kimi-web/test/notification-logic.test.ts @@ -1,6 +1,11 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { i18n } from '../src/i18n'; import { STORAGE_KEYS, safeGetString } from '../src/lib/storage'; -import { useNotification } from '../src/composables/client/useNotification'; +import { + completionNotificationCopy, + questionNotificationCopy, + useNotification, +} from '../src/composables/client/useNotification'; function createMemoryStorage(): Storage { const data = new Map(); @@ -71,3 +76,50 @@ describe('useNotification preferences', () => { expect(safeGetString(STORAGE_KEYS.notifyOnComplete)).toBe('0'); }); }); + +describe('notification copy', () => { + beforeEach(() => { + i18n.global.locale.value = 'en'; + }); + + it('uses an event title and session-title body for completion notifications', () => { + expect(completionNotificationCopy('Refactor auth flow')).toEqual({ + title: 'Kimi Code · Turn finished', + body: 'Refactor auth flow', + }); + }); + + it('falls back to a result hint when there is no session title', () => { + expect(completionNotificationCopy(' ')).toEqual({ + title: 'Kimi Code · Turn finished', + body: 'View result', + }); + }); + + it('prefers the question preview in question notifications', () => { + expect(questionNotificationCopy('Storage migration', 'Which database?')).toEqual({ + title: 'Kimi Code · Needs answer', + body: 'Which database?', + }); + }); + + it('falls back to the session title before the generic question line', () => { + expect(questionNotificationCopy('Storage migration', ' ')).toEqual({ + title: 'Kimi Code · Needs answer', + body: 'Storage migration', + }); + }); + + it('localizes the notification copy', () => { + i18n.global.locale.value = 'zh'; + + expect(completionNotificationCopy('')).toEqual({ + title: 'Kimi Code · 回合完成', + body: '点击查看结果', + }); + expect(questionNotificationCopy('', '')).toEqual({ + title: 'Kimi Code · 待回答', + body: '有提问等待你回答', + }); + }); +});