Group Display — Collapsible Tool Steps
Hướng dẫn implement UI group cho tool calls: collapsible header, auto-collapse khi done, streaming max 3 items, interrupt handling.
Cấu Trúc Flat Block
Cả History API lẫn Stream đều trả flat blocks — mỗi message/event là 1 block riêng biệt, không nested. Frontend scan tuần tự và group theo markers.
Nguyên tắc
- Backend split assistant messages phức tạp (thinking + text + tool_calls) thành nhiều flat messages
- Mỗi message chỉ chứa 1 loại content chính (text HOẶC thinking HOẶC tool_calls)
display_typetrên mỗi message cho biết vai trò:content,group_start,group_item,group_end- Frontend KHÔNG cần logic phức tạp — chỉ scan flat array, if/else theo
display_type
Ví dụ: 1 AIMessage gốc có cả thinking + text + tool_calls
Backend split thành 3 flat messages:
// Message 1: text (share_thought) → standalone
{
"role": "assistant",
"message_type": "chat",
"content": [{"type": "text", "text": "Để mình kiểm tra...", "is_part": true}],
"display_type": "content"
}
// Message 2: tool_calls → group
{
"role": "assistant",
"message_type": "step",
"content": [],
"tool_calls": [
{"id": "tc-1", "name": "analyze_price", "tool_content_message": "Phân tích giá VNINDEX"}
],
"display_type": "group_start",
"summary": "Phân tích giá VNINDEX"
}
// Message 3: tool result → group item
{
"role": "tool",
"tool_call_id": "tc-1",
"name": "analyze_price",
"status": "success",
"tool_content_message": "Phân tích giá VNINDEX",
"display_type": "group_item"
}
Streaming cũng flat
Stream events tuần tự — group_start / group_end là event riêng xen giữa content_block_*:
content_block_start (text) → standalone
content_block_stop
group_start → bắt đầu group
content_block_start (tool_use)
content_block_stop
content_block_start (tool_result)
content_block_stop
group_end → kết thúc group
content_block_start (text) → standalone (final answer)
message_type field
"chat"— message đầu tiên từ AI trong 1 turn (text chào, share_thought)"step"— các messages tiếp theo (tool calls, intermediate results)
Chỉ message chat đầu tiên hiển thị brand header (logo + tên agent).
Tổng Quan
Agent thực hiện nhiều tool calls (search, analyze, read_file...) trong 1 lượt. Các tool steps được group lại thành 1 khối collapsible để UI gọn gàng.
Tìm kiếm thông tin thị trường ˅ ← group header (collapsed)
● Phân tích kỹ thuật VNINDEX
● Lấy dữ liệu giá và thanh khoản
● Tìm kiếm tin tức mới nhất
✓ Hoàn thành
Events từ Backend
group_start
Bắt đầu 1 nhóm steps mới.
{
"type": "group_start",
"index": 5,
"message_id": "msg-uuid"
}
group_end
Kết thúc nhóm, kèm summary.
{
"type": "group_end",
"index": 12,
"summary": "Tìm kiếm thông tin thị trường phiên 25/03/2026",
"message_id": "msg-uuid"
}
Blocks bên trong group
Giữa group_start và group_end, các events bình thường:
group_start
content_block_start (tool_use: write_todos)
content_block_stop
content_block_start (tool_use: web_search)
content_block_stop
content_block_start (tool_result: write_todos)
content_block_stop
content_block_start (tool_result: web_search)
content_block_stop
group_end
History API — display_type
History API trả messages với display_type field:
| Value | Ý nghĩa |
|---|---|
content | Text hoặc user message — hiển thị trực tiếp |
group_start | Bắt đầu group, có summary field |
group_item | Item bên trong group |
group_end | Kết thúc group, có summary field |
group_closed — Group 1 item (self-closing)
Khi group chỉ có 1 message (ví dụ: thinking đơn lẻ), backend không thể set cả group_start và group_end trên cùng 1 message. Thay vào đó:
{
"role": "assistant",
"content": [
{
"type": "thinking",
"thinking": "..."
}
],
"display_type": "group_start",
"summary": "Suy nghĩ",
"group_closed": true
}
group_closed: true = group đã hoàn thành, chỉ có 1 item.
Frontend xử lý: khi gặp group_start với group_closed: true, emit thêm group_end ngay sau content blocks:
if (displayType === 'group_start') {
blocks.push({ type: 'group_start', summary: msg.summary });
blocks.push(...parseContent(msg));
if (msg.group_closed) {
blocks.push({ type: 'group_end', summary: msg.summary });
}
}
Kết quả: group render collapsible, auto-collapsed (done), có "Hoàn thành".
Suy nghĩ > ← collapsed, 1 item inside
thinking content...
✓ Hoàn thành
Example History Response
[
{
"role": "user",
"content": [
{
"type": "text",
"text": "thị trường hôm nay"
}
],
"display_type": "content"
},
{
"role": "assistant",
"content": [
{
"type": "text",
"text": "Chào Thảo! Chờ mình cập nhật nhé.",
"is_part": true
}
],
"display_type": "content"
},
{
"role": "assistant",
"tool_calls": [
{
"id": "tc-1",
"name": "write_todos",
"tool_content_message": "Lập kế hoạch phân tích"
},
{
"id": "tc-2",
"name": "analyze_price",
"tool_content_message": "Phân tích giá VNINDEX"
}
],
"display_type": "group_start",
"summary": "Phân tích giá VNINDEX"
},
{
"role": "tool",
"tool_call_id": "tc-1",
"name": "write_todos",
"status": "success",
"tool_content_message": "Lập kế hoạch phân tích",
"display_type": "group_item"
},
{
"role": "tool",
"tool_call_id": "tc-2",
"name": "analyze_price",
"status": "success",
"tool_content_message": "Phân tích giá VNINDEX",
"display_type": "group_end",
"summary": "Phân tích giá VNINDEX"
},
{
"role": "assistant",
"content": [
{
"type": "text",
"text": "VNINDEX hôm nay tăng 2.69%...",
"is_final": true
}
],
"display_type": "content"
}
]
Frontend Implementation
1. Grouping Algorithm
Scan blocks tuần tự, group bằng group_start / group_end:
function buildGroups(blocks, isStreaming) {
const items = [];
let currentGroup = null;
for (const block of blocks) {
if (block.type === 'group_start') {
if (currentGroup) items.push(currentGroup);
currentGroup = { type: 'group', blocks: [], summary: block.summary || '', isOpen: true };
continue;
}
if (block.type === 'group_end') {
if (currentGroup) {
currentGroup.summary = block.summary || currentGroup.summary;
currentGroup.isOpen = false; // done
items.push(currentGroup);
currentGroup = null;
}
continue;
}
if (block.type === 'user' || (block.type === 'text' && !block.isPart)) {
if (currentGroup) { items.push(currentGroup); currentGroup = null; }
items.push(block);
continue;
}
// Other blocks: tool_use, tool_result, approval_request (non-question) stay in group
if (currentGroup) {
currentGroup.blocks.push(block);
} else {
items.push(block);
}
}
// Last group: open if streaming, closed if not
if (currentGroup) {
if (!isStreaming) currentGroup.isOpen = false;
items.push(currentGroup);
}
return items;
}
2. Group Component
┌─────────────────────────────────────────────┐
│ Phân tích giá VNINDEX > │ ← header (click toggle)
│ │
│ ● Lập kế hoạch phân tích │ ← tool items
│ ● Phân tích giá VNINDEX 10 kết quả │
│ ✓ Hoàn thành │ ← done indicator
└─────────────────────────────────────────────┘
States
| State | isOpen | Header | Items | Done indicator |
|---|---|---|---|---|
| Streaming (running) | true | Shimmer text + arrow ˅ | Max 3, slide mới lên | Không |
| Done (collapsed) | false | Static text + arrow > | Ẩn | Ẩn |
| Done (expanded) | false | Static text + arrow ˅ | Tất cả items | "✓ Hoàn thành" |
Behavior
- Streaming: expanded mặc định, max 3 items visible, items cũ slide lên khi có mới
group_endnhận: auto-collapse sau 300ms- Click header: toggle expand/collapse
- Summary: lấy từ
group_end.summary. Khi streaming, scan blocks ngược tìmtool_content_message
Pseudocode
function GroupBlock({ group, isStreaming }) {
const [isExpanded, setIsExpanded] = useState(group.isOpen);
const isDone = !group.isOpen;
const MAX_VISIBLE = 3;
// Auto-collapse when done
useEffect(() => {
if (wasPreviouslyOpen && isDone) {
setTimeout(() => setIsExpanded(false), 300);
}
}, [isDone]);
// Summary: from group or last tool_content_message
const summary = isDone && group.summary
? group.summary
: findLastToolContentMessage(group.blocks) || 'Đang xử lý...';
// Filter merged blocks (tool_result merged into tool_use)
const visibleBlocks = group.blocks.filter(b => !b._merged);
// Slice: max 3 when running, all when done
const displayBlocks = (isDone || showAll)
? visibleBlocks
: visibleBlocks.slice(-MAX_VISIBLE);
return (
<div>
<Header
summary={summary}
isExpanded={isExpanded}
isStreaming={!isDone}
onClick={() => setIsExpanded(!isExpanded)}
/>
{isExpanded && displayBlocks.map(block => renderBlock(block))}
{isDone && isExpanded && <DoneIndicator />}
</div>
);
}
Streaming Flow
Normal Streaming
Timeline:
message_start
text (share_thought) → render standalone
group_start → open group, show header with shimmer
tool_use #1 → add to group (visible)
tool_use #2 → add to group (visible, #1 slides up if >3)
tool_result #1 → merge into tool_use #1 (dot changes to green)
tool_result #2 → merge into tool_use #2
group_end → auto-collapse after 300ms, show summary
text (final answer) → render standalone
message_stop → done
F5 Reconnect (Agent Running)
1. GET /history → messages with display_type + last_event_id
2. Convert history → blocks (group_start/item/end markers)
3. Subscribe WS with last_event_id
4. Stream replay events AFTER last_event_id
5. Append to same block array → groups continue naturally
History blocks: [user, text, group_start, tool1, tool2] ← group open
Stream blocks: [tool_result1, tool_result2, group_end, text] ← continues group
Combined: [user, text, group_start, tool1, tool2, result1, result2, group_end, text]
Key: last_event_id từ history chính xác → stream replay không overlap, không gap.
F5 Reconnect (Agent Completed)
1. GET /history → full messages, all groups closed
2. No subscribe needed (agent_status != "running")
3. Render history only — all groups collapsed with "Hoàn thành"
Interrupt / Approval
Có 2 loại tool tách khỏi group (display_as_content):
share_thought — Text ngoài group
share_thought convert thành text block, hiển thị trực tiếp ngoài group (không collapsible).
ask_user_question — Question UI ngoài group
ask_user_question tách khỏi group — close group trước, hiển thị question UI ngoài group, tool calls sau đó mở group mới.
group_start
tool_use: write_todos
tool_result: write_todos
group_end ← group close TRƯỚC ask_user_question
approval_request (ask_user_question) ← ngoài group, render question UI
... user chọn option ...
group_start ← group MỚI cho tool calls tiếp theo
tool_use: analyze_price
tool_result: analyze_price
group_end
Config trong tool_stream_config.py:
"ask_user_question": ToolStreamRule(
stream_tool_use=False, # không stream tool_use block
stream_tool_result=False, # không stream tool_result block
display_as_content=True, # tách khỏi group
),
Tool approval thông thường (trong group)
group_start
tool_use: get_portfolio_detail
approval_request ← trong group, render UI approval
... user approve/reject/timeout ...
approval_result
tool_result: get_portfolio_detail
group_end
UI khi chờ Tool Approval
┌─────────────────────────────────────────────┐
│ Xem chi tiết danh mục ˅ │ ← group header (expanded)
│ │
│ ● Xem chi tiết danh mục │ ← tool (pending, yellow dot)
│ │
│ ┌─────────────────────────────────────┐ │
│ │ Cho phép Simplize thực hiện? │ │ ← approval UI
│ │ ○ Xem chi tiết danh mục │ │
│ │ Danh mục: Chiến lược 2026 │ │
│ │ [Xác nhận] [Chỉnh sửa] [Từ chối] │ │
│ └─────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
Sau Approve
┌─────────────────────────────────────────────┐
│ Xem chi tiết danh mục > │ ← collapsed
│ ● Xem chi tiết danh mục (green dot) │
│ ✓ Hoàn thành │
└─────────────────────────────────────────────┘
Sau Reject / Timeout
Tool status = error (red dot). Group close. Agent tiếp tục với logic khác.
F5 khi có Pending Approval
1. GET /history → messages + pending_approval data + agent_status = "running"
2. History có group open (group_start, không group_end)
3. Subscribe WS → stream có approval_request event
4. Render: group mở + approval UI bên trong
5. User approve → stream tiếp: approval_result, tool_result, group_end
6. Group auto-collapse
Tool Content Message
Mỗi tool_use và tool_result có tool_content_message — text mô tả ngắn cho UI.
{
"type": "tool_use",
"name": "web_search",
"tool_content_message": "Tìm kiếm tin tức thị trường mới nhất ngày 25/03/2026"
}
Nếu tool_content_message rỗng, backend fallback: "web_search" → "Web search".
Dùng tool_content_message làm label cho tool item trong group.
Tool Status Dots
| Status | Dot | Ý nghĩa |
|---|---|---|
| Pending (chưa có result) | ● vàng (amber) | Tool đang chạy |
| Success | ● xanh (green) | Tool hoàn thành |
| Error / Cancelled | ● đỏ (red) | Tool lỗi hoặc bị reject |
Xác định status:
tool_usechưa mergetool_result→ pending (vàng)tool_usecótoolResultvàtoolResultError = false→ success (xanh)tool_usecótoolResultvàtoolResultError = true→ error (đỏ)
Merge Tool Blocks
Backend trả tool_use và tool_result riêng. Frontend merge:
// tool_use: { type: "tool_use", id: "tc-1", name: "web_search", ... }
// tool_result: { type: "tool_result", tool_use_id: "tc-1", content: "...", status: "success" }
// Sau merge:
// { type: "tool_use", id: "tc-1", name: "web_search", toolResult: "...", toolResultError: false }
// tool_result block: { _merged: true } → filter khỏi render
Merge bằng tool_use.id === tool_result.tool_use_id.
Checklist Implementation
- Parse
group_start/group_endevents từ stream - Parse
display_typetừ history API - Group algorithm: scan tuần tự, mở/đóng group
- Group component: header + collapsible items
- Shimmer text khi streaming
- Max 3 items visible khi running, slide cũ lên
- Auto-collapse sau 300ms khi
group_end - Click header toggle expand/collapse
- "Hoàn thành" indicator khi done + expanded
- Tool status dots (pending/success/error)
- Merge tool_use + tool_result
- Filter
_mergedblocks - Summary: từ
group_end.summaryhoặc lasttool_content_message - Approval UI trong group
- F5 reconnect: history + stream combined, groups tiếp tục
- F5 completed: all groups collapsed