mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
feat(kimi-web): add status-aware browser notifications (#1479)
* feat(kimi-web): add approval notification storage key and i18n copy
* feat(kimi-web): add approval notification helpers and tests
* feat(kimi-web): wire approval notifications and guard completion alerts
* fix(kimi-web): extract shouldNotifyCompletion helper and add tests
* feat(kimi-web): add approval notification settings toggle
* chore(kimi-web): add changeset and tidy notification module comment
- Align approval notification tag with spec (kimi-approval-${approvalId})
- Update module header to describe all three notification kinds
* fix(kimi-web): make notifications fire reliably
- Key completion notification tags by turn (sid + promptId) and question
tags by request id, so a stale notification left in the notification
center no longer swallows every follow-up alert in the same session
- Suppress notifications only while the window is actually focused, not
merely visible (document.hasFocus() on top of visibilityState)
- Play the attention sound when a tool needs approval, matching the
completion and question sounds
* chore(kimi-web): simplify changeset
This commit is contained in:
parent
b89fc1a4fb
commit
735922c291
10 changed files with 320 additions and 48 deletions
5
.changeset/web-approval-notifications.md
Normal file
5
.changeset/web-approval-notifications.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
web: Add notifications when a tool needs approval, and improve notification reliability.
|
||||
|
|
@ -898,6 +898,7 @@ function openPr(url: string): void {
|
|||
:account-model="client.defaultModel.value"
|
||||
:notify="client.notifyOnComplete.value"
|
||||
:notify-question="client.notifyOnQuestion.value"
|
||||
:notify-approval="client.notifyOnApproval.value"
|
||||
:notify-permission="client.notifyPermission.value"
|
||||
:sound="client.soundOnComplete.value"
|
||||
:conversation-toc="client.conversationToc.value"
|
||||
|
|
@ -910,6 +911,7 @@ function openPr(url: string): void {
|
|||
@set-ui-font-size="client.setUiFontSize($event)"
|
||||
@set-notify="client.setNotifyOnComplete($event)"
|
||||
@set-notify-question="client.setNotifyOnQuestion($event)"
|
||||
@set-notify-approval="client.setNotifyOnApproval($event)"
|
||||
@set-sound="client.setSoundOnComplete($event)"
|
||||
@set-conversation-toc="client.setConversationToc($event)"
|
||||
@update-config="handleUpdateConfig($event)"
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ const props = defineProps<{
|
|||
notify: boolean;
|
||||
/** Browser-notification-on-question (needs answer) preference. */
|
||||
notifyQuestion: boolean;
|
||||
/** Browser-notification-on-approval preference. */
|
||||
notifyApproval: boolean;
|
||||
/** OS permission state ('default' | 'granted' | 'denied') for the hint. */
|
||||
notifyPermission?: string;
|
||||
/** Play-a-sound-on-completion preference. */
|
||||
|
|
@ -54,6 +56,7 @@ const emit = defineEmits<{
|
|||
setUiFontSize: [size: number];
|
||||
setNotify: [on: boolean];
|
||||
setNotifyQuestion: [on: boolean];
|
||||
setNotifyApproval: [on: boolean];
|
||||
setSound: [on: boolean];
|
||||
setConversationToc: [on: boolean];
|
||||
login: [];
|
||||
|
|
@ -417,6 +420,18 @@ function archiveTime(iso: string): string {
|
|||
@update:model-value="emit('setNotifyQuestion', $event)"
|
||||
/>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="rlabel">
|
||||
{{ t('settings.notifyOnApproval') }}
|
||||
<span v-if="notifyPermission === 'denied'" class="hint">{{ t('settings.notifyDenied') }}</span>
|
||||
</span>
|
||||
<Switch
|
||||
:model-value="notifyApproval"
|
||||
:disabled="notifyPermission === 'denied'"
|
||||
:label="t('settings.notifyOnApproval')"
|
||||
@update:model-value="emit('setNotifyApproval', $event)"
|
||||
/>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="rlabel">{{ t('settings.soundOnComplete') }}</span>
|
||||
<Switch
|
||||
|
|
|
|||
|
|
@ -1,20 +1,29 @@
|
|||
// apps/kimi-web/src/composables/client/useNotification.ts
|
||||
// Browser notifications for when the agent needs attention: a turn finished or
|
||||
// a question is waiting for an answer. Each kind has its own on/off preference
|
||||
// (persisted) plus the shared OS permission + Notification API. Pure UI action
|
||||
// module — it never reads rawState or calls the API. The rawState-dependent
|
||||
// bits (is the session active & visible, its title, the click-to-select action)
|
||||
// are passed in by the caller via the ctx objects.
|
||||
// Browser notifications for when the agent needs attention: a turn finished, a
|
||||
// question waiting for an answer, or a tool needing approval. Each kind has its
|
||||
// own on/off preference (persisted) plus the shared OS permission + Notification
|
||||
// API. Pure UI action module — it never reads rawState or calls the API. The
|
||||
// rawState-dependent bits (is the user watching the session, its title, the
|
||||
// click-to-select action) are passed in by the caller via the ctx objects.
|
||||
//
|
||||
// Why two preferences: completion notifications default on (existing behavior),
|
||||
// but question notifications surface question text and default OFF, so an
|
||||
// existing user who only opted into completion alerts doesn't start receiving
|
||||
// question content on their desktop without explicitly opting in.
|
||||
// Why three preferences: completion notifications default on (existing
|
||||
// behavior), but question and approval notifications surface request text/tool
|
||||
// names and default OFF, so an existing user who only opted into completion
|
||||
// alerts doesn't start receiving sensitive content on their desktop without
|
||||
// explicitly opting in.
|
||||
|
||||
import { ref, type Ref } from 'vue';
|
||||
import { i18n } from '../../i18n';
|
||||
import { safeGetString, safeSetString, STORAGE_KEYS } from '../../lib/storage';
|
||||
|
||||
export function shouldNotifyCompletion(
|
||||
status: 'idle' | 'aborted',
|
||||
hasPendingApproval: boolean,
|
||||
hasPendingQuestion: boolean,
|
||||
): boolean {
|
||||
return status === 'idle' && !hasPendingApproval && !hasPendingQuestion;
|
||||
}
|
||||
|
||||
function loadNotify(key: string, defaultOn: boolean): boolean {
|
||||
const v = safeGetString(key);
|
||||
return v === null ? defaultOn : v === '1';
|
||||
|
|
@ -22,6 +31,7 @@ function loadNotify(key: string, defaultOn: boolean): boolean {
|
|||
|
||||
const notifyOnComplete = ref(loadNotify(STORAGE_KEYS.notifyOnComplete, true));
|
||||
const notifyOnQuestion = ref(loadNotify(STORAGE_KEYS.notifyOnQuestion, false));
|
||||
const notifyOnApproval = ref(loadNotify(STORAGE_KEYS.notifyOnApproval, false));
|
||||
const notifyPermission = ref<string>(
|
||||
typeof Notification !== 'undefined' ? Notification.permission : 'denied',
|
||||
);
|
||||
|
|
@ -61,20 +71,42 @@ function setNotifyOnQuestion(on: boolean): Promise<void> {
|
|||
return setNotifyPref(notifyOnQuestion, STORAGE_KEYS.notifyOnQuestion, on);
|
||||
}
|
||||
|
||||
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;
|
||||
/** Enable/disable approval notifications. Off by default. */
|
||||
function setNotifyOnApproval(on: boolean): Promise<void> {
|
||||
return setNotifyPref(notifyOnApproval, STORAGE_KEYS.notifyOnApproval, on);
|
||||
}
|
||||
|
||||
export interface NotifyBaseCtx {
|
||||
/** True when the user is actually watching the target session: it is the
|
||||
active session, the page is visible, and the window has focus — in which
|
||||
case we suppress the notification. */
|
||||
isUserWatching: boolean;
|
||||
/** 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 {
|
||||
export interface NotifyCompletionCtx extends NotifyBaseCtx {
|
||||
/** Prompt id of the finished turn; keys the dedup tag so every turn fires its
|
||||
own notification while a replayed idle event for the same turn stays
|
||||
collapsed. Falls back to a per-call unique tag when absent. */
|
||||
promptId?: string;
|
||||
}
|
||||
|
||||
export interface NotifyQuestionCtx extends NotifyBaseCtx {
|
||||
/** Short preview of the question, used as the notification body. Falls back
|
||||
to the session title, then to a generic line when empty. */
|
||||
questionPreview: string;
|
||||
/** Unique question request id; used to deduplicate notifications per request. */
|
||||
questionId: string;
|
||||
}
|
||||
|
||||
export interface NotifyApprovalCtx extends NotifyBaseCtx {
|
||||
/** Tool call name needing approval, used as the notification body. */
|
||||
toolName: string;
|
||||
/** Unique approval request id; used to deduplicate notifications per request. */
|
||||
approvalId: string;
|
||||
}
|
||||
|
||||
export interface NotificationCopy {
|
||||
|
|
@ -111,12 +143,29 @@ export function questionNotificationCopy(
|
|||
};
|
||||
}
|
||||
|
||||
export function approvalNotificationCopy(
|
||||
sessionTitle: string,
|
||||
toolName: string,
|
||||
): NotificationCopy {
|
||||
return {
|
||||
title: i18n.global.t('settings.notifyApprovalTitle'),
|
||||
body: firstText(
|
||||
toolName,
|
||||
sessionTitle,
|
||||
i18n.global.t('settings.notifyApprovalFallback'),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** Shared permission gate + fire. `enabled` is the caller's per-kind preference;
|
||||
`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. */
|
||||
`copy` and `tag` let each kind carry its own text and a per-turn/per-request
|
||||
dedup tag: repeats of the same turn or request collapse into one
|
||||
notification, while distinct ones each fire (same-tag notifications replace
|
||||
silently — renotify is unreliable across platforms — so the tag must change
|
||||
whenever a new alert should pop). */
|
||||
function maybeNotify(
|
||||
enabled: boolean,
|
||||
ctx: NotifyCompletionCtx,
|
||||
ctx: NotifyBaseCtx,
|
||||
copy: NotificationCopy,
|
||||
tag: string,
|
||||
): void {
|
||||
|
|
@ -135,8 +184,8 @@ function maybeNotify(
|
|||
fire(ctx, copy, tag);
|
||||
}
|
||||
|
||||
function fire(ctx: NotifyCompletionCtx, copy: NotificationCopy, tag: string): void {
|
||||
if (ctx.isActiveAndVisible) return;
|
||||
function fire(ctx: NotifyBaseCtx, copy: NotificationCopy, tag: string): void {
|
||||
if (ctx.isUserWatching) return;
|
||||
try {
|
||||
const n = new Notification(copy.title, { body: copy.body, tag, icon: NOTIFICATION_ICON });
|
||||
n.onclick = () => {
|
||||
|
|
@ -154,24 +203,38 @@ function fire(ctx: NotifyCompletionCtx, copy: NotificationCopy, tag: string): vo
|
|||
}
|
||||
|
||||
/** Fire a completion notification for a finished session, but only when the
|
||||
caller says the user isn't already looking at it. */
|
||||
caller says the user isn't already looking at it. The tag carries the turn's
|
||||
prompt id: same-tag notifications replace silently, so without it a stale
|
||||
notification left in the notification center would swallow every later
|
||||
turn's alert for that session. */
|
||||
function maybeNotifyCompletion(sid: string, ctx: NotifyCompletionCtx): void {
|
||||
maybeNotify(
|
||||
notifyOnComplete.value,
|
||||
ctx,
|
||||
completionNotificationCopy(ctx.sessionTitle),
|
||||
`kimi-complete-${sid}`,
|
||||
`kimi-complete-${sid}-${ctx.promptId ?? Date.now()}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
function maybeNotifyQuestion(ctx: NotifyQuestionCtx): void {
|
||||
maybeNotify(
|
||||
notifyOnQuestion.value,
|
||||
ctx,
|
||||
questionNotificationCopy(ctx.sessionTitle, ctx.questionPreview),
|
||||
`kimi-question-${sid}`,
|
||||
`kimi-question-${ctx.questionId}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Fire a notification when a tool needs approval, but only when the user
|
||||
explicitly opted into approval notifications and isn't already looking. */
|
||||
function maybeNotifyApproval(ctx: NotifyApprovalCtx): void {
|
||||
maybeNotify(
|
||||
notifyOnApproval.value,
|
||||
ctx,
|
||||
approvalNotificationCopy(ctx.sessionTitle, ctx.toolName),
|
||||
`kimi-approval-${ctx.approvalId}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -179,10 +242,13 @@ export function useNotification() {
|
|||
return {
|
||||
notifyOnComplete,
|
||||
notifyOnQuestion,
|
||||
notifyOnApproval,
|
||||
notifyPermission,
|
||||
setNotifyOnComplete,
|
||||
setNotifyOnQuestion,
|
||||
setNotifyOnApproval,
|
||||
maybeNotifyCompletion,
|
||||
maybeNotifyQuestion,
|
||||
maybeNotifyApproval,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
// apps/kimi-web/src/composables/client/useSoundNotification.ts
|
||||
// Browser "turn completed" sound: a persisted on/off preference plus a short
|
||||
// chime synthesized with the WebAudio API (no audio asset, no permission
|
||||
// prompt). Pure UI action module — it never reads rawState or calls the API.
|
||||
// Browser attention sound: a persisted on/off preference plus a short chime
|
||||
// synthesized with the WebAudio API (no audio asset, no permission prompt).
|
||||
// One chime covers every "the agent needs you" moment — a finished turn, a
|
||||
// question waiting for an answer, a tool needing approval. Pure UI action
|
||||
// module — it never reads rawState or calls the API.
|
||||
//
|
||||
// Why the eager "unlock": the sound is most useful when the tab is in the
|
||||
// background (so you hear it while doing something else). But an AudioContext
|
||||
|
|
@ -161,11 +163,19 @@ function maybePlayQuestionSound(): void {
|
|||
playChime();
|
||||
}
|
||||
|
||||
/** Play the attention sound when a tool needs approval, whenever the
|
||||
preference is on. Same chime as completion: it means "the agent needs you". */
|
||||
function maybePlayApprovalSound(): void {
|
||||
if (!soundOnComplete.value) return;
|
||||
playChime();
|
||||
}
|
||||
|
||||
export function useSoundNotification() {
|
||||
return {
|
||||
soundOnComplete,
|
||||
setSoundOnComplete,
|
||||
maybePlayCompletionSound,
|
||||
maybePlayQuestionSound,
|
||||
maybePlayApprovalSound,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ import {
|
|||
} from '../lib/storage';
|
||||
import { createEventBatcher, isRenderEvent } from './client/eventBatcher';
|
||||
import { useAppearance } from './client/useAppearance';
|
||||
import { useNotification } from './client/useNotification';
|
||||
import { useNotification, shouldNotifyCompletion } from './client/useNotification';
|
||||
import { useSoundNotification } from './client/useSoundNotification';
|
||||
import { useTaskPoller } from './client/useTaskPoller';
|
||||
import { useModelProviderState } from './client/useModelProviderState';
|
||||
|
|
@ -860,6 +860,11 @@ function processEvent(appEvent: AppEvent, meta: { sessionId: string; seq: number
|
|||
if (appEvent.type === 'questionRequested') {
|
||||
onQuestionRequested(appEvent.sessionId, appEvent.question);
|
||||
}
|
||||
|
||||
// The agent needs approval for a tool call — surface it so the user comes back.
|
||||
if (appEvent.type === 'approvalRequested') {
|
||||
onApprovalRequested(appEvent.sessionId, appEvent.approval);
|
||||
}
|
||||
}
|
||||
|
||||
const enqueueEvent = createEventBatcher<PendingEvent>(
|
||||
|
|
@ -2315,10 +2320,27 @@ const workspaceState = useWorkspaceState(rawState, {
|
|||
fileDiffLoading,
|
||||
});
|
||||
|
||||
/** True when the user is actually watching this session: it is the active
|
||||
session, the page is visible, and the window has focus. Focus matters on
|
||||
top of visibility: a window that lost focus to another app often stays
|
||||
(partially) visible on screen, but the user is working elsewhere and would
|
||||
miss the moment without a notification. */
|
||||
function isUserWatching(sid: string): boolean {
|
||||
return (
|
||||
sid === rawState.activeSessionId &&
|
||||
typeof document !== 'undefined' &&
|
||||
document.visibilityState === 'visible' &&
|
||||
document.hasFocus()
|
||||
);
|
||||
}
|
||||
|
||||
function onSessionIdle(sid: string, status: 'idle' | 'aborted'): void {
|
||||
// The turn finished — this session no longer has a prompt in flight.
|
||||
inFlightPromptSessions.delete(sid);
|
||||
rawState.sendingBySession = { ...rawState.sendingBySession, [sid]: false };
|
||||
// Capture before the cleanup below drops it — it keys the completion
|
||||
// notification's dedup tag so each finished turn alerts once.
|
||||
const finishedPromptId = rawState.promptIdBySession[sid];
|
||||
// Drop any cached prompt_id so a later skill activation (which has no
|
||||
// prompt_id) doesn't accidentally reuse this stale id for :abort.
|
||||
if (rawState.promptIdBySession[sid] !== undefined) {
|
||||
|
|
@ -2343,16 +2365,20 @@ function onSessionIdle(sid: string, status: 'idle' | 'aborted'): void {
|
|||
}
|
||||
|
||||
// Browser notification when the user isn't watching this session.
|
||||
// Only real completions notify; aborted turns and turns that ended up
|
||||
// blocked on approval/question do not fire the generic "Turn finished" alert.
|
||||
const hasPendingApproval = (rawState.approvalsBySession[sid] ?? []).length > 0;
|
||||
const hasPendingQuestion = (rawState.questionsBySession[sid] ?? []).length > 0;
|
||||
if (shouldNotifyCompletion(status, hasPendingApproval, hasPendingQuestion)) {
|
||||
notification.maybeNotifyCompletion(sid, {
|
||||
isActiveAndVisible:
|
||||
sid === rawState.activeSessionId &&
|
||||
typeof document !== 'undefined' &&
|
||||
document.visibilityState === 'visible',
|
||||
isUserWatching: isUserWatching(sid),
|
||||
sessionTitle: rawState.sessions.find((s) => s.id === sid)?.title ?? '',
|
||||
promptId: finishedPromptId,
|
||||
onClick: () => {
|
||||
void workspaceState.selectSession(sid);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Completion sound — only for real completions (aborted/cancelled turns stay
|
||||
// silent). Plays regardless of visibility so it also reaches a backgrounded tab.
|
||||
|
|
@ -2391,13 +2417,11 @@ function onQuestionRequested(sid: string, question: AppQuestionRequest): void {
|
|||
header && questionText ? `${header}: ${questionText}` : questionText || header;
|
||||
|
||||
// Browser notification when the user isn't watching this session.
|
||||
notification.maybeNotifyQuestion(sid, {
|
||||
isActiveAndVisible:
|
||||
sid === rawState.activeSessionId &&
|
||||
typeof document !== 'undefined' &&
|
||||
document.visibilityState === 'visible',
|
||||
notification.maybeNotifyQuestion({
|
||||
isUserWatching: isUserWatching(sid),
|
||||
sessionTitle: rawState.sessions.find((s) => s.id === sid)?.title ?? '',
|
||||
questionPreview: preview,
|
||||
questionId: question.questionId,
|
||||
onClick: () => {
|
||||
void workspaceState.selectSession(sid);
|
||||
},
|
||||
|
|
@ -2408,6 +2432,23 @@ function onQuestionRequested(sid: string, question: AppQuestionRequest): void {
|
|||
sound.maybePlayQuestionSound();
|
||||
}
|
||||
|
||||
function onApprovalRequested(sid: string, approval: AppApprovalRequest): void {
|
||||
// Browser notification when the user isn't watching this session.
|
||||
notification.maybeNotifyApproval({
|
||||
isUserWatching: isUserWatching(sid),
|
||||
sessionTitle: rawState.sessions.find((s) => s.id === sid)?.title ?? '',
|
||||
toolName: approval.toolName,
|
||||
approvalId: approval.approvalId,
|
||||
onClick: () => {
|
||||
void workspaceState.selectSession(sid);
|
||||
},
|
||||
});
|
||||
|
||||
// Attention sound — plays regardless of visibility so it also reaches a
|
||||
// backgrounded tab (same as the completion sound).
|
||||
sound.maybePlayApprovalSound();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Composable return
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -2501,9 +2542,11 @@ export function useKimiWebClient() {
|
|||
setAccent: appearance.setAccent,
|
||||
notifyOnComplete: notification.notifyOnComplete,
|
||||
notifyOnQuestion: notification.notifyOnQuestion,
|
||||
notifyOnApproval: notification.notifyOnApproval,
|
||||
notifyPermission: notification.notifyPermission,
|
||||
setNotifyOnComplete: notification.setNotifyOnComplete,
|
||||
setNotifyOnQuestion: notification.setNotifyOnQuestion,
|
||||
setNotifyOnApproval: notification.setNotifyOnApproval,
|
||||
soundOnComplete: sound.soundOnComplete,
|
||||
setSoundOnComplete: sound.setSoundOnComplete,
|
||||
onboarded,
|
||||
|
|
|
|||
|
|
@ -12,12 +12,15 @@ export default {
|
|||
notifications: 'Notifications',
|
||||
notifyOnComplete: 'Notify when a turn completes',
|
||||
notifyOnQuestion: 'Notify when a question needs an answer',
|
||||
soundOnComplete: 'Play a sound when a turn completes or needs an answer',
|
||||
notifyOnApproval: 'Notify when a tool needs approval',
|
||||
soundOnComplete: 'Play a sound when a turn completes, needs an answer, or needs approval',
|
||||
notifyDenied: 'Blocked in browser settings',
|
||||
notifyTitle: 'Kimi Code · Turn finished',
|
||||
notifyQuestionTitle: 'Kimi Code · Needs answer',
|
||||
notifyApprovalTitle: 'Kimi Code · Approval required',
|
||||
notifyFallback: 'View result',
|
||||
notifyQuestionFallback: 'A question is waiting for your answer',
|
||||
notifyApprovalFallback: 'A tool needs your approval',
|
||||
account: 'Account',
|
||||
uiFontSize: 'Font size',
|
||||
agentDefaults: 'Agent defaults',
|
||||
|
|
|
|||
|
|
@ -12,12 +12,15 @@ export default {
|
|||
notifications: '通知',
|
||||
notifyOnComplete: '会话完成时通知',
|
||||
notifyOnQuestion: '待回答时通知',
|
||||
soundOnComplete: '会话完成或待回答时播放提示音',
|
||||
notifyOnApproval: '待审批时通知',
|
||||
soundOnComplete: '会话完成、待回答或待审批时播放提示音',
|
||||
notifyDenied: '已在浏览器设置中被阻止',
|
||||
notifyTitle: 'Kimi Code · 回合完成',
|
||||
notifyQuestionTitle: 'Kimi Code · 待回答',
|
||||
notifyApprovalTitle: 'Kimi Code · 等待审批',
|
||||
notifyFallback: '点击查看结果',
|
||||
notifyQuestionFallback: '有提问等待你回答',
|
||||
notifyApprovalFallback: '有工具等待你审批',
|
||||
account: '账户',
|
||||
uiFontSize: '字体大小',
|
||||
agentDefaults: 'Agent 默认值',
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ export const STORAGE_KEYS = {
|
|||
conversationToc: 'kimi-web.beta-toc',
|
||||
notifyOnComplete: 'kimi-web.notify-on-complete',
|
||||
notifyOnQuestion: 'kimi-web.notify-on-question',
|
||||
notifyOnApproval: 'kimi-web.notify-on-approval',
|
||||
soundOnComplete: 'kimi-web.sound-on-complete',
|
||||
inputHistory: 'kimi-web.input-history',
|
||||
// cross-file
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|||
import { i18n } from '../src/i18n';
|
||||
import { STORAGE_KEYS, safeGetString } from '../src/lib/storage';
|
||||
import {
|
||||
approvalNotificationCopy,
|
||||
completionNotificationCopy,
|
||||
questionNotificationCopy,
|
||||
shouldNotifyCompletion,
|
||||
useNotification,
|
||||
} from '../src/composables/client/useNotification';
|
||||
|
||||
|
|
@ -41,11 +43,17 @@ function installStorage(storage: Storage): void {
|
|||
// Singleton — module-level refs + setters. The OS Notification API is absent in
|
||||
// the test env, so the *enable* path is a no-op; the disable path and the
|
||||
// load-from-storage defaults are what we exercise here.
|
||||
const { notifyOnComplete, notifyOnQuestion, setNotifyOnComplete, setNotifyOnQuestion } = useNotification();
|
||||
// Captured at import (before beforeEach touches the refs), so these reflect the
|
||||
// load-from-storage defaults when nothing has been stored yet.
|
||||
const {
|
||||
notifyOnComplete,
|
||||
notifyOnQuestion,
|
||||
notifyOnApproval,
|
||||
setNotifyOnComplete,
|
||||
setNotifyOnQuestion,
|
||||
setNotifyOnApproval,
|
||||
} = useNotification();
|
||||
const importedCompleteDefault = notifyOnComplete.value;
|
||||
const importedQuestionDefault = notifyOnQuestion.value;
|
||||
const importedApprovalDefault = notifyOnApproval.value;
|
||||
|
||||
describe('useNotification preferences', () => {
|
||||
beforeEach(() => {
|
||||
|
|
@ -64,6 +72,10 @@ describe('useNotification preferences', () => {
|
|||
expect(importedQuestionDefault).toBe(false);
|
||||
});
|
||||
|
||||
it('approval notifications default to off', () => {
|
||||
expect(importedApprovalDefault).toBe(false);
|
||||
});
|
||||
|
||||
it('disabling question notifications persists "0" and updates the ref', () => {
|
||||
void setNotifyOnQuestion(false);
|
||||
expect(notifyOnQuestion.value).toBe(false);
|
||||
|
|
@ -75,6 +87,12 @@ describe('useNotification preferences', () => {
|
|||
expect(notifyOnComplete.value).toBe(false);
|
||||
expect(safeGetString(STORAGE_KEYS.notifyOnComplete)).toBe('0');
|
||||
});
|
||||
|
||||
it('disabling approval notifications persists "0" and updates the ref', () => {
|
||||
void setNotifyOnApproval(false);
|
||||
expect(notifyOnApproval.value).toBe(false);
|
||||
expect(safeGetString(STORAGE_KEYS.notifyOnApproval)).toBe('0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('notification copy', () => {
|
||||
|
|
@ -110,6 +128,32 @@ describe('notification copy', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('uses tool name in approval notifications', () => {
|
||||
expect(approvalNotificationCopy('Refactor auth flow', 'bash')).toEqual({
|
||||
title: 'Kimi Code · Approval required',
|
||||
body: 'bash',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to session title and then generic approval line', () => {
|
||||
expect(approvalNotificationCopy('Refactor auth flow', ' ')).toEqual({
|
||||
title: 'Kimi Code · Approval required',
|
||||
body: 'Refactor auth flow',
|
||||
});
|
||||
expect(approvalNotificationCopy(' ', ' ')).toEqual({
|
||||
title: 'Kimi Code · Approval required',
|
||||
body: 'A tool needs your approval',
|
||||
});
|
||||
});
|
||||
|
||||
it('localizes approval notification copy', () => {
|
||||
i18n.global.locale.value = 'zh';
|
||||
expect(approvalNotificationCopy('', '')).toEqual({
|
||||
title: 'Kimi Code · 等待审批',
|
||||
body: '有工具等待你审批',
|
||||
});
|
||||
});
|
||||
|
||||
it('localizes the notification copy', () => {
|
||||
i18n.global.locale.value = 'zh';
|
||||
|
||||
|
|
@ -123,3 +167,83 @@ describe('notification copy', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldNotifyCompletion', () => {
|
||||
it('returns true only for idle + no pending approval + no pending question', () => {
|
||||
expect(shouldNotifyCompletion('idle', false, false)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for aborted', () => {
|
||||
expect(shouldNotifyCompletion('aborted', false, false)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when pending approval exists', () => {
|
||||
expect(shouldNotifyCompletion('idle', true, false)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when pending question exists', () => {
|
||||
expect(shouldNotifyCompletion('idle', false, true)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// Same-tag notifications replace silently (renotify is unreliable), so the tag
|
||||
// must be unique per turn/request for follow-up alerts in a session to pop.
|
||||
describe('notification tags', () => {
|
||||
class FakeNotification {
|
||||
static permission = 'granted';
|
||||
static fired: Array<{ title: string; tag?: string }> = [];
|
||||
onclick: (() => void) | null = null;
|
||||
constructor(title: string, options?: { body?: string; tag?: string; icon?: string }) {
|
||||
FakeNotification.fired.push({ title, tag: options?.tag });
|
||||
}
|
||||
close(): void {}
|
||||
}
|
||||
|
||||
const { maybeNotifyCompletion, maybeNotifyQuestion, maybeNotifyApproval } = useNotification();
|
||||
const base = { isUserWatching: false, sessionTitle: 'T', onClick: () => {} };
|
||||
|
||||
beforeEach(() => {
|
||||
FakeNotification.fired = [];
|
||||
(globalThis as Record<string, unknown>).Notification = FakeNotification;
|
||||
notifyOnComplete.value = true;
|
||||
notifyOnQuestion.value = true;
|
||||
notifyOnApproval.value = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete (globalThis as Record<string, unknown>).Notification;
|
||||
notifyOnComplete.value = true;
|
||||
notifyOnQuestion.value = false;
|
||||
notifyOnApproval.value = false;
|
||||
});
|
||||
|
||||
it('completion tags carry the prompt id so each turn in a session alerts', () => {
|
||||
maybeNotifyCompletion('s1', { ...base, promptId: 'p1' });
|
||||
maybeNotifyCompletion('s1', { ...base, promptId: 'p2' });
|
||||
expect(FakeNotification.fired.map((f) => f.tag)).toEqual([
|
||||
'kimi-complete-s1-p1',
|
||||
'kimi-complete-s1-p2',
|
||||
]);
|
||||
});
|
||||
|
||||
it('a replayed idle event for the same turn keeps the same tag', () => {
|
||||
maybeNotifyCompletion('s1', { ...base, promptId: 'p1' });
|
||||
maybeNotifyCompletion('s1', { ...base, promptId: 'p1' });
|
||||
expect(FakeNotification.fired).toHaveLength(2);
|
||||
expect(FakeNotification.fired[0]?.tag).toBe(FakeNotification.fired[1]?.tag);
|
||||
});
|
||||
|
||||
it('question and approval tags are per-request', () => {
|
||||
maybeNotifyQuestion({ ...base, questionPreview: 'q', questionId: 'q1' });
|
||||
maybeNotifyApproval({ ...base, toolName: 'bash', approvalId: 'a1' });
|
||||
expect(FakeNotification.fired.map((f) => f.tag)).toEqual([
|
||||
'kimi-question-q1',
|
||||
'kimi-approval-a1',
|
||||
]);
|
||||
});
|
||||
|
||||
it('suppresses the notification while the user is watching the session', () => {
|
||||
maybeNotifyCompletion('s1', { ...base, isUserWatching: true, promptId: 'p1' });
|
||||
expect(FakeNotification.fired).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue