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
32 changes: 32 additions & 0 deletions docs/issues/merged-activity-groups/plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Plan

## Current Behavior

`buildAssistantRenderItems` buffers completed reasoning and tool-call blocks while grouping is
enabled. The buffer flushes when a block is not completed activity.

In affected histories, an empty `reasoning_content` block with provider metadata can sit between
visible reasoning and tool-call blocks. Because that empty reasoning block is not visible but still
flushes the activity buffer, one continuous assistant work span becomes several activity summaries.

The compact summary details are currently hidden with `v-show`, so expansion changes the detail body
from `display: none` to normal layout in one frame. Long merged activity histories can make the
message list jump visibly while row measurement catches up.

## Implementation

- Treat empty settled reasoning blocks as invisible metadata blocks for activity grouping.
- Keep buffering visible completed reasoning and tool calls across those ignored metadata blocks.
- Replace the activity group detail `v-show` with an always-mounted transition shell that animates
grid row height and opacity.
- Avoid leaving collapsed spacing behind by moving the body gap to the animated shell's expanded
margin state.
- Mark the collapsed shell inert so mounted hidden controls cannot receive focus or pointer input.
- Add renderer regression coverage around provider signed empty reasoning blocks.
- Update the activity group component test to assert accessible collapsed state and mounted details
rather than relying on `display: none` visibility.

## Validation

- Run focused renderer tests for message activity grouping.
- Run repository-required quality gates: `pnpm run format`, `pnpm run i18n`, and `pnpm run lint`.
42 changes: 42 additions & 0 deletions docs/issues/merged-activity-groups/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Merged Activity Groups

## Problem

Some providers emit an empty `reasoning_content` block carrying provider metadata between visible
reasoning and tool-call blocks. The chat view does not render that empty reasoning block, but the
activity grouping pass currently treats it as a normal boundary. This splits one continuous assistant
work span into several collapsed activity summaries.

## User Story

As a chat user reviewing an imported or previously merged session, I want reasoning and tool-call
activity to stay attached to the correct assistant segment so expanded activity details match the
visible answer they summarize.

## Acceptance Criteria

- Empty reasoning metadata blocks do not split the reasoning/tool-call activity they sit between.
- Consecutive activity blocks within the same assistant segment collapse into one compact summary.
- Internal tool calls remain hidden from the assistant activity list.
- The final assistant text continues to render after its activity summaries.
- Expanding or collapsing the compact activity summary does not hard-toggle the details with
`display: none`; details remain mounted and use a bounded height/opacity transition to reduce
scroll and layout jitter. Collapsed details are not pointer- or keyboard-interactive.
- The compact activity summary keeps the chevron and title close enough to read as one control.
- A regression test covers the MiniMax-style sequence: visible reasoning, empty signed reasoning,
tool call, next visible reasoning, empty signed reasoning, next tool call.
- A component test covers the collapsed and expanded activity detail states after the transition
wrapper change.

## Non-goals

- Redesign activity group copy, visual hierarchy, or per-block detail components.
- Change session storage or import schema.
- Change the content of reasoning/tool-call blocks.

## Constraints

- Keep the fix scoped to renderer-side render item construction unless investigation shows the source
data is malformed.
- Do not introduce new user-facing strings.
- Follow existing message rendering and test patterns.
9 changes: 9 additions & 0 deletions docs/issues/merged-activity-groups/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Tasks

