Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/web-notification-title-icon.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Show the Kimi icon and clearer titles in web desktop notifications.
73 changes: 61 additions & 12 deletions apps/kimi-web/src/composables/client/useNotification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ const notifyPermission = ref<string>(
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<boolean>, key: string, on: boolean): Promise<void> {
Expand Down Expand Up @@ -63,22 +65,61 @@ 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;
}

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 | undefined>): 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;
Expand All @@ -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();
Expand All @@ -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() {
Expand Down
6 changes: 4 additions & 2 deletions apps/kimi-web/src/i18n/locales/en/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
6 changes: 4 additions & 2 deletions apps/kimi-web/src/i18n/locales/zh/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ export default {
notifyOnQuestion: '待回答时通知',
soundOnComplete: '会话完成或待回答时播放提示音',
notifyDenied: '已在浏览器设置中被阻止',
notifyBody: '已完成一轮',
notifyQuestionBody: '有提问等待你回答',
notifyTitle: 'Kimi Code · 回合完成',
notifyQuestionTitle: 'Kimi Code · 待回答',
notifyFallback: '点击查看结果',
notifyQuestionFallback: '有提问等待你回答',
account: '账户',
uiFontSize: '字体大小',
agentDefaults: 'Agent 默认值',
Expand Down
54 changes: 53 additions & 1 deletion apps/kimi-web/test/notification-logic.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>();
Expand Down Expand Up @@ -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: '有提问等待你回答',
});
});
});
Loading