AionUi 团队运行体验优化设计解析:身份色、并行/单聊视图与 Warmup 闸门的纯前端实现
【免费下载链接】AionUi免费、本地、开源的 24/7 全天候 Cowork 应用,以及适用于 Gemini CLI、Claude Code、Codex、OpenCode、Qwen Code、Goose CLI、Auggie 等的 OpenClaw | 🌟 喜欢就点star吧项目地址: https://gitcode.com/iOfficeAI/AionUi
AionUi(iOfficeAI)在团队详情页面向多成员并行协作场景做了一次纯渲染层的体验重构,核心目标是让用户「一眼分清谁是谁、清楚当前在跟谁对话、并在团队初始化期间得到明确引导而非静默卡顿」。本文以 team-runtime-experience.design.md 为骨架,配套 PRD,结合 TeamPage.tsx、TeamTabsContext.tsx 等真实实现,完整讲解身份色系统、视图切换、Warmup 初始化闸门、成员胶囊栏、「告诉 Leader」与 Leader 问候语的设计决策与落地要点。
0. 设计前提:现状调研决定架构
本方案全部是渲染层改动,不改 aioncore 后端与团队数据结构,持久化一律走localStorage。代码基准目录为packages/desktop/src/renderer/(下文称@renderer)。设计文档开篇用九条「现状事实」锁定了架构方向,其中最关键的四条决定了后面所有模块的形态:
| # | 事实 | 出处 | 对方案的影响 |
|---|---|---|---|
| F1 | TeamTabsProvider已是每团队一实例,已有按team_id分键的localStorage(team-active-slot-*、team-assistant-order-*) | TeamTabsContext.tsx | 身份色映射、视图模式复用同一层与同款 key |
| F2 | 运行时事件ITeamAgentRuntimeStatusEvent带slot_id+status: 'pending' / 'ready' / 'failed' | common/types/team/teamTypes.ts | 可单独判定 Leader ready,Warmup 闸门成立 |
| F3 | membershipMutationBusy是全员聚合忙碌,不区分 Leader | teamMembershipMutationBusy.ts | Warmup 需在 session 层额外派生 leader-ready |
| F4 | teammate 消息只带senderConversationId(无slot_id) | chatLib.ts、MessageText.tsx | 身份色按 conversation_id 取色,需要conversationId→颜色的解析 |
其余事实还包括:发送框预填走getSendBoxDraftHook(type)(conversation_id).mutate(...),SWR 同 key 自动同步到 SendBox,因此「告诉 Leader」可直接复用而无需新机制(F5);并行/全屏已有fullscreenSlotId,可升级为显式、持久化的viewMode(F6);空状态 Leader 分支已有 subtitle + 3 个建议卡(F7);选中/列高亮颜色散落在 TeamPage 与 TeamTabs 的硬编码 primary(F8,需由身份色系统统一收口);成员实例键为slot_id,且conversation_id与slot_id在assistants[]上一一对应(F9,可建索引打通消息与成员)。
架构含义:身份色的「真源」是slot_id(成员实例),但消息侧只认conversation_id。因此身份色系统对外暴露两个查询——colorOf(slot_id)与colorOfConversation(conversation_id),内部用assistants[]维护conversation_id→slot_id索引打通。
1. 模块总览:在既有 Provider 上「挂载」而非重写
新增能力全部挂在已存在的TeamTabsProvider(每团队一实例)上:
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 的分散)。
在真实代码中,上述模块已经落地为pages/team/identity/、pages/team/hooks/、pages/team/components/三组文件,且TeamTabsContextValue已正式导出colorOf与colorOfConversation两个查询(见 TeamTabsContext.tsx 的 context 类型定义),TeamPageContent也从useTeamTabs()中解构取用。
2. 身份色系统:slot 为真源、conversation 为消息侧入口
2.1 色板:品牌基调的低饱和邻近色
色板取自 AionUi 品牌基调,首版直接用 hex,避免铺开主题工作量(teamMemberColors.ts):
// 低饱和 slate 邻近色。每色给 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)。PRD 同时强调设计基调:颜色只作「身份标识」,不做整格彩底,保持界面灰白干净。
2.2 分配算法:钉死 + 释放复用
真源是一张Record<slot_id, colorIndex>,随成员列表增量维护。核心纯函数assignMemberColors实现三段式分配:
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; }关键不变量(PRD §1 的稳定性规则):
- 移除成员时该 slot 不在
assistants[]里 → 自然从next消失 =释放色号;其余 slot 因走「沿用」分支而不变色。满足「别人增删不改变已有颜色」。 - 新增成员取「当前未占用的最小色号」,优先复用被移除成员释放出的空档。
- 循环仅在成员数超过色板时发生(罕见),且循环项位置相隔远、不易混淆。
- 未知 slot 回退到 Leader 色(安全兜底),见
memberColorValue实现。
2.3 持久化与 Hook:useTeamMemberColors
key 为team-member-colors-${team_id},值是Record<slot_id, colorIndex>。Hook 随成员列表增量重算,shallowEqual无变化则不触发写盘;同时用useMemo构建conversation_id→slot_id索引:
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。真实实现里存储读取还有防御处理:JSON.parse失败或非对象时回退空对象,localStorage.setItem抛错(存储满/不可用)时忽略——颜色仍在内存生效。
2.4 消息侧取色:F4 的两种解法
MessageText位于很深的会话渲染链(AssistantChatSlot → TeamChatView → AcpChat → MessageList → MessageText),不在 TeamTabsProvider 子树内的保证性不足。设计文档给出两个方案:
- 方案 a(推荐):team 会话渲染链上已知
team_id与assistants,在AssistantChatSlot → TeamChatView传入resolveSenderColor(senderConversationId)回调,透传到 MessageList/MessageText。改动局部、不引入全局 context。 - 方案 b:新建
TeamIdentityContext提供colorOfConversation,MessageText里useContext(可选、非 team 场景返回 undefined → 不显示色条)。
最终落地采用了方案 b:真实代码中 TeamIdentityContext.tsx 以可选 context 提供colorOfConversation,useTeammateColor(senderConversationId)在非团队场景返回undefined(不着色),因此对普通会话渲染零影响。TeamPageContent中用TeamIdentityProvider colorOfConversation={colorOfConversation}包裹整个ChatLayout,实现跨深层渲染链取色。
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)
真实代码中,AssistantChatSlot的列身体就是background: color-mix(in srgb, ${color} 4%, var(--bg-base)),列头名字通过nameStyle={{ color }}着色(见 TeamPage.tsx 的AssistantChatSlot),并且刻意「抬头不叠身份色底」,避免压低彩色名字的可读性。
3. 视图切换:并行 / 单聊按团队记忆
useTeamViewMode(team_id):viewMode: 'parallel' | 'single',keyteam-view-mode-${team_id},默认'parallel',挂TeamTabsProvider,context 暴露viewMode/setViewMode。
真实实现中类型还包含'board'(只读的「消息 & 任务」看板视图),且读取时做了旧值迁移:stored === 'board' || stored === 'flow'都归一为board(历史遗留的flow值自动升级),存储失败时降级为parallel,见 useTeamViewMode.ts。
TeamPage 渲染改造要点(对照 TeamPage.tsx 的TeamPageContent):
- 把现有
fullscreenSlotId ? 全屏 : 并行的判断,替换为viewMode === 'single' ? 单列(activeSlotId) : 并行。 - 单聊显示
activeSlotId对应成员(复用现全屏那段 JSX,slot 来源从fullscreenSlotId换成activeSlotId);找不到时回退leadAssistant ?? assistants[0]。 fullscreenSlotId本地 state 移除;原「点全屏图标」改为「切到单聊 + switchTab 到该 slot」(代码中是switchTab(assistant.slot_id); setViewMode('single');单聊内点 off-screen 则setViewMode('parallel'))。- 选中成员被移除时回退 Leader:既有逻辑
TeamTabsContext.tsx中「active tab removed → prefer leader」的 effect 覆盖。 - 视图切换控件
TeamViewToggle放 ChatLayout 标题行右侧:真实代码通过headerExtra={assistants.length > 1 ? <TeamViewToggle value={viewMode} onChange={setViewMode} /> : undefined}传入,成员数 ≤ 1 时不显示切换入口。 - 并行视图下点胶囊会滚动到对应列并做一次 150ms 的闪烁提示;单聊视图下直接切换选中成员即可。
- warmup 期间允许切视图:
TeamViewToggle不受 warmup 禁用影响(控件在标题行,不在遮罩覆盖范围内)。
4. Warmup 初始化闸门:Leader ready 即放行
4.1 派生 leader-ready(F2/F3)
设计骨架useTeamWarmup(team_id, leaderSlotId, statusMap):
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初值用team.assistants[].status初始化(useTeamSession.ts);若后端在进入时不重发 ready 事件,靠初值判定;若重发,靠事件。两条都覆盖,故leaderReady初值也读 statusMap。
真实的 useTeamWarmup.ts 实现更完整:phase收敛为'warming' | 'ready' | 'error',同时订阅两条事件流——agentRuntimeStatusChanged(提供按 slot 的诊断细节,写入runtimeStatus: Map<slot_id, {status, error}>)与sessionStatusChanged(starting → warming、ready → ready、failed → error,作为 ready/failed 迁移的权威信号,stopped状态不改变当前 phase,由发送框以可恢复提示呈现)。注释中说明:session 状态流是 ready/failed 迁移的权威,ensure promise 是事件丢失时的兜底,成员 runtime 事件仅提供逐 slot 诊断细节。retry()通过递增ensureAttempt重新触发整队初始化。
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 区,不盖标题行——标题行的视图切换仍可用)。真实代码中TeamWarmupOverlay接收phase / assistants / runtimeStatus / colorOf / onRetry,渲染在 chat 区域容器内、ChatLayout之下。
4.3 禁用清单接线
warmup(phase === 'warming')期间:
- 加/删/改成员:已有
membershipMutationBusy门控(TeamTabs、TeamPage.handleRemoveAssistant);warmup 与之高度重合,保持即可。加号(TeamAddMemberPopoverdisabled)同理。 - 发消息:遮罩覆盖 chat 区(含 SendBox)→ 天然禁用,无需额外逻辑。
- 允许:切视图、切成员、滚动(这些控件在标题行/成员栏,不被遮罩覆盖)。
真实代码进一步说明边界:仅在「唤醒进行中」禁用改成员;失败态(error)要放开,让用户能移除失败成员来自救;warmup 失败的成员 slot 集合(warmupFailedSlotIds)会把对应胶囊头像标红。此外AssistantChatSlot在整队 warming 期间不下发手动触发(warmupDisabled),手动唤醒(retry start)由阶段闸门控制。
5. 成员栏胶囊化
TeamMemberCapsuleBar(替换TeamTabs的呈现,保留其 context 消费与>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= 「帮我在团队里加一个擅长 ___ 的成员」,不自动发送,光标定位让用户补全后自行发出。这依赖 F5 的既有机制:发送框预填走getSendBoxDraftHook(type)(conversation_id).mutate(...),SWR 同 key 自动同步到 SendBox,无需新机制。不做空状态——用户一定有可添加的助手。
6.2 Leader 问候语(PRD §6)
TeamChatEmptyState.tsx的 Leader 分支 subtitle 替换为 B 版文案(新 i18n keyteam.emptyState.leaderGreeting):
你好,我是 Leader,负责理解你的目标并协调团队。描述你想做的事,我来安排。
保留下方 3 个建议卡不动(建议卡 onClick=fillDraft(label)的既有交互继续生效),纯文案改动走 i18n。
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(脚本位于 scripts/)。
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 链或 TeamIdentityContext,非团队不变)
- 验收:多成员/同助手多实例下颜色清晰稳定;增删成员别人不变色;选中态明显。
阶段 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,深色模式需目视核对;多套自定义主题用:has()覆盖过 modal,需确认不误伤团队页新类。 - leader-ready 信号:阶段 3 实现前必须先在代码/CDP 确认信号可取,否则 warmup 闸门方案需回退到「全员聚合 + 超时」。该前置确认在真实实现中已通过双事件流(
sessionStatusChanged权威 +agentRuntimeStatusChanged诊断)解决。 - 性能:身份色映射为 O(成员数) 纯函数,随 assistants 变化重算,无忧。
附:与 PRD 的对照及后续专题
本文对应的 PRD 还包含两个本次范围之外的后续专题:全 App 头像容器统一(期望统一为左侧对话列表那种圆形头像)与确认型弹窗轻量统一。仓库内另有配套的 团队运行体验 PRD 与团队整体说明 README,可结合阅读。
【免费下载链接】AionUi免费、本地、开源的 24/7 全天候 Cowork 应用,以及适用于 Gemini CLI、Claude Code、Codex、OpenCode、Qwen Code、Goose CLI、Auggie 等的 OpenClaw | 🌟 喜欢就点star吧项目地址: https://gitcode.com/iOfficeAI/AionUi
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考