展示:可锚定块带上稳定块身份 data-block-key
DirectProjectTurn 给正文块写 data-block-key={block.key},「执行过程」包装块与终态文案各给一个回合内唯一的字面量块身份。
ToolCallGroup 与 AgentReasoning 新增可选 blockKey 并透传到 data-block-key,turnKey 的注释改成「按回合 key + 块身份定位」。
组件用例断言每个可锚定块都有块身份且同回合内唯一,并用 running→finished 前后对照钉住「块序号会整体后移、块身份不变」。
Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
+9
-2
@@ -19,15 +19,21 @@ function AgentReasoningImpl({
|
||||
label = '思考过程',
|
||||
testId,
|
||||
turnKey,
|
||||
blockKey,
|
||||
}: {
|
||||
text: string;
|
||||
label?: string;
|
||||
testId?: string;
|
||||
/**
|
||||
* 所属回合的 `DirectChatTurn.key`:写进 `data-turn-key`,供会话滚动的前插锚点按
|
||||
* 「回合 key + 块序号」定位这一块(见 `DirectProjectConversation/conversationScrollAnchor.ts`)。
|
||||
* 所属回合的 `DirectChatTurn.key`:写进 `data-turn-key`,供会话滚动的前插锚点定位这一块
|
||||
* (见 `DirectProjectConversation/conversationScrollAnchor.ts`)。
|
||||
*/
|
||||
turnKey?: string;
|
||||
/**
|
||||
* 这一块的稳定块身份(`DirectChatBlock.key`):写进 `data-block-key`。锚点按「回合 key +
|
||||
* 块身份」定位,不按块序号——回合收口时过程块会被折进 `<details>`,序号会整体后移。
|
||||
*/
|
||||
blockKey?: string;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
/*
|
||||
@@ -44,6 +50,7 @@ function AgentReasoningImpl({
|
||||
aria-label={label}
|
||||
data-testid={testId}
|
||||
data-turn-key={turnKey}
|
||||
data-block-key={blockKey}
|
||||
onToggle={(event) =>
|
||||
setExpanded((event.currentTarget as HTMLDetailsElement).open)
|
||||
}
|
||||
|
||||
+83
@@ -2,6 +2,7 @@
|
||||
import { act, cleanup, fireEvent, render } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { DirectChatToolCard } from '../../conversation/directThreadChat';
|
||||
import type { DirectChatTurn } from '../../conversation/directTurnPresentation';
|
||||
import { DirectProjectConversation } from './DirectProjectConversation';
|
||||
|
||||
@@ -15,6 +16,47 @@ const turn = (key: string): DirectChatTurn => ({
|
||||
endedAt: 20,
|
||||
});
|
||||
|
||||
/** 一个工具调用的持久化卡片:块身份来自条目 itemId,这里只要能渲染出来。 */
|
||||
const toolCall = (id: string): DirectChatToolCard => ({
|
||||
schemaVersion: 'agc-tool-call.v1',
|
||||
id,
|
||||
kind: 'command',
|
||||
title: '执行命令',
|
||||
summary: 'npm run build',
|
||||
status: 'completed',
|
||||
detail: { command: 'npm run build' },
|
||||
startedAt: 10,
|
||||
updatedAt: 20,
|
||||
});
|
||||
|
||||
/**
|
||||
* 带执行过程的回合:`renderTurnProcess` 对运行中的回合把过程块平铺、对已结束的回合把它们折进一个
|
||||
* `<details>`,所以「收口前后同一个块的块身份是否跟着块走」只能拿这种回合验。
|
||||
*/
|
||||
const processTurn = (
|
||||
state: DirectChatTurn['state'],
|
||||
overrides: Partial<DirectChatTurn> = {},
|
||||
): DirectChatTurn => ({
|
||||
key: 't1',
|
||||
users: [{ kind: 'user', key: 't1:u', text: '做一个跳跃动作', at: 10 }],
|
||||
process: [
|
||||
{ kind: 'tools', key: 't1:g1', calls: [toolCall('c1')] },
|
||||
{ kind: 'tools', key: 't1:g2', calls: [toolCall('c2')] },
|
||||
],
|
||||
finals: [{ kind: 'assistant', key: 't1:f', text: '改好了', at: 20 }],
|
||||
state,
|
||||
startedAt: 10,
|
||||
endedAt: state === 'finished' ? 20 : 0,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/** 列表里所有块的块身份,按文档顺序;拿不到 `data-block-key` 的块是 null。 */
|
||||
function blockKeysOf(container: HTMLElement): (string | null)[] {
|
||||
return Array.from(container.querySelectorAll('[data-turn-key]')).map(
|
||||
(block) => block.getAttribute('data-block-key'),
|
||||
);
|
||||
}
|
||||
|
||||
function conversationElement(overrides: {
|
||||
conversationKey?: string;
|
||||
turns?: DirectChatTurn[];
|
||||
@@ -103,6 +145,47 @@ describe('前插锚点的标记', () => {
|
||||
view.container.querySelectorAll('[data-turn-key="t1"]').length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('每个可锚定的块都带上 data-block-key,且同一回合内唯一', () => {
|
||||
const view = renderConversation({ turns: [processTurn('finished')] });
|
||||
const keys = blockKeysOf(view.container);
|
||||
|
||||
// 正文块用 DirectChatBlock.key,过程包装块与终态文案各有自己的字面量块身份。
|
||||
expect(keys).toContain('t1:u');
|
||||
expect(keys).toContain('t1:g1');
|
||||
expect(keys).toContain('t1:g2');
|
||||
expect(keys).toContain('t1:f');
|
||||
expect(keys).toContain('process');
|
||||
expect(keys).toContain('usage');
|
||||
// 缺块身份的块会被锚点静默跳过:宁可这里红,也不要在滚动时才发现。
|
||||
expect(keys.every((key) => key !== null && key.length > 0)).toBe(true);
|
||||
expect(new Set(keys).size).toBe(keys.length);
|
||||
});
|
||||
|
||||
it('回合收口后块身份跟着块走:块序号变了,块身份不变', () => {
|
||||
const view = renderConversation({ turns: [processTurn('running')] });
|
||||
// 运行中:过程块平铺,没有「执行过程」包装块。
|
||||
expect(blockKeysOf(view.container)).toEqual([
|
||||
't1:u',
|
||||
't1:g1',
|
||||
't1:g2',
|
||||
't1:f',
|
||||
]);
|
||||
|
||||
view.rerender(conversationElement({ turns: [processTurn('finished')] }));
|
||||
|
||||
// 收口后过程被折进一个 `<details>`,同一个工具组的序号从 1 变成 3——旧锚点存的是序号,
|
||||
// 会解析到隔壁块;块身份仍然只属于它自己。
|
||||
const keys = blockKeysOf(view.container);
|
||||
expect(keys).toEqual([
|
||||
't1:u',
|
||||
'process',
|
||||
't1:g1',
|
||||
't1:g2',
|
||||
't1:f',
|
||||
'usage',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('换会话的复位方式', () => {
|
||||
|
||||
+19
-1
@@ -30,6 +30,12 @@ import {
|
||||
* - **肯定式**(哪一段正文在流式、"正在处理"这类断言)读 `state === 'running'`:
|
||||
* 只有宿主开始事件到了才能这么说。
|
||||
*/
|
||||
/** 「执行过程」包装块的块身份:只在同一回合内要求唯一,与条目块的 `${回合 key}:${条目 id}` 不会撞。 */
|
||||
const PROCESS_BLOCK_KEY = 'process';
|
||||
|
||||
/** 终态文案的块身份:同上,同一个回合里只出现一次。 */
|
||||
const USAGE_BLOCK_KEY = 'usage';
|
||||
|
||||
export function DirectProjectTurn({ turn }: { turn: DirectChatTurn }) {
|
||||
const streamingKey =
|
||||
turn.state === 'running'
|
||||
@@ -50,7 +56,14 @@ export function DirectProjectTurn({ turn }: { turn: DirectChatTurn }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** 运行中的回合把执行过程平铺,已结束的回合折叠进「执行过程」。 */
|
||||
/**
|
||||
* 运行中的回合把执行过程平铺,已结束的回合折叠进「执行过程」。
|
||||
*
|
||||
* 两种结构下每个块都带 `data-block-key`:会话滚动的前插锚点按「回合 key + 块身份」定位,
|
||||
* 不按块序号——收口时过程块被折进 `<details>`,块序号会整体后移(见
|
||||
* `conversationScrollAnchor.ts` 与 `docs/adr/【ADR】DirectProject对话滚动与历史自动加载-2026-10-02.md`)。
|
||||
* 「执行过程」包装块与终态文案没有 `DirectChatBlock`,各用一个回合内唯一的字面量块身份。
|
||||
*/
|
||||
function renderTurnProcess(turn: DirectChatTurn, streamingKey: string | null) {
|
||||
if (turn.process.length === 0) return null;
|
||||
const blocks = turn.process.map((block) =>
|
||||
@@ -62,6 +75,7 @@ function renderTurnProcess(turn: DirectChatTurn, streamingKey: string | null) {
|
||||
className="message-turn-process"
|
||||
data-testid="turn-process"
|
||||
data-turn-key={turn.key}
|
||||
data-block-key={PROCESS_BLOCK_KEY}
|
||||
>
|
||||
<summary>执行过程</summary>
|
||||
<div className="message-turn-process-body">{blocks}</div>
|
||||
@@ -83,6 +97,7 @@ function DirectProjectTurnUsage({ turn }: { turn: DirectChatTurn }) {
|
||||
data-testid="turn-usage"
|
||||
data-turn-running="false"
|
||||
data-turn-key={turn.key}
|
||||
data-block-key={USAGE_BLOCK_KEY}
|
||||
>
|
||||
{`本轮结束于 ${new Date(endedAt).toLocaleTimeString('zh-CN', {
|
||||
hour12: false,
|
||||
@@ -105,6 +120,7 @@ function renderBlock(
|
||||
active={turn.state === 'running'}
|
||||
className="message-tool-call"
|
||||
turnKey={turn.key}
|
||||
blockKey={block.key}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -115,6 +131,7 @@ function renderBlock(
|
||||
text={block.text}
|
||||
label="思考过程"
|
||||
turnKey={turn.key}
|
||||
blockKey={block.key}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -124,6 +141,7 @@ function renderBlock(
|
||||
key={block.key}
|
||||
className={`message message--${role}`}
|
||||
data-turn-key={turn.key}
|
||||
data-block-key={block.key}
|
||||
>
|
||||
<AgentMessageContent tone={tone}>
|
||||
<ChatMarkdownMessage
|
||||
|
||||
+9
-2
@@ -43,6 +43,7 @@ export function ToolCallGroup({
|
||||
active = false,
|
||||
className,
|
||||
turnKey,
|
||||
blockKey,
|
||||
}: {
|
||||
calls: DirectChatToolCard[];
|
||||
/**
|
||||
@@ -53,10 +54,15 @@ export function ToolCallGroup({
|
||||
active?: boolean;
|
||||
className?: string;
|
||||
/**
|
||||
* 所属回合的 `DirectChatTurn.key`:写进 `data-turn-key`,供会话滚动的前插锚点按
|
||||
* 「回合 key + 块序号」定位这一块(见 `DirectProjectConversation/conversationScrollAnchor.ts`)。
|
||||
* 所属回合的 `DirectChatTurn.key`:写进 `data-turn-key`,供会话滚动的前插锚点定位这一块
|
||||
* (见 `DirectProjectConversation/conversationScrollAnchor.ts`)。
|
||||
*/
|
||||
turnKey?: string;
|
||||
/**
|
||||
* 这一块的稳定块身份(`DirectChatBlock.key`):写进 `data-block-key`。锚点按「回合 key +
|
||||
* 块身份」定位,不按块序号——回合收口时过程块会被折进 `<details>`,序号会整体后移。
|
||||
*/
|
||||
blockKey?: string;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const bodyId = useId();
|
||||
@@ -105,6 +111,7 @@ export function ToolCallGroup({
|
||||
data-testid="agent-tool-call-group"
|
||||
aria-label="陶泥儿执行过程"
|
||||
data-turn-key={turnKey}
|
||||
data-block-key={blockKey}
|
||||
data-status={status}
|
||||
data-has-failure={hasFailure ? 'true' : undefined}
|
||||
data-duration-ms={timing.durationMs ?? ''}
|
||||
|
||||
Reference in New Issue
Block a user