- [x] Trace assistant block metadata and existing grouping tests.
- [x] Add a regression test for empty signed reasoning blocks between reasoning and tool calls.
- [x] Update activity grouping to honor segment boundaries.
- [x] Smooth the activity group detail expand/collapse path to reduce layout jitter.
- [x] Update component assertions for the always-mounted transition shell.
- [x] Run focused tests and required quality gates.
- [x] Tighten the activity summary chevron/title spacing.
60 changes: 38 additions & 22 deletions src/renderer/src/components/message/MessageBlockActivityGroup.vue
Original file line number Diff line number Diff line change
@@ -1,40 +1,56 @@
<template>
<div class="flex flex-col w-full gap-1.5" data-testid="activity-group">
<div class="flex flex-col w-full" data-testid="activity-group">
<button
type="button"
data-testid="activity-group-toggle"
class="inline-flex max-w-full min-w-0 items-center gap-[10px] self-start text-xs leading-4 text-[rgba(37,37,37,0.5)] dark:text-white/50 select-none rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
class="inline-flex max-w-full min-w-0 items-center gap-1 self-start text-xs leading-4 text-[rgba(37,37,37,0.5)] dark:text-white/50 select-none rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
:aria-expanded="isExpanded"
:aria-label="toggleLabel"
@click="toggleExpanded"
>
<Icon
:icon="isExpanded ? 'lucide:chevron-down' : 'lucide:chevron-right'"
class="w-[14px] h-[14px] shrink-0 text-[rgba(37,37,37,0.5)] dark:text-white/50"
icon="lucide:chevron-right"
class="w-[14px] h-[14px] shrink-0 text-[rgba(37,37,37,0.5)] dark:text-white/50 transition-transform duration-[var(--dc-motion-fast)] ease-[var(--dc-ease-out-soft)] motion-reduce:transition-none"
:class="isExpanded ? 'rotate-90' : 'rotate-0'"
/>
<span class="min-w-0 truncate">
{{ titleText }}
</span>
</button>

<div v-show="isExpanded" class="flex flex-col w-full gap-1.5" data-testid="activity-group-body">
<template v-for="(block, index) in blocks" :key="buildActivityBlockKey(block, index)">
<MessageBlockThink
v-if="
(block.type === 'reasoning_content' || block.type === 'artifact-thinking') &&
block.content
"
:block="block"
:usage="usage"
@toggle-collapse="handleChildCollapseToggle"
/>
<MessageBlockToolCall
v-else-if="block.type === 'tool_call'"
:block="block"
:message-id="messageId"
:thread-id="threadId"
/>
</template>
<div
class="grid w-full overflow-hidden transition-[grid-template-rows,opacity,margin-top] duration-[var(--dc-motion-default)] ease-[var(--dc-ease-out-express)] motion-reduce:transition-none"
:class="
isExpanded
? 'mt-1.5 grid-rows-[1fr] opacity-100'
: 'mt-0 grid-rows-[0fr] opacity-0 pointer-events-none'
"
:aria-hidden="!isExpanded"
:inert="isExpanded ? undefined : true"
data-testid="activity-group-body-shell"
>
<div
class="min-h-0 flex flex-col w-full gap-1.5 overflow-hidden"
data-testid="activity-group-body"
>
<template v-for="(block, index) in blocks" :key="buildActivityBlockKey(block, index)">
<MessageBlockThink
v-if="
(block.type === 'reasoning_content' || block.type === 'artifact-thinking') &&
block.content
"
:block="block"
:usage="usage"
@toggle-collapse="handleChildCollapseToggle"
/>
<MessageBlockToolCall
v-else-if="block.type === 'tool_call'"
:block="block"
:message-id="messageId"
:thread-id="threadId"
/>
</template>
</div>
</div>
</div>
</template>
Expand Down
8 changes: 8 additions & 0 deletions src/renderer/src/components/message/messageActivityGroups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ const isReasoningActivityBlock = (block: DisplayAssistantMessageBlock): boolean
typeof block.content === 'string' &&
block.content.trim().length > 0

const isEmptyReasoningBlock = (block: DisplayAssistantMessageBlock): boolean =>
(block.type === 'reasoning_content' || block.type === 'artifact-thinking') &&
(typeof block.content !== 'string' || block.content.trim().length === 0)

