Chuyển tới nội dung chính

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_type trê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_startgroup_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
contentText hoặc user message — hiển thị trực tiếp
group_startBắt đầu group, có summary field
group_itemItem bên trong group
group_endKế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_startgroup_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

StateisOpenHeaderItemsDone indicator
Streaming (running)trueShimmer text + arrow ˅Max 3, slide mới lênKhông
Done (collapsed)falseStatic text + arrow >ẨnẨn
Done (expanded)falseStatic 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_end nhận: auto-collapse sau 300ms
  • Click header: toggle expand/collapse
  • Summary: lấy từ group_end.summary. Khi streaming, scan blocks ngược tìm tool_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

StatusDotÝ 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_use chưa merge tool_result → pending (vàng)
  • tool_usetoolResulttoolResultError = false → success (xanh)
  • tool_usetoolResulttoolResultError = true → error (đỏ)

Merge Tool Blocks

Backend trả tool_usetool_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_end events từ stream
  • Parse display_type từ 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 _merged blocks
  • Summary: từ group_end.summary hoặc last tool_content_message
  • Approval UI trong group
  • F5 reconnect: history + stream combined, groups tiếp tục
  • F5 completed: all groups collapsed