docs/prds/teams/team-runtime-experience.design.md
配套 PRD:team-runtime-experience.md。本文面向实现,给出现状调研结论、架构决策、模块设计、关键代码骨架与分阶段落地。全部为渲染层改动,不改 aioncore 与团队数据结构;持久化走 localStorage。
代码位置基准:packages/desktop/src/renderer/(下称 @renderer)。
| # | 事实 | 出处 | 对方案的影响 |
| --- | --------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | -------------------------------------------------------------------- | ------------------------------------ | --------------------------------------------- |
| 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 索引打通。
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 的分散)。
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,避免铺开主题工作量。
真源是一张 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「别人增删不改变已有颜色」。循环仅在成员数超过色板时发生(罕见),循环项位置相隔远、不易混淆。
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 使用。
MessageText 目前不在 TeamTabsProvider 子树内的保证性不足(它在会话渲染链里)。两个方案:
team_id 与 assistants,在 AssistantChatSlot → TeamChatView 传入一个 resolveSenderColor(senderConversationId) 回调,透传到 MessageList/MessageText。改动局部、不引入全局 context。TeamIdentityContext 提供 colorOfConversation,MessageText 里 useContext(可选、非 team 场景返回 undefined→不显示色条)。首版走 a:沿现有 props 链把 resolveSenderColor 传到 MessageText;非团队消息该回调不存在 → 行为不变。
统一做法:给需要着色的容器设 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)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」。TeamTabsContext.tsx:104-110 覆盖。TeamViewToggle 放 ChatLayout 标题行右侧(headerLeading 对侧,需确认 ChatLayout 是否提供 title 右侧 slot;若无,作为 tabsSlot 区域右对齐元素)。warmup 期间允许切视图:TeamViewToggle 不受 warmup 禁用影响。
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。
TeamWarmupOverlayphase==='warming':磨砂遮罩(backdrop-filter:blur(3px) + bg color-mix(--bg-1 78%))+ 成员头像从左到右逐个点亮(依据各 slot 的 statusMap ready 数)+「唤醒中 N/M」+ 品牌色进度条。phase==='error'|'timeout':错误态卡片(文案 + 重试/返回)。重试 = 重新触发进入团队的初始化(复用现有进入路径 / useActiveLease 重建;具体动作实现时定,不新增后端接口)。phase==='ready':不渲染(撤除)。.warmwrap 定位父级,覆盖 chat 区,不盖标题行——标题行的视图切换仍可用)。warmup(phase==='warming')期间:
membershipMutationBusy 门控(TeamTabs、TeamPage.handleRemoveAssistant:277);warmup 与之高度重合,保持即可。加号(TeamAddMemberPopover disabled)同理。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] 约定)。
在 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 = 「帮我在团队里加一个擅长 ___ 的成员」。
TeamChatEmptyState.tsx:157-161 subtitle 替换为 B 版文案(新 i18n key team.emptyState.leaderGreeting):「你好,我是 Leader,负责理解你的目标并协调团队。描述你想做的事,我来安排。」保留 3 建议卡不动。
| 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。
每阶段:改动 → bunx tsc --noEmit + oxlint + 相关单测 + bun run package 起 dev/CDP 自查 → 交验收 → 通过后按聚焦 commit。
teamMemberColors.ts(纯函数)+ 单测(分配/钉死/释放/循环用例)useTeamMemberColors 挂 provider,context 暴露 colorOf/colorOfConversationTeamMemberCapsuleBar 替换 TeamTabs 呈现(保留 testid)--mc(替换 F8 硬编码 primary)resolveSenderColor props 链,非团队不变)useTeamViewMode + TeamViewToggle(标题行右侧)viewMode 替换 fullscreenSlotIduseTeamWarmup(先校验 leader-ready 信号可取)TeamWarmupOverlay(warming/error/timeout)+ 超时:has() 覆盖过 modal,需确认不误伤团队页新类。