export const isCompletedActivityBlock = (block: DisplayAssistantMessageBlock): boolean => {
if (!ACTIVITY_BLOCK_TYPES.has(block.type)) {
return false
Expand Down Expand Up @@ -144,6 +148,10 @@ export const buildAssistantRenderItems = ({
return
}

if (shouldGroup && isEmptyReasoningBlock(block)) {
return
}

if (shouldGroup && isCompletedActivityBlock(block)) {
activityBuffer.push({ block, index })
return
Expand Down
28 changes: 26 additions & 2 deletions test/renderer/components/message/MessageBlockActivityGroup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,17 @@ describe('MessageBlockActivityGroup', () => {
expect(wrapper.get('[data-testid="activity-group-toggle"]').attributes('aria-expanded')).toBe(
'false'
)
expect(wrapper.get('[data-testid="activity-group-body"]').isVisible()).toBe(false)
expect(wrapper.get('[data-testid="activity-group-body-shell"]').attributes('aria-hidden')).toBe(
'true'
)
expect(
wrapper.get('[data-testid="activity-group-body-shell"]').attributes('inert')
).toBeDefined()
expect(wrapper.get('[data-testid="activity-group-body-shell"]').classes()).toContain(
'grid-rows-[0fr]'
)
expect(wrapper.find('[data-testid="think-block"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="tool-block"]').exists()).toBe(true)
})

it('toggles expanded state and shows the original activity blocks', async () => {
Expand All @@ -134,7 +144,15 @@ describe('MessageBlockActivityGroup', () => {
expect(wrapper.get('[data-testid="activity-group-toggle"]').attributes('aria-expanded')).toBe(
'true'
)
expect(wrapper.get('[data-testid="activity-group-body"]').isVisible()).toBe(true)
expect(wrapper.get('[data-testid="activity-group-body-shell"]').attributes('aria-hidden')).toBe(
'false'
)
expect(
wrapper.get('[data-testid="activity-group-body-shell"]').attributes('inert')
).toBeUndefined()
expect(wrapper.get('[data-testid="activity-group-body-shell"]').classes()).toContain(
'grid-rows-[1fr]'
)
expect(wrapper.find('[data-testid="think-block"]').text()).toBe('thinking')
expect(wrapper.find('[data-testid="tool-block"]').text()).toBe('shell_command')

Expand All @@ -143,6 +161,12 @@ describe('MessageBlockActivityGroup', () => {
expect(wrapper.get('[data-testid="activity-group-toggle"]').attributes('aria-expanded')).toBe(
'false'
)
expect(wrapper.get('[data-testid="activity-group-body-shell"]').attributes('aria-hidden')).toBe(
'true'
)
expect(
wrapper.get('[data-testid="activity-group-body-shell"]').attributes('inert')
).toBeDefined()
})

it('does not persist expanded state across remounts', async () => {
Expand Down
84 changes: 84 additions & 0 deletions test/renderer/components/message/messageActivityGroups.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,90 @@ describe('messageActivityGroups', () => {
expect(items.map((item) => item.kind)).toEqual(['activity-group', 'block', 'activity-group'])
})

it('ignores empty reasoning signature blocks when merging continuous activity', () => {
const items = buildAssistantRenderItems({
messageId: 'm1',
messageUpdatedAt: 12_000,
shouldGroup: true,
blocks: [
createBlock('reasoning_content', {
content: 'The user wants to see files.',
timestamp: 1_000
}),
createBlock('reasoning_content', {
content: '',
timestamp: 1_100,
extra: {
providerOptionsJson: '{"anthropic":{"signature":"sig-1"}}'
}
}),
createBlock('tool_call', {
timestamp: 2_000,
tool_call: {
id: 'tc1',
name: 'exec'
}
}),
createBlock('reasoning_content', {
content: 'The working directory does not exist.',
timestamp: 3_000
}),
createBlock('reasoning_content', {
content: '',
timestamp: 3_100,
extra: {
providerOptionsJson: '{"anthropic":{"signature":"sig-2"}}'
}
}),
createBlock('tool_call', {
timestamp: 4_000,
tool_call: {
id: 'tc2',
name: 'exec'
}
}),
createBlock('reasoning_content', {
content: 'I should ask the user to confirm the workspace.',
timestamp: 5_000
}),
createBlock('reasoning_content', {
content: '',
timestamp: 5_100,
extra: {
providerOptionsJson: '{"anthropic":{"signature":"sig-3"}}'
}
}),
createBlock('content', {
content: 'Please confirm the folder.',
timestamp: 6_000
})
]
})

expect(items.map((item) => item.kind)).toEqual(['activity-group', 'block'])

const groups = items.filter((item) => item.kind === 'activity-group')
expect(groups).toHaveLength(1)
expect(groups.map((item) => item.blocks.map((block) => block.type))).toEqual([
['reasoning_content', 'tool_call', 'reasoning_content', 'tool_call', 'reasoning_content']
])
expect(
groups.map((item) => item.blocks.map((block) => block.tool_call?.id ?? block.content))
).toEqual([
[
'The user wants to see files.',
'tc1',
'The working directory does not exist.',
'tc2',
'I should ask the user to confirm the workspace.'
]
])
expect(groups[0]).toMatchObject({
reasoningCount: 3,
toolCallCount: 2
})
})

it('does not group when the turn is not settled', () => {
const items = buildAssistantRenderItems({
messageId: 'm1',
Expand Down