From a95590b1cf851465a7bf5adabcc16a7ee478e1c3 Mon Sep 17 00:00:00 2001
From: Chris <4436110+zqchris@users.noreply.github.com>
Date: Thu, 5 Nov 2026 00:59:10 +0800
Subject: [PATCH 1/2] fix(bots): show partner avatars beside work processes on
all clients
Assisted-by: Cindy (https://cindy.app)
Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com>
---
.../teammate-process/process-fixture.tsx | 2 +-
.../fixtures/teammate-process/ui-page.mjs | 7 +++++++
.../renderer/components/chat/MessageStream.tsx | 17 ++++++++---------
.../bots/__tests__/botChatSurface.test.ts | 10 +++++-----
.../__tests__/pluginAttachmentBubble.test.tsx | 7 ++++---
apps/mobile/src/session/MessageRenderer.tsx | 14 +++++++++++---
docs/design-rules/DESIGN.md | 3 +++
7 files changed, 39 insertions(+), 21 deletions(-)
diff --git a/apps/desktop/scripts/fixtures/teammate-process/process-fixture.tsx b/apps/desktop/scripts/fixtures/teammate-process/process-fixture.tsx
index 7301d347e65..c2a58b34ada 100644
--- a/apps/desktop/scripts/fixtures/teammate-process/process-fixture.tsx
+++ b/apps/desktop/scripts/fixtures/teammate-process/process-fixture.tsx
@@ -81,7 +81,7 @@ function Subject({ done, retry, artifactCase }: { done: boolean; retry: boolean;
return
} simplifiedBotConversation isSessionStreaming={!finished} contentWidth={720} />
+ assistantAvatar={
} simplifiedBotConversation isSessionStreaming={!finished} contentWidth={720} />
;
}
void mountPage({
diff --git a/apps/desktop/scripts/fixtures/teammate-process/ui-page.mjs b/apps/desktop/scripts/fixtures/teammate-process/ui-page.mjs
index 5db9bd0e260..3d494a5556e 100644
--- a/apps/desktop/scripts/fixtures/teammate-process/ui-page.mjs
+++ b/apps/desktop/scripts/fixtures/teammate-process/ui-page.mjs
@@ -97,6 +97,11 @@ export default {
const journal = page.locator('[data-teammate-process]');
await journal.waitFor();
assert.equal(await journal.count(), 1, 'One disclosure owns all intermediate notes');
+ // Visible UI regression: process and reply each own a portrait; expansion
+ // must not repeat it in tool details. Hidden spacers failed this check.
+ const processPortrait = journal.locator('xpath=../..').getByTestId('fixture-partner-avatar');
+ assert.equal(await processPortrait.count(), 1);
+ assert.equal(await processPortrait.isVisible(), true, 'The process portrait is visible');
const heading = journal.locator(':scope > button');
if (definition.data.retry) {
const action = journal.locator('[data-teammate-segment] > button');
@@ -138,6 +143,8 @@ export default {
assert.equal(await heading.getAttribute('aria-expanded'), 'true', 'Keep inspected work open on completion');
}
await ui.capture('records');
+ assert.equal(await page.getByTestId('fixture-partner-avatar').count(), 2, 'Expanded process and final reply each have one portrait');
+ assert.equal(await processPortrait.isVisible(), true);
await heading.click();
assert.equal(await heading.getAttribute('aria-expanded'), 'false');
},
diff --git a/apps/desktop/src/renderer/components/chat/MessageStream.tsx b/apps/desktop/src/renderer/components/chat/MessageStream.tsx
index 49d4b1fed95..4876bbdba06 100644
--- a/apps/desktop/src/renderer/components/chat/MessageStream.tsx
+++ b/apps/desktop/src/renderer/components/chat/MessageStream.tsx
@@ -2899,7 +2899,7 @@ function renderWorkGroupChild(
}
// 工作组里的中间过程文字不挂 assistantAvatar:折叠块里逐条画脸只会变噪音,
- // 身份标记只属于对话流里真正的那句回复(见 MessageItem 的 assistantAvatar)。
+ // 过程区外层与最终回复各显示一次头像,展开明细不重复。
return (
;
context: WorkGroupRenderContext;
compact: boolean;
- /** 伙伴聊天:隐形头像占位,让工作过程与回复正文同列对齐。 */
- replyColumnSpacer?: ReactNode;
+ /** 伙伴聊天:过程区与最终回复各显示一个头像,正文保持同列。 */
+ companionAvatar?: ReactNode;
}) {
// 完成态外层时间线可包含内层 work_group。递归只负责形状映射,
// 具体折叠 / 直接详情逻辑全部复用 WorkGroupBlock。
@@ -2999,7 +2999,7 @@ export const WorkGroupRenderItemView = memo(function WorkGroupRenderItemView({
childItems={childItems}
compact={compact}
deferred={item.deferred}
- fitHeader={Boolean(replyColumnSpacer)}
+ fitHeader={Boolean(companionAvatar)}
journal={item.teammateJournal}
journalTitle={item.isStreaming ? (elapsed) => (
{elapsed}
@@ -3018,7 +3018,7 @@ export const WorkGroupRenderItemView = memo(function WorkGroupRenderItemView({
className="scroll-mt-20"
data-message-client-ids={collectWorkGroupClientIds(item.children).join(' ')}
>
- {replyColumnSpacer ? withAssistantAvatar(replyColumnSpacer, block) : block}
+ {companionAvatar ? withAssistantAvatar(companionAvatar, block) : block}
);
});
@@ -6352,8 +6352,7 @@ export function MessageStream({
previousLocalFileRefsRef.current = localFileRefs;
}, [localFileRefs]);
- // 伙伴聊天的工作过程、成果与回复正文同列:只占头像宽度,不重复画脸。memo 住以保持
- // WorkGroupRenderItemView 的 memo 比较成立。
+ // 成果与回复正文同列,只占头像宽度;过程区单独显示伙伴头像。
const botReplyColumnSpacer = useMemo(
() =>
simplifiedBotConversation && assistantAvatar ? (
@@ -6707,7 +6706,7 @@ export function MessageStream({
// 伙伴聊天与普通任务同一套工作过程:运行中露最近 5 条活动与计时,
// 组头按内容宽;最终答复后保留折叠过程,按需展开。
compact={false}
- replyColumnSpacer={botReplyColumnSpacer}
+ companionAvatar={simplifiedBotConversation ? assistantAvatar : undefined}
/>
);
}
diff --git a/apps/desktop/src/renderer/features/bots/__tests__/botChatSurface.test.ts b/apps/desktop/src/renderer/features/bots/__tests__/botChatSurface.test.ts
index 7ee1b514226..12d167d119f 100644
--- a/apps/desktop/src/renderer/features/bots/__tests__/botChatSurface.test.ts
+++ b/apps/desktop/src/renderer/features/bots/__tests__/botChatSurface.test.ts
@@ -53,15 +53,15 @@ describe('消息流的头像挂载', () => {
expect(helper).toContain('if (!avatar) return bubble;');
});
- it('只有 assistant 分支挂头像', () => {
+ it('回复和过程区挂头像,卡片保留占位', () => {
expect(messageStream).toContain('return withAssistantAvatar(\n assistantAvatar,');
- // Assistant text shows the avatar; task and teammate-message cards plus the
- // work fold and generated artifacts reserve the same space invisibly.
+ // Assistant text and work folds show avatars; task and teammate-message
+ // cards and generated artifacts reserve the same space invisibly.
// User messages bypass it; fixture geometry covers pending/empty artifacts.
expect(messageStream.match(/withAssistantAvatar\(/g)?.length).toBe(5);
expect(messageStream).toMatch(/simplifiedBotConversation\s*&&\s*\(message\.systemCardType === 'bot-session-task'\s*\|\|\s*message\.systemCardType === 'bot-session-task-result'\s*\|\|\s*message\.systemCardType === 'bot-session-task-message'\s*\|\|\s*message\.systemCardType === 'bot-direct-message'\)\s*\? withAssistantAvatar\(/);
expect(messageStream).toMatch(/\s*\{assistantAvatar\}\s*<\/span>/);
- expect(messageStream).toContain('{replyColumnSpacer ? withAssistantAvatar(replyColumnSpacer, block) : block}');
+ expect(messageStream).toContain('{companionAvatar ? withAssistantAvatar(companionAvatar, block) : block}');
});
});
@@ -111,7 +111,7 @@ describe('伙伴消息流复用工作组件并保留独立过程分组', () => {
expect(messageStream).toContain("from '@/features/bots/botConversationPresentation'");
expect(messageStream).toContain('compact={false}');
expect(messageStream).not.toContain('compact={simplifiedBotConversation}');
- expect(messageStream).toContain('replyColumnSpacer={botReplyColumnSpacer}');
+ expect(messageStream).toContain('companionAvatar={simplifiedBotConversation ? assistantAvatar : undefined}');
expect(messageStream).not.toContain('data-testid="bot-thinking-indicator"');
expect(sessionView).not.toContain(' {
for (const element of cases) expect(renderClient(element)).not.toContain('companion.taskResult');
});
- // Regression: expanding process prose must not duplicate the final reply's portrait.
+ // Regression: the process and final reply each own one portrait; nested details do not.
// Exercise the production projection and renderer, including nested work groups.
- it('keeps expanded companion work readable without repeating the reply portrait', () => {
+ it('shows one process portrait and one reply portrait without repeating them in expanded details', () => {
const items = companionConversationItems(buildMobileMessageRenderItems([
msg('u', 'user', 'Check it'),
msg('p', 'assistant', 'Checking the details', { agentMeta: { assistantPhase: 'commentary' } }),
@@ -277,7 +277,8 @@ describe('partner conversation presentation', () => {
expect(html.match(/message\.agentBubble/g)).toHaveLength(2);
expect(html).toContain('message.toolRow');
expect(html.match(/companion\.replyRow/g)).toHaveLength(1);
- expect(html.match(/partner-portrait/g)).toHaveLength(1);
+ expect(html.match(/companion\.processRow/g)).toHaveLength(1);
+ expect(html.match(/partner-portrait/g)).toHaveLength(2);
} finally {
viewportHarness.expanded = false;
}
diff --git a/apps/mobile/src/session/MessageRenderer.tsx b/apps/mobile/src/session/MessageRenderer.tsx
index d4156a28662..9973d4654c1 100644
--- a/apps/mobile/src/session/MessageRenderer.tsx
+++ b/apps/mobile/src/session/MessageRenderer.tsx
@@ -3202,10 +3202,18 @@ const RenderItemView = memo(function RenderItemView({
}
const groupTimestamp = actions.companion && (item.type === 'message' || item.type === 'pending_send')
? actions.companionTimeGroups?.get(item.key) : undefined;
- // Cards and work groups share the reply text column, leaving the avatar slot empty.
+ // Each outer process group owns a portrait, just like the final reply.
+ // Nested details clear companionAvatar, so expanding them cannot repeat it.
+ const processAvatar = actions.companion && item.type === 'work_group' ? actions.companionAvatar : undefined;
+ // Cards still share the reply text column without repeating the portrait.
const alignWithReplies = !!actions.companionAvatar && actions.companion
- && (item.type === 'work_group' || (item.type === 'message' && !!item.message.companion));
- const aligned = alignWithReplies ? (
+ && item.type === 'message' && !!item.message.companion;
+ const aligned = processAvatar ? (
+
+ {processAvatar}
+ {node}
+
+ ) : alignWithReplies ? (
{node}
) : (
node
diff --git a/docs/design-rules/DESIGN.md b/docs/design-rules/DESIGN.md
index 0463b9a8016..0365aae2b59 100644
--- a/docs/design-rules/DESIGN.md
+++ b/docs/design-rules/DESIGN.md
@@ -1547,6 +1547,9 @@ and media position (`ui/media-scrubber`, seconds in/out). Native media controls
### 伙伴私聊消息下操作(2026-10-29)
+伙伴私聊的工作过程区(运行中与完成后)和最终回复各显示一个当前伙伴头像,三端一致。
+复用回复头像的尺寸、间距和正文列;展开的中间文字与工具明细不重复头像,成果卡仍与正文对齐。
+
常用操作固定在消息下方,复用现有群聊图标、引用预览与输入组件。桌面32px按钮、4px间隔、14px图标;手机44pt热区、不额外插空,左右消息的操作图形对齐对应正文边缘。复制/回复/更多常显,现有朗读与编辑能力保留;创建话题与分享图片放更多。已有话题沿用头像、回复数及最后回复时间/未读提示入口,不另造数字徽章。群聊操作及主对话914px宽度规则不变,手机正文直接选字不受操作栏影响。沿用语义色与原菜单开合、焦点和按压状态;五语及明暗模式均不得溢出。
### 伙伴内部通讯入口(2026-11-01)
From 876c8c4e97cb694e1dcd4e26cdd0f3d5ca05039e Mon Sep 17 00:00:00 2001
From: Chris <4436110+zqchris@users.noreply.github.com>
Date: Thu, 5 Nov 2026 01:15:27 +0800
Subject: [PATCH 2/2] fix(mobile): keep partner avatars out of nested subagent
details
Assisted-by: Cindy (https://cindy.app)
Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com>
---
.../__tests__/pluginAttachmentBubble.test.tsx | 29 +++++++++++++++++++
apps/mobile/src/session/MessageRenderer.tsx | 4 ++-
2 files changed, 32 insertions(+), 1 deletion(-)
diff --git a/apps/mobile/src/__tests__/pluginAttachmentBubble.test.tsx b/apps/mobile/src/__tests__/pluginAttachmentBubble.test.tsx
index 0771decf6a5..47badaa8a85 100644
--- a/apps/mobile/src/__tests__/pluginAttachmentBubble.test.tsx
+++ b/apps/mobile/src/__tests__/pluginAttachmentBubble.test.tsx
@@ -284,6 +284,35 @@ describe('partner conversation presentation', () => {
}
});
+ // PR #8864: an Agent's nested tool work must not inherit the main partner's
+ // portrait. Existing process expansion coverage did not cross SubagentCard.
+ // Production projection + renderer, unit tier; Agent -> tool -> child answer
+ // is the smallest input that exposes the extra process portrait.
+ it('keeps the main partner portrait out of expanded subagent work', () => {
+ const items = companionConversationItems(buildMobileMessageRenderItems([
+ msg('u', 'user', 'Research it'),
+ msg('agent', 'tool_use', { toolName: 'Agent', toolUseId: 'agent', input: { description: 'Research' } }, { toolUseId: 'agent' }),
+ msg('tool', 'tool_use', { toolName: 'Bash', toolUseId: 'tool', input: { command: 'pwd' } }, { toolUseId: 'tool', agentMeta: { parentUuid: 'agent' } }),
+ msg('tool-result', 'tool_result', '/repo', { toolUseId: 'tool', agentMeta: { parentUuid: 'agent' } }),
+ msg('child-answer', 'assistant', 'Child research complete', { agentMeta: { parentUuid: 'agent', assistantPhase: 'final_answer' } }),
+ msg('agent-result', 'tool_result', 'Research summary', { toolUseId: 'agent' }),
+ msg('answer', 'assistant', 'Main partner answer', { agentMeta: { assistantPhase: 'final_answer' } }),
+ ], { isSessionStreaming: false }));
+ viewportHarness.expanded = true;
+ try {
+ const html = renderClient(} />);
+ const host = document.createElement('div');
+ host.innerHTML = html;
+ expect(host.querySelector('[data-testid="message.subagentToggle"]')).toBeTruthy();
+ expect(host.querySelector('[data-testid="message.toolRow"]')).toBeTruthy();
+ expect(host.textContent).toContain('Research summary');
+ expect(host.querySelectorAll('[data-testid="companion.processRow"]')).toHaveLength(0);
+ expect(host.querySelectorAll('[data-testid="partner-portrait"]')).toHaveLength(1);
+ } finally {
+ viewportHarness.expanded = false;
+ }
+ });
+
it('hangs replies from the partner portrait and stamps five-minute groups like Desktop', () => {
const messages = [
msg('u1', 'user', 'Hi', { createdAt: '2026-01-01T09:00:00Z' }),
diff --git a/apps/mobile/src/session/MessageRenderer.tsx b/apps/mobile/src/session/MessageRenderer.tsx
index 9973d4654c1..4dcb5b52274 100644
--- a/apps/mobile/src/session/MessageRenderer.tsx
+++ b/apps/mobile/src/session/MessageRenderer.tsx
@@ -5389,6 +5389,8 @@ function SubagentCard({
const [expanded, toggleExpanded] = useFoldableExpandedState(item.key, false);
const deferred = item.deferred;
useEffect(() => deferred?.setVisible?.(expanded, false), [deferred?.owner, deferred?.key, expanded]);
+ // Subagent work is detail content, not another reply from the main partner.
+ const childActions = useMemo(() => ({ ...actions, companionAvatar: undefined }), [actions]);
const title = item.header.subagentType
? t('message.renderer.subagentTyped', { type: item.header.subagentType })
: t('message.renderer.subagent');
@@ -5425,7 +5427,7 @@ function SubagentCard({
) : null}
{/* 两级展开(与 WorkGroupCard 同规则):内层子卡保持各自折叠头行,按需下钻。 */}
{item.childItems.map((child) => (
-
+
))}
{item.summary ? (