19 KiB
团队运行体验优化 · 技术设计
配套 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 | 选中/列高亮当前散落在 TeamPage(leader 硬编码 primary 色)与 TeamTabs(active 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)
// 低饱和 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>,随成员列表增量维护:
// 纯函数:给定上次映射 + 当前成员列表 → 新映射
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; // 保底非0(0 属 Leader)
next[a.slot_id] = idx;
used.add(idx);
cursor++;
}
return next;
}
注:移除成员时该 slot 不在
assistants[]里 → 自然从next消失 = 释放;其余 slot 因走「沿用」分支而不变色。满足 PRD「别人增删不改变已有颜色」。循环仅在成员数超过色板时发生(罕见),循环项位置相隔远、不易混淆。
2.3 持久化与 Hook(useTeamMemberColors)
// 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. Warmup(PRD §7)
4.1 派生 leader-ready(F2/F3)
useTeamWarmup(可并入 useTeamSession 或独立 hook,挂 provider):
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. 告诉 Leader(PRD §5)+ Leader 问候(PRD §6)
6.1 告诉 Leader
在 TeamAssistantPickerDropdown 底部 footer 已具备(前序已实现 header/footer 能力)。点击「告诉 Leader →」:
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 — 身份色系统 + 胶囊栏 + 气泡 + 选中态
teamMemberColors.ts(纯函数)+ 单测(分配/钉死/释放/循环用例)useTeamMemberColors挂 provider,context 暴露colorOf/colorOfConversationTeamMemberCapsuleBar替换 TeamTabs 呈现(保留 testid)- TeamPage 列高亮改用
--mc(替换 F8 硬编码 primary) - MessageText 气泡色条+彩名(经
resolveSenderColorprops 链,非团队不变)
- 验收:多成员/同助手多实例下颜色清晰稳定;增删成员别人不变色;选中态明显。
阶段 2 — 视图切换
useTeamViewMode+TeamViewToggle(标题行右侧)- TeamPage 用
viewMode替换fullscreenSlotId
- 验收:并行/单聊切换连贯、按团队记忆、选中态共享、移除回退 Leader。
阶段 3 — Warmup
useTeamWarmup(先校验 leader-ready 信号可取)TeamWarmupOverlay(warming/error/timeout)+ 超时- teammate 失败态落到胶囊/列
- 验收:Leader ready 即可用;某成员失败不卡死;超时有兜底;期间可切视图、发消息被挡。
阶段 4 — 告诉 Leader + Leader 问候
- Dropdown footer 引导 + 点击预填切 Leader
- 空状态问候语替换
- i18n 收口
- 验收:入口常驻可见、引导可切 Leader 预填不自动发、问候到位。
9. 风险与回归
- 测试契约:胶囊栏替换 TeamTabs DOM,需同步 team 相关单测/E2E 的 selector(本仓有 [E2E SYnC] 约定)。
- 主题回归:身份色用 hex + color-mix,深色模式需目视核对;多套自定义主题(discourse-horizon 等)用
:has()覆盖过 modal,需确认不误伤团队页新类。 - leader-ready 信号:阶段 3 实现前必须先在代码/CDP 确认信号可取,否则 warmup 闸门方案需回退到「全员聚合 + 超时」。
- 性能:身份色映射为 O(成员数) 纯函数,随 assistants 变化重算,无忧。