Files
iofficeai--aionui/docs/prds/teams/team-runtime-experience.design.md
2026-07-13 13:20:22 +08:00

319 lines
19 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 团队运行体验优化 · 技术设计
配套 PRD`team-runtime-experience.md`。本文面向实现,给出现状调研结论、架构决策、模块设计、关键代码骨架与分阶段落地。全部为渲染层改动,不改 aioncore 与团队数据结构;持久化走 `localStorage`
代码位置基准:`packages/desktop/src/renderer/`(下称 `@renderer`)。
---
## 0. 现状调研结论(决定架构的关键事实)
| # | 事实 | 出处 | 对方案的影响 |
| --- | --------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | -------------------------------------------------------------------- | ------------------------------------ | --------------------------------------------- |
| F1 | `TeamTabsProvider` 已是每团队一实例,已有按 `team_id` 分键的 localStorage`team-active-slot-*``team-assistant-order-*` | `hooks/TeamTabsContext.tsx:72,30-49` | 身份色映射、视图模式复用同一层与同款 key |
| F2 | 运行时事件 `ITeamAgentRuntimeStatusEvent``slot_id` + `status: 'pending' | 'ready' | 'failed'` | `common/types/team/teamTypes.ts:161` | **可单独判定 Leader ready** → warmup 闸门成立 |
| F3 | `membershipMutationBusy` 是全员聚合忙碌,不区分 Leader | `hooks/teamMembershipMutationBusy.ts:51` | warmup 需在 session 层额外派生 leader-ready |
| F4 | teammate 消息只带 `senderConversationId`(无 `slot_id`) | `chatLib.ts:117``MessageText.tsx:167` | **身份色按 conversation_id 取色**;需要 `conversationId→颜色` 的解析 |
| F5 | 发送框预填走 `getSendBoxDraftHook(type)(conversation_id).mutate(d=>({...d,content}))`SWR 同 key 自动同步到 SendBox | `hooks/chat/useSendBoxDraft.ts:93``TeamChatEmptyState.tsx:76-89` | 「告诉 Leader」直接复用,无需新机制 |
| F6 | 并行/全屏已有 `fullscreenSlotId`TeamPageContent 本地 state | `TeamPage.tsx:247,462-559` | 升级为显式、持久化的 `viewMode`(不新造全屏逻辑) |
| F7 | 空状态 Leader 分支有 subtitle + 3 建议卡;建议卡 onClick=`fillDraft(label)` | `TeamChatEmptyState.tsx:157-178` | Leader 问候语替换 subtitle,纯文案 |
| F8 | 选中/列高亮当前散落在 TeamPageleader 硬编码 primary 色)与 TeamTabsactive class | `TeamPage.tsx:140-154``TeamTabs.tsx:107-108` | 身份色系统统一收口,替换这些硬编码 |
| F9 | 成员实例键为 `slot_id``conversation_id``slot_id``assistants[]` 上一一对应 | `TeamPage.tsx:294`、teamTypes | 建 `conversationId→slot_id` 索引即可打通消息与成员 |
**架构含义**:身份色的「真源」是 `slot_id`(成员实例),但消息侧只认 `conversation_id`。因此身份色系统对外暴露两个查询:`colorOf(slot_id)``colorOfConversation(conversation_id)`,内部用 `assistants[]` 维护 `conversation_id→slot_id` 索引打通。
---
## 1. 模块总览
```
TeamTabsProvider (已存在,每团队一实例)
├── useTeamMemberColors(team_id, assistants) ← 新增:身份色真源 + 持久化
├── useTeamViewMode(team_id) ← 新增:并行/单聊,持久化
├── useTeamWarmup(team_id, leaderSlotId, statusMap) ← 新增:warmup 闸门/进度/超时
└── context 追加导出: colorOf / colorOfConversation / viewMode / setViewMode / warmup
```
新增纯函数 / 组件:
- `team/identity/teamMemberColors.ts` — 色板 + 分配算法(纯函数,可单测)
- `team/identity/TeamIdentityDot / useMemberColorVars` — 把颜色落成 CSS 变量的小工具
- `team/components/TeamMemberCapsuleBar.tsx` — 胶囊成员栏(替换现 TeamTabs 呈现)
- `team/components/TeamWarmupOverlay.tsx` — warmup 遮罩
- `team/components/TeamViewToggle.tsx` — 标题行视图切换
- 改造:`MessageText.tsx`(气泡色条+彩名)、`TeamChatEmptyState.tsx`(问候语 + 告诉 Leader)、`AssistantChatSlot`/`TeamPage`(列高亮用身份色)
设计原则:**身份色系统是唯一色源**,所有用色处(胶囊 / 气泡 / 列 / 遮罩)都从它取,杜绝各处硬编码 primary(消除 F8 的分散)。
---
## 2. 身份色系统(PRD §1
### 2.1 色板(`teamMemberColors.ts`
```ts
// 低饱和 slate 邻近色,取自 AionUi 品牌基调。每色给 accent(主) + soft(浅底) 两档,
// 浅底用 color-mix 在运行时算,故此处只存 accent(CSS 变量或 hex)。
export const TEAM_MEMBER_PALETTE = [
'var(--brand)', // 0 = Leader 固定
'#5c9ea4',
'#b58a5e',
'#9481bf',
'#c07d97',
'#6ba07e',
'#4f8ac9',
'#c99a4b',
] as const;
export const LEADER_COLOR_INDEX = 0;
```
深色模式:这些 hex 在深色下仍是中性可辨的低饱和色;如需微调,后续在 `default-color-scheme.css` 暗色块加对应 `--team-mX` 覆盖,`teamMemberColors` 改为引用 `var(--team-mX)`。首版直接用 hex,避免铺开主题工作量。
### 2.2 分配算法(钉死 + 释放复用,PRD §1)
真源是一张 `Record<slot_id, colorIndex>`,随成员列表增量维护:
```ts
// 纯函数:给定上次映射 + 当前成员列表 → 新映射
export function assignMemberColors(
prev: Record<string, number>,
assistants: { slot_id: string; role: string }[]
): Record<string, number> {
const next: Record<string, number> = {};
const used = new Set<number>();
// 1) Leader 固定 0
const leader = assistants.find((a) => a.role === 'leader');
if (leader) {
next[leader.slot_id] = LEADER_COLOR_INDEX;
used.add(LEADER_COLOR_INDEX);
}
// 2) 已有映射的沿用(钉死)
for (const a of assistants) {
if (a.slot_id in next) continue;
const c = prev[a.slot_id];
if (c !== undefined) {
next[a.slot_id] = c;
used.add(c);
}
}
// 3) 新成员取「未占用的最小非0色号」,优先复用被释放的空档;超出则对长度取模循环
let cursor = 1;
for (const a of assistants) {
if (a.slot_id in next) continue;
while (used.has(cursor % TEAM_MEMBER_PALETTE.length) && used.size < TEAM_MEMBER_PALETTE.length - 1) cursor++;
const idx = cursor % TEAM_MEMBER_PALETTE.length || 1; // 保底非00 属 Leader
next[a.slot_id] = idx;
used.add(idx);
cursor++;
}
return next;
}
```
> 注:移除成员时该 slot 不在 `assistants[]` 里 → 自然从 `next` 消失 = 释放;其余 slot 因走「沿用」分支而不变色。满足 PRD「别人增删不改变已有颜色」。循环仅在成员数超过色板时发生(罕见),循环项位置相隔远、不易混淆。
### 2.3 持久化与 Hook`useTeamMemberColors`
```ts
// key: team-member-colors-${team_id},值 = Record<slot_id, colorIndex>
function useTeamMemberColors(team_id: string, assistants: TeamAssistant[]) {
const key = `team-member-colors-${team_id}`;
const [map, setMap] = useState<Record<string, number>>(() => readJSON(key, {}));
useEffect(() => {
setMap((prev) => {
const next = assignMemberColors(prev, assistants);
if (!shallowEqual(prev, next)) writeJSON(key, next);
return next;
});
}, [assistants, key]);
const convIndex = useMemo(
() => Object.fromEntries(assistants.filter((a) => a.conversation_id).map((a) => [a.conversation_id, a.slot_id])),
[assistants]
);
const colorOf = (slot_id?: string) => TEAM_MEMBER_PALETTE[map[slot_id ?? ''] ?? LEADER_COLOR_INDEX];
const colorOfConversation = (cid?: string) => colorOf(convIndex[cid ?? '']);
return { colorOf, colorOfConversation };
}
```
挂载点:`TeamTabsProvider`(已持有 `team_id` + `assistants`)。在 context value 追加 `colorOf` / `colorOfConversation`,供 TeamTabs、TeamPage 列、以及(通过一个轻量 context 或参数)MessageText 使用。
### 2.4 消息侧取色(F4 的解法)
`MessageText` 目前不在 TeamTabsProvider 子树内的保证性不足(它在会话渲染链里)。两个方案:
- **方案 a(推荐)**:team 会话渲染链上已知 `team_id``assistants`,在 `AssistantChatSlot → TeamChatView` 传入一个 `resolveSenderColor(senderConversationId)` 回调,透传到 MessageList/MessageText。改动局部、不引入全局 context。
- 方案 b:新建一个 `TeamIdentityContext` 提供 `colorOfConversation`MessageText 里 `useContext`(可选、非 team 场景返回 undefined→不显示色条)。
首版走 a:沿现有 props 链把 `resolveSenderColor` 传到 MessageText;非团队消息该回调不存在 → 行为不变。
### 2.5 用色落地(CSS 变量注入)
统一做法:给需要着色的容器设 `style={{ '--mc': colorOf(slot_id) }}`CSS 里用 `var(--mc)` + `color-mix` 出浅底:
- 胶囊底:`background: color-mix(in srgb, var(--mc) 9%, var(--bg-base))`;选中 16% + `box-shadow:0 0 0 1.5px var(--mc)`
- 列选中:`box-shadow: inset 0 0 0 2px var(--mc)`;列头 `color-mix(... 8% ...)`
- 气泡:发送者名 `color:var(--mc)`;气泡 `border-left:3px solid var(--mc)`
---
## 3. 视图切换(PRD §4
`useTeamViewMode(team_id)``viewMode: 'parallel' | 'single'`key `team-view-mode-${team_id}`,默认 `'parallel'`。挂 `TeamTabsProvider`context 暴露 `viewMode/setViewMode`
TeamPage 渲染改造:把现有 `fullscreenSlotId ? 全屏 : 并行` 的判断,替换为 `viewMode === 'single' ? 单列(activeSlotId) : 并行`
- 单聊显示 `activeSlotId` 对应成员(复用现全屏那段 JSX,slot 来源从 `fullscreenSlotId` 换成 `activeSlotId`)。
- `fullscreenSlotId` 本地 state 移除;原「点全屏图标」改为「切到单聊 + switchTab 到该 slot」。
- 选中成员被移除时回退 Leader:已有逻辑 `TeamTabsContext.tsx:104-110` 覆盖。
- 视图切换控件 `TeamViewToggle` 放 ChatLayout 标题行右侧(`headerLeading` 对侧,需确认 ChatLayout 是否提供 title 右侧 slot;若无,作为 tabsSlot 区域右对齐元素)。
warmup 期间允许切视图:TeamViewToggle 不受 warmup 禁用影响。
---
## 4. WarmupPRD §7
### 4.1 派生 leader-readyF2/F3
`useTeamWarmup`(可并入 useTeamSession 或独立 hook,挂 provider):
```ts
function useTeamWarmup(team_id, leaderSlotId, statusMap) {
const [leaderReady, setLeaderReady] = useState(
() => statusMap.get(leaderSlotId)?.status && statusMap.get(leaderSlotId)!.status !== 'pending'
);
const [timedOut, setTimedOut] = useState(false);
const [leaderFailed, setLeaderFailed] = useState(false);
useEffect(() => {
const unsub = ipcBridge.team.agentRuntimeStatusChanged.on((e: ITeamAgentRuntimeStatusEvent) => {
if (e.team_id !== team_id || e.slot_id !== leaderSlotId) return;
if (e.status === 'ready') setLeaderReady(true);
if (e.status === 'failed') setLeaderFailed(true);
});
const timer = setTimeout(() => setTimedOut(true), WARMUP_TIMEOUT_MS); // e.g. 20_000
return () => {
unsub();
clearTimeout(timer);
};
}, [team_id, leaderSlotId]);
const phase = leaderReady ? 'ready' : leaderFailed ? 'error' : timedOut ? 'timeout' : 'warming';
return { phase }; // 'warming' | 'ready' | 'error' | 'timeout'
}
```
> 需实现时校验:进入团队时 Leader 若已 ready`statusMap` 初值是否已是非 pending`useTeamSession.ts:41` 用 `team.assistants[].status` 初始化)—— 若后端在进入时不重发 ready 事件,靠初值判定;若重发,靠事件。两条都覆盖,故 `leaderReady` 初值也读 statusMap。
### 4.2 遮罩组件 `TeamWarmupOverlay`
- `phase==='warming'`:磨砂遮罩(`backdrop-filter:blur(3px)` + `bg color-mix(--bg-1 78%)`)+ 成员头像从左到右逐个点亮(依据各 slot 的 statusMap ready 数)+「唤醒中 N/M」+ 品牌色进度条。
- `phase==='error'|'timeout'`:错误态卡片(文案 + 重试/返回)。重试 = 重新触发进入团队的初始化(复用现有进入路径 / `useActiveLease` 重建;具体动作实现时定,不新增后端接口)。
- `phase==='ready'`:不渲染(撤除)。
- 渲染位置:TeamPageContent 内容区之上(`.warmwrap` 定位父级,覆盖 chat 区,不盖标题行——标题行的视图切换仍可用)。
### 4.3 禁用清单接线
warmup`phase==='warming'`)期间:
- 加/删/改成员:已有 `membershipMutationBusy` 门控(`TeamTabs``TeamPage.handleRemoveAssistant:277`);warmup 与之高度重合,保持即可。加号(TeamAddMemberPopover disabled)同理。
- 发消息:遮罩覆盖 chat 区(含 SendBox)→ 天然禁用,无需额外逻辑。
- 允许:切视图、切成员、滚动(这些控件在标题行/成员栏,不被遮罩覆盖)。
---
## 5. 成员栏胶囊化(PRD §2/§3)
`TeamMemberCapsuleBar`(替换 `TeamTabs` 的呈现,保留其 context 消费与 data-testid 契约):
- 每成员 = 胶囊:`rav`(头像,无描边) + 名称 + `AgentStatusBadge` + 身份色浅底;hover 出重命名/移除小图标(复用现 `onRename`/`onRemove`/`membershipMutationBusy` 门控)。
- 选中态:底色加深 + `box-shadow:0 0 0 1.5px var(--mc)`
- 溢出:横向滚动 + 两侧渐隐(复用现 `TAB_OVERFLOW_THRESHOLD` 逻辑)。
- 加号:`TeamAddMemberPopover` 固定最右、虚线胶囊样式,不随列表滚(wrap 成 `flex` 两段:左滚动列表 + 右固定加号,参照已实现的移动端加成员模式)。
**测试契约**:保留现有 `data-testid``team-tab-${slot_id}``team-tab-name-${slot_id}``team-tab-add-member``team-tab-remove-${slot_id}` 等),E2E/单测不破。若 DOM 结构变化影响 selector,同步更新对应测试(参照本仓既有 [E2E SYNC] 约定)。
---
## 6. 告诉 LeaderPRD §5+ Leader 问候(PRD §6
### 6.1 告诉 Leader
`TeamAssistantPickerDropdown` 底部 footer 已具备(前序已实现 header/footer 能力)。点击「告诉 Leader →」:
```ts
switchTab(leaderSlotId); // 选中 Leader
setViewMode('single'); // 切单聊全屏 Leader(可选,按体验)
// 预填 Leader 会话草稿(F5),不自动发
const draft = getSendBoxDraftHook(kindOf(leaderConv.type), initial)(leaderConv.conversation_id);
draft.mutate((prev) => ({ ...prev, content: t('team.addMember.tellLeaderPrefill') }));
```
文案 `team.addMember.tellLeaderPrefill` = 「帮我在团队里加一个擅长 \_\_\_ 的成员」。
### 6.2 Leader 问候
`TeamChatEmptyState.tsx:157-161` subtitle 替换为 B 版文案(新 i18n key `team.emptyState.leaderGreeting`):「你好,我是 Leader,负责理解你的目标并协调团队。描述你想做的事,我来安排。」保留 3 建议卡不动。
---
## 7. i18n 新增 key(覆盖各 locale
| key | 用途 |
| ----------------------------------------------------------------------------------------------------- | --------------- |
| `team.view.parallel` / `team.view.single` | 视图切换标签 |
| `team.warmup.title` / `team.warmup.progress` (带 {n}/{m}) | 遮罩文案 |
| `team.warmup.timeout` / `team.warmup.leaderFailed` / `team.warmup.retry` | 错误态 |
| `team.member.startFailed` / `team.member.retry` | teammate 失败态 |
| `team.addMember.tellLeaderHint` / `team.addMember.tellLeaderCta` / `team.addMember.tellLeaderPrefill` | 告诉 Leader |
| `team.emptyState.leaderGreeting` | Leader 问候 |
准确翻译 en-US / zh-CN / zh-TW,其余 locale 英文兜底;改后跑 `node scripts/generate-i18n-types.js` + `node scripts/check-i18n.js`
---
## 8. 分阶段落地与验收
每阶段:改动 → `bunx tsc --noEmit` + `oxlint` + 相关单测 + `bun run package` 起 dev/CDP 自查 → 交验收 → 通过后按聚焦 commit。
### 阶段 1 — 身份色系统 + 胶囊栏 + 气泡 + 选中态
1. `teamMemberColors.ts`(纯函数)+ 单测(分配/钉死/释放/循环用例)
2. `useTeamMemberColors` 挂 providercontext 暴露 `colorOf/colorOfConversation`
3. `TeamMemberCapsuleBar` 替换 TeamTabs 呈现(保留 testid
4. TeamPage 列高亮改用 `--mc`(替换 F8 硬编码 primary
5. MessageText 气泡色条+彩名(经 `resolveSenderColor` props 链,非团队不变)
- **验收**:多成员/同助手多实例下颜色清晰稳定;增删成员别人不变色;选中态明显。
### 阶段 2 — 视图切换
1. `useTeamViewMode` + `TeamViewToggle`(标题行右侧)
2. TeamPage 用 `viewMode` 替换 `fullscreenSlotId`
- **验收**:并行/单聊切换连贯、按团队记忆、选中态共享、移除回退 Leader。
### 阶段 3 — Warmup
1. `useTeamWarmup`(先校验 leader-ready 信号可取)
2. `TeamWarmupOverlay`warming/error/timeout+ 超时
3. teammate 失败态落到胶囊/列
- **验收**Leader ready 即可用;某成员失败不卡死;超时有兜底;期间可切视图、发消息被挡。
### 阶段 4 — 告诉 Leader + Leader 问候
1. Dropdown footer 引导 + 点击预填切 Leader
2. 空状态问候语替换
3. i18n 收口
- **验收**:入口常驻可见、引导可切 Leader 预填不自动发、问候到位。
---
## 9. 风险与回归
- **测试契约**:胶囊栏替换 TeamTabs DOM,需同步 team 相关单测/E2E 的 selector(本仓有 [E2E SYnC] 约定)。
- **主题回归**:身份色用 hex + color-mix,深色模式需目视核对;多套自定义主题(discourse-horizon 等)用 `:has()` 覆盖过 modal,需确认不误伤团队页新类。
- **leader-ready 信号**:阶段 3 实现前必须先在代码/CDP 确认信号可取,否则 warmup 闸门方案需回退到「全员聚合 + 超时」。
- **性能**:身份色映射为 O(成员数) 纯函数,随 assistants 变化重算,无忧。