diff --git a/docs/issues/merged-activity-groups/plan.md b/docs/issues/merged-activity-groups/plan.md new file mode 100644 index 000000000..7604f63b7 --- /dev/null +++ b/docs/issues/merged-activity-groups/plan.md @@ -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`. diff --git a/docs/issues/merged-activity-groups/spec.md b/docs/issues/merged-activity-groups/spec.md new file mode 100644 index 000000000..feb93f4bc --- /dev/null +++ b/docs/issues/merged-activity-groups/spec.md @@ -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. diff --git a/docs/issues/merged-activity-groups/tasks.md b/docs/issues/merged-activity-groups/tasks.md new file mode 100644 index 000000000..f45478f7a --- /dev/null +++ b/docs/issues/merged-activity-groups/tasks.md @@ -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. diff --git a/src/renderer/src/components/message/MessageBlockActivityGroup.vue b/src/renderer/src/components/message/MessageBlockActivityGroup.vue index 854708d5e..93e9949ca 100644 --- a/src/renderer/src/components/message/MessageBlockActivityGroup.vue +++ b/src/renderer/src/components/message/MessageBlockActivityGroup.vue @@ -1,40 +1,56 @@ diff --git a/src/renderer/src/components/message/messageActivityGroups.ts b/src/renderer/src/components/message/messageActivityGroups.ts index caf6e3395..aa1bf6be3 100644 --- a/src/renderer/src/components/message/messageActivityGroups.ts +++ b/src/renderer/src/components/message/messageActivityGroups.ts @@ -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 @@ -144,6 +148,10 @@ export const buildAssistantRenderItems = ({ return } + if (shouldGroup && isEmptyReasoningBlock(block)) { + return + } + if (shouldGroup && isCompletedActivityBlock(block)) { activityBuffer.push({ block, index }) return diff --git a/test/renderer/components/message/MessageBlockActivityGroup.test.ts b/test/renderer/components/message/MessageBlockActivityGroup.test.ts index 51b710c5e..ed8ed85a8 100644 --- a/test/renderer/components/message/MessageBlockActivityGroup.test.ts +++ b/test/renderer/components/message/MessageBlockActivityGroup.test.ts @@ -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 () => { @@ -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') @@ -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 () => { diff --git a/test/renderer/components/message/messageActivityGroups.test.ts b/test/renderer/components/message/messageActivityGroups.test.ts index b898e1b86..de3b68239 100644 --- a/test/renderer/components/message/messageActivityGroups.test.ts +++ b/test/renderer/components/message/messageActivityGroups.test.ts @@ -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',