Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ function Subject({ done, retry, artifactCase }: { done: boolean; retry: boolean;
return <div className="flex h-screen w-full min-w-0 flex-col bg-[var(--surface)]" data-ui-page-subject>
<div className="self-end p-2"><Button onClick={() => setFinished(true)} data-testid="fixture-finish">Finish fixture</Button></div>
<MessageStream workingDir="" historyLoaded messages={finished ? [...rows, { ...text('final_answer', '任务与 PR 的进展已整理。未处理完的事项和需要你判断的部分,已分别列出。', 'final_answer'), turnCompleted: true }] : rows}
assistantAvatar={<BotAvatar bot={{ id: "fixture", name: "Cindy", avatar: "cindy://avatar/preset/cindy" }} size="sm" />} simplifiedBotConversation isSessionStreaming={!finished} contentWidth={720} />
assistantAvatar={<span data-testid="fixture-partner-avatar"><BotAvatar bot={{ id: "fixture", name: "Cindy", avatar: "cindy://avatar/preset/cindy" }} size="sm" /></span>} simplifiedBotConversation isSessionStreaming={!finished} contentWidth={720} />
</div>;
}
void mountPage({
Expand Down
7 changes: 7 additions & 0 deletions apps/desktop/scripts/fixtures/teammate-process/ui-page.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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');
},
Expand Down
17 changes: 8 additions & 9 deletions apps/desktop/src/renderer/components/chat/MessageStream.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2899,7 +2899,7 @@ function renderWorkGroupChild(
}

// 工作组里的中间过程文字不挂 assistantAvatar:折叠块里逐条画脸只会变噪音,
// 身份标记只属于对话流里真正的那句回复(见 MessageItem 的 assistantAvatar)。
// 过程区外层与最终回复各显示一次头像,展开明细不重复。
return (
<div data-message-client-id={item.message.clientId}>
<MessageItem
Expand Down Expand Up @@ -2945,13 +2945,13 @@ export const WorkGroupRenderItemView = memo(function WorkGroupRenderItemView({
item,
context,
compact,
replyColumnSpacer,
companionAvatar,
}: {
item: Extract<RenderItem, { type: 'work_group' }>;
context: WorkGroupRenderContext;
compact: boolean;
/** 伙伴聊天:隐形头像占位,让工作过程与回复正文同列对齐。 */
replyColumnSpacer?: ReactNode;
/** 伙伴聊天:过程区与最终回复各显示一个头像,正文保持同列。 */
companionAvatar?: ReactNode;
}) {
// 完成态外层时间线可包含内层 work_group。递归只负责形状映射,
// 具体折叠 / 直接详情逻辑全部复用 WorkGroupBlock。
Expand Down Expand Up @@ -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) => (
<TeammateProcessStatus sessionId={context.sessionId}>{elapsed}</TeammateProcessStatus>
Expand All @@ -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}
</div>
);
});
Expand Down Expand Up @@ -6352,8 +6352,7 @@ export function MessageStream({
previousLocalFileRefsRef.current = localFileRefs;
}, [localFileRefs]);

// 伙伴聊天的工作过程、成果与回复正文同列:只占头像宽度,不重复画脸。memo 住以保持
// WorkGroupRenderItemView 的 memo 比较成立。
// 成果与回复正文同列,只占头像宽度;过程区单独显示伙伴头像。
const botReplyColumnSpacer = useMemo(
() =>
simplifiedBotConversation && assistantAvatar ? (
Expand Down Expand Up @@ -6707,7 +6706,7 @@ export function MessageStream({
// 伙伴聊天与普通任务同一套工作过程:运行中露最近 5 条活动与计时,
// 组头按内容宽;最终答复后保留折叠过程,按需展开。
compact={false}
replyColumnSpacer={botReplyColumnSpacer}
companionAvatar={simplifiedBotConversation ? assistantAvatar : undefined}
/>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(/<span aria-hidden="true" className="invisible">\s*\{assistantAvatar\}\s*<\/span>/);
expect(messageStream).toContain('{replyColumnSpacer ? withAssistantAvatar(replyColumnSpacer, block) : block}');
expect(messageStream).toContain('{companionAvatar ? withAssistantAvatar(companionAvatar, block) : block}');
});
});

Expand Down Expand Up @@ -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('<BotWorkingStatus');
expect(sessionView).toContain('<RunningStatusBar');
Expand Down
36 changes: 33 additions & 3 deletions apps/mobile/src/__tests__/pluginAttachmentBubble.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -262,9 +262,9 @@ describe('partner conversation presentation', () => {
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' } }),
Expand All @@ -277,7 +277,37 @@ 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;
}
});

// 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(<MessageRenderer items={items} companion companionAvatar={<i data-testid="partner-portrait" />} />);
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;
}
Expand Down
18 changes: 14 additions & 4 deletions apps/mobile/src/session/MessageRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
zqchris marked this conversation as resolved.
Resolved
// 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 ? (
<View style={styles.companionAvatarRow} testID="companion.processRow">
<View style={styles.companionAvatarSlot}>{processAvatar}</View>
<View style={styles.companionAvatarContent}>{node}</View>
</View>
) : alignWithReplies ? (
<View style={styles.companionAvatarInset}>{node}</View>
) : (
node
Expand Down Expand Up @@ -5381,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');
Expand Down Expand Up @@ -5417,7 +5427,7 @@ function SubagentCard({
) : null}
{/* 两级展开(与 WorkGroupCard 同规则):内层子卡保持各自折叠头行,按需下钻。 */}
{item.childItems.map((child) => (
<RenderItemView key={child.key} item={child} actions={actions} />
<RenderItemView key={child.key} item={child} actions={childActions} />
))}
{item.summary ? (
<View style={styles.stackSmall}>
Expand Down
3 changes: 3 additions & 0 deletions docs/design-rules/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -1546,6 +1546,9 @@ and media position (`ui/media-scrubber`, seconds in/out). Native media controls

### 伙伴私聊消息下操作(2026-10-29)

伙伴私聊的工作过程区(运行中与完成后)和最终回复各显示一个当前伙伴头像,三端一致。
复用回复头像的尺寸、间距和正文列;展开的中间文字与工具明细不重复头像,成果卡仍与正文对齐。

常用操作固定在消息下方,复用现有群聊图标、引用预览与输入组件。桌面32px按钮、4px间隔、14px图标;手机44pt热区、不额外插空,左右消息的操作图形对齐对应正文边缘。复制/回复/更多常显,现有朗读与编辑能力保留;创建话题与分享图片放更多。已有话题沿用头像、回复数及最后回复时间/未读提示入口,不另造数字徽章。群聊操作及主对话914px宽度规则不变,手机正文直接选字不受操作栏影响。沿用语义色与原菜单开合、焦点和按压状态;各语言及明暗模式均不得溢出。

### 伙伴内部通讯入口(2026-11-01)
Expand Down
Loading