makecindy / makecindy/cindy

[Feature] 明确多窗口场景下的会话通知焦点语义,并修复基于单 renderer 焦点判断导致的误发与重复发送风险

Open
#54 0 comments 0 reactions 0 assignees View on GitHub
feature
Dominant language
TypeScript
Stars
2.7k
Forks
395
Avg merge
21h 48m
Merged PRs (30d)
776

Description

## Background

设置页提供“会话完成或需要回复时发送飞书消息”开关,当前说明文案为:

> 通过已绑定的飞书机器人,向你私聊发送一条消息;仅在窗口失焦时发送。

用户反馈:开启该设置后,即使 Cindy 仍有窗口处于前台聚焦状态,也可能收到飞书通知。

在多窗口场景下,“窗口失焦”目前存在两种合理的产品语义:

### 方案 A:应用级失焦

只要任一 Cindy 内容窗口处于前台聚焦状态,就不发送通知。

适用于“用户正在使用 Cindy 时不额外打扰”的产品目标。

### 方案 B:会话级失焦

只有用户正在查看发生状态变化的那个会话时,才不发送通知。

例如用户正在查看会话 B,而后台会话 A 完成或需要回复,此时仍发送通知。

适用于“后台会话状态变化不能被当前正在查看的其他会话遮蔽”的产品目标。

当前设置文案没有明确这里的“窗口”是整个 Cindy 应用,还是目标会话所在窗口,需要先确认产品语义。

## Current Implementation

当前通知链路无法可靠实现上述任一种多窗口语义。

会话状态变化会广播给多个 renderer,每个挂载相关监听的 renderer 都可能处理同一会话事件:

- `apps/desktop/src/main/maker-ipc/register.ts`
- `apps/desktop/src/renderer/hooks/useSessionRunningStatus.ts`

renderer 目前只通过以下代码判断焦点:

```ts
if (typeof document !== 'undefined' && document.hasFocus()) return;
```

位置:

- `apps/desktop/src/renderer/features/cc-agent/CCAgentSidebarUpper.tsx`

这个判断只能说明:

> 当前执行通知回调的 renderer document 是否聚焦。

它不能说明:

- Cindy 是否有其他内容窗口聚焦;
- 当前聚焦窗口正在展示哪个会话;
- 当前 renderer 是否属于发生状态变化的会话;
- 发生状态变化的会话是否正在聚焦窗口中展示;
- 是否已有其他 renderer 为同一事件提交过通知。

main process 收到 `notification:show-session-event` 后,也没有再次检查应用焦点、目标会话可见性或事件幂等,而是按传入的 channel 直接发送通知:

- `apps/desktop/src/main/notificationService.ts`

## Actual Result

多窗口场景下可能出现以下行为:

1. Cindy 的一个窗口处于前台聚焦状态,但另一个失焦 renderer 仍然提交飞书通知。
2. 即使发生状态变化的会话正在聚焦窗口中展示,其他失焦 renderer 也可能为该会话发送通知。
3. 多个失焦 renderer 同时观察到同一状态变化时,存在重复发送通知的风险。
4. renderer 完成焦点判断后、main 实际发送前,用户可能已经切回 Cindy,但通知仍会发送。
5. 设置文案无法让用户判断当前行为属于应用级通知还是会话级通知。

## Steps to Reproduce

### 场景一:多个会话窗口

1. 绑定飞书机器人。
2. 在“设置 → 通知”中开启“会话完成或需要回复时发送飞书消息”。
3. 打开两个 Cindy 会话窗口。
4. 保持其中一个窗口聚焦。
5. 让任一会话完成或进入需要回复状态。
6. 观察是否收到飞书通知,以及是否可能重复收到。

### 场景二:独立右侧栏窗口

1. 开启飞书会话通知。
2. 启动一个会话。
3. 将右侧栏分离为独立窗口。
4. 保持独立右侧栏窗口聚焦,确保 Cindy 仍是前台应用。
5. 等待会话完成或进入需要回复状态。
6. 观察飞书通知。

## Expected Result

需要先确认并固定以下产品语义之一。

### 选项 A:应用级失焦

- 任一 Cindy 内容窗口聚焦时,不发送通知。
- 只有所有 Cindy 内容窗口均失焦时,才发送通知。
- main process 使用 OS 级内容窗口焦点作为最终判断依据。

### 选项 B:会话级失焦

- 目标会话正在聚焦窗口中展示时,不发送通知。
- 用户正在查看其他会话时,目标会话完成或需要回复仍可发送通知。
- 必须可靠识别目标 `sessionId` 是否正在聚焦窗口中展示,不能仅依赖触发回调的 renderer 的 `document.hasFocus()`。

无论选择哪种语义,都应保证:

- 同一个会话状态事件最多发送一次通知;
- renderer 与 main 之间发生焦点切换时,最终行为符合选定语义;
- 设置文案准确描述最终行为。

## Root Cause

1. 会话状态事件会广播到多个 BrowserWindow。
2. 多个 renderer 可能分别观察并处理同一个会话状态变化。
3. 焦点 gate 使用单个 renderer 的 `document.hasFocus()`,没有应用级或会话级焦点上下文。
4. 通知 payload 没有提供足够的信息来判断目标会话是否正在聚焦窗口中展示。
5. main process 在真正发送通知前没有执行最终焦点校验。
6. main process 没有对同一会话状态事件进行幂等控制。
7. 设置文案没有定义多窗口场景下“窗口失焦”的准确含义。

## Suggested Fix

### 第一步:确认产品语义

由产品确认采用:

- [ ] A:应用级失焦
- [ ] B:会话级失焦

### 如果采用应用级失焦

1. 复用现有的 `isFocusedAppContentWindow()` / `hasFocusedAppWindow()`。
2. 由 main process 在实际发送前检查是否存在聚焦的 Cindy 内容窗口。
3. renderer 的 `document.hasFocus()` 可以作为减少 IPC 的快速短路,但不能作为最终正确性判断。
4. 明确 OAuth、语音输入 overlay 等工具窗口是否计入“内容窗口”,并沿用现有 `windowFocusClassifier` 分类。

### 如果采用会话级失焦

1. 建立目标 `sessionId` 与窗口当前展示会话之间的可靠映射。
2. 判断聚焦的 Cindy 内容窗口当前是否正在展示目标会话。
3. 不再使用“哪个 renderer 恰好执行了回调”推断会话窗口归属。
4. 将通知生产收敛到单一位置,避免多个 renderer 分别提交。

### 两种方案都需要

1. 为通知事件提供稳定的幂等标识,例如:

```text
sessionId + event kind + transition/event id
```

2. 防止多个 renderer 对同一事件重复发送。
3. 在 main 实际发送前执行最终校验,处理 renderer → main IPC 期间的焦点切换竞态。
4. 补充单窗口、多窗口、焦点切换和重复事件测试。
5. 同步修改四种语言的设置文案,明确通知语义。

## 实现建议

建议按以下顺序拆分为可独立验证的提交:

1. **固定焦点语义与状态模型**
- 明确采用应用级或会话级失焦,并定义工具窗口、会话副窗口和独立右侧栏的归类。
- 如果采用会话级语义,先建立 `sessionId` 与聚焦窗口当前展示会话的可靠映射。
- 验证:为焦点分类或会话可见性状态补充纯逻辑单元测试。
2. **将最终通知 gate 与幂等控制收敛到 main**
- 在实际发送前按选定语义复核焦点,并为同一会话状态事件去重。
- renderer 的 `document.hasFocus()` 仅保留为快速短路,不承担最终正确性。
- 验证:覆盖单窗口、多窗口、焦点切换竞态及重复 IPC。
3. **同步设置文案和端到端行为**
- 更新 `zh-CN`、`en`、`ja`、`ko` 四种语言文案,明确采用的焦点语义。
- 验证:手工覆盖主窗口、会话副窗口、独立右侧栏及应用完全失焦场景,并确认桌面通知与飞书通知行为一致。

## Acceptance Criteria

### 通用验收项

- [ ] 产品已明确选择应用级或会话级焦点语义。
- [ ] 当前聚焦判断不再仅依赖触发回调 renderer 的 `document.hasFocus()`。
- [ ] 同一会话状态事件最多发送一次飞书通知。
- [ ] 多个 renderer 同时收到同一状态事件时,不会重复发送。
- [ ] renderer 判断后、main 处理前发生焦点变化时,最终行为仍符合选定语义。
- [ ] 会话完成、执行失败、ask-user、permission、plan-review 等状态行为一致。
- [ ] 飞书通知开关关闭时始终不发送。
- [ ] 未绑定飞书机器人 owner 时保持现有安全跳过行为。
- [ ] 桌面系统通知与飞书通知采用一致且明确的焦点语义。
- [ ] 补充 main 层及多窗口场景的自动化回归测试。
- [ ] `zh-CN`、`en`、`ja`、`ko` 四种语言文案与最终行为一致。

### 如果选择应用级失焦

- [ ] 单一主窗口聚焦时不发送。
- [ ] 会话副窗口聚焦时不发送。
- [ ] 独立右侧栏等已注册内容窗口聚焦时不发送。
- [ ] 所有 Cindy 内容窗口均失焦时正常发送一次。

### 如果选择会话级失焦

- [ ] 目标会话正在聚焦窗口中展示时不发送。
- [ ] 另一个会话窗口聚焦、目标会话在后台时正常发送一次。
- [ ] 同一会话在多个窗口中展示时,只要其中一个展示实例聚焦就不发送。
- [ ] 无法识别会话窗口归属时采用明确且经过产品确认的 fallback,不依赖随机处理事件的 renderer。

## Copy Proposal

最终文案根据产品选择调整。

### 如果选择应用级失焦

> 通过已绑定的飞书机器人向你发送私聊消息;仅在 Cindy 不处于前台时发送。

### 如果选择会话级失焦

> 通过已绑定的飞书机器人向你发送私聊消息;当该会话未在当前聚焦窗口中显示时发送。

## Impact

- 会话完成通知
- 会话执行失败通知
- 需要回复通知
- 飞书机器人私聊通知
- 桌面系统通知
- 会话多窗口
- 独立右侧栏窗口
- renderer/main 焦点切换竞态
- 多 renderer 重复通知风险

Contributor guide

Open the contributing guide

Research direction

First resolve whether the product requires application-level or session-level focus, then trace notification flow through apps/desktop/src/main/maker-ipc/register.ts, apps/desktop/src/renderer/hooks/useSessionRunningStatus.ts, apps/desktop/src/renderer/features/cc-agent/CCAgentSidebarUpper.tsx, and apps/desktop/src/main/notificationService.ts. Read the existing focus classifier and notification tests, then add coverage for multi-window focus, IPC races, and duplicate events; done means one consistent notification per event and matching copy in zh-CN, en, ja, and ko.

Written by the indexing model from the issue text.

Assessment

Tech stack
electron, typescript
Domain
desktop, localization, testing
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Quiet
Clarity
Needs clarification
Newbie friendliness
30/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.