merge successful tool call + result into one card (T-262)

A successful tool call now renders as a single merged card instead of a
separate call card + result card:

- ConversationCard gains a trailing header status slot (success check /
  error cross, semantics-labelled) and optional labelled extra body
  segments (P1 + P2 — reused by T-263's Agent card).
- conversation_view builds a reverse toolUseId→result lookup, folds a
  successful result into its call card as a colorized ClideCodeBlock
  (Read → file grammar, Bash → shell, else text), and suppresses the
  standalone success result in _visibleItems. Errors stay a separate
  prominent red card, now with a matching red header mark (note C).
- Suppression happens before grouping, so a merged success result is no
  longer a separate activity-cluster step (note D).

Tests: card status-mark + segment coverage, merged-fold + language
inference + in-flight/orphan/error cases, cluster non-double-count, and a
golden for the merged card (collapsed + expanded + error).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-07 19:41:28 +02:00
co-authored by Claude Opus 4.8
parent af23c7809e
commit 9a8f8641f7
9 changed files with 412 additions and 16 deletions
@@ -283,6 +283,68 @@ void main() {
expect(find.text('result'), findsOneWidget);
});
testWidgets('status: success renders a "succeeded" semantics mark in the header (T-262)', (tester) async {
final handle = tester.ensureSemantics();
await tester.pumpWidget(harness(
f,
const ConversationCard(
variant: ConversationCardVariant.bordered,
accent: Color(0xFFFFFFFF),
label: 'Bash',
status: ConversationCardStatus.success,
body: Text('body', textDirection: TextDirection.ltr),
),
));
await tester.pump();
expect(find.bySemanticsLabel('succeeded'), findsOneWidget);
expect(find.bySemanticsLabel('failed'), findsNothing);
handle.dispose();
});
testWidgets('status: error renders a "failed" semantics mark; none renders nothing (T-262)', (tester) async {
final handle = tester.ensureSemantics();
await tester.pumpWidget(harness(
f,
const ConversationCard(
variant: ConversationCardVariant.bordered,
accent: Color(0xFFFFFFFF),
label: 'Bash',
status: ConversationCardStatus.error,
body: Text('body', textDirection: TextDirection.ltr),
),
));
await tester.pump();
expect(find.bySemanticsLabel('failed'), findsOneWidget);
expect(find.bySemanticsLabel('succeeded'), findsNothing);
handle.dispose();
});
testWidgets('extraSegments render below the body with their sub-label when expanded (T-262)', (tester) async {
await tester.pumpWidget(harness(
f,
const ConversationCard(
variant: ConversationCardVariant.bordered,
accent: Color(0xFFFFFFFF),
label: 'Read',
collapsible: true,
collapsedByDefault: true,
body: Text('the call', textDirection: TextDirection.ltr),
extraSegments: [CardSegment(label: 'result', child: Text('the output', textDirection: TextDirection.ltr))],
),
));
await tester.pump();
// Collapsed: neither the body nor the segment shows.
expect(find.text('the call'), findsNothing);
expect(find.text('result'), findsNothing);
expect(find.text('the output'), findsNothing);
await tester.tap(find.bySemanticsLabel('Expand'));
await tester.pump();
expect(find.text('the call'), findsOneWidget); // primary body
expect(find.text('result'), findsOneWidget); // segment sub-label
expect(find.text('the output'), findsOneWidget); // segment child
});
testWidgets('bare variant renders content without frame decoration', (tester) async {
await tester.pumpWidget(harness(
f,
@@ -177,7 +177,9 @@ void main() {
tester,
[
_tool('Bash', const {'command': 'echo hi'}),
_result('hi'),
// A second, distinct in-flight tool call (T-262 folds a success
// result into its call card, so two *calls* are what make 2 steps).
AssistantToolUse(uuid: 'tu2', timestamp: _t, isSidechain: false, toolUseId: 'x2', name: 'Read', input: const {'file_path': '/a'}),
],
foldLevel: FoldLevel.tools);
// Collapsed by default: one card with a step count, not the raw rows.
@@ -189,6 +191,21 @@ void main() {
expect(find.bySemanticsLabel('Activity, 2 steps, expanded'), findsOneWidget);
});
testWidgets('a folded success result is not a separate step — merged into its call (T-262 note D)', (tester) async {
await pumpWith(
tester,
[
_tool('Bash', const {'command': 'echo hi'}),
_result('hi there'),
],
foldLevel: FoldLevel.tools);
// The call + its success result is ONE unit now: 1 step, not 2.
expect(find.text('1 step'), findsOneWidget);
expect(find.text('2 steps'), findsNothing);
// The result is not double-rendered: no standalone result card.
expect(find.text('Bash · result'), findsNothing);
});
testWidgets('an image card renders with the "image" label, the file, and its caption (T-249)', (tester) async {
await pumpWith(tester, [_image('/no/such/file.png', caption: 'before the fix')]);
expect(find.text('image'), findsOneWidget);
@@ -247,8 +264,9 @@ void main() {
expect(find.text('claude'), findsOneWidget);
expect(find.text('thinking'), findsOneWidget);
expect(find.text('Bash'), findsOneWidget);
// Result labels now include the paired tool name (T-168).
expect(find.text('Bash · result'), findsOneWidget);
// T-262: a successful result folds into the tool card (no standalone
// "Bash · result"); a failed result keeps its prominent standalone card.
expect(find.text('Bash · result'), findsNothing);
expect(find.text('Bash · error'), findsOneWidget);
});
@@ -289,8 +307,12 @@ void main() {
hiddenToolUseIds: {'x1'},
toolUseOutcomes: {'x1': true}, // approved
);
final handle = tester.ensureSemantics();
expect(find.text('Write'), findsOneWidget); // shown (resolved)
expect(find.byType(ClideIcon), findsOneWidget); // collapsed caret
expect(find.bySemanticsLabel('Expand'), findsOneWidget); // collapsed caret
// T-262: the resolved card also folds its result + a success check.
expect(find.bySemanticsLabel('succeeded'), findsOneWidget);
handle.dispose();
});
testWidgets('an injected user message renders as a muted "context" card, not "you"', (tester) async {
@@ -323,12 +345,63 @@ void main() {
expect(find.text('/foo/bar.dart'), findsOneWidget);
});
testWidgets('result label includes paired tool name (T-168)', (tester) async {
testWidgets('a successful result folds into the tool-use card with a check (T-262)', (tester) async {
final handle = tester.ensureSemantics();
await pumpWith(tester, [
_tool('Read', {'file_path': '/x'}),
_result('file content'),
_tool('Read', {'file_path': '/foo/bar.dart'}),
_result('final answer = 42;'),
]);
expect(find.text('Read · result'), findsOneWidget);
// No standalone success result card — it's folded into the Read card.
expect(find.text('Read · result'), findsNothing);
// The merged card shows a success check.
expect(find.bySemanticsLabel('succeeded'), findsOneWidget);
// Collapsed by default: neither the call body nor the result is shown yet.
expect(find.text('final answer = 42;'), findsNothing);
// Expand: the call segment, a "result" sub-label, and the folded result
// as a colorized code block (Read → grammar from the .dart path).
await tester.tap(find.bySemanticsLabel('Expand'));
await tester.pumpAndSettle();
expect(find.text('result'), findsOneWidget); // segment sub-label
final blocks = tester.widgetList<ClideCodeBlock>(find.byType(ClideCodeBlock)).toList();
expect(blocks.any((b) => b.language == 'dart' && b.source == 'final answer = 42;'), isTrue);
handle.dispose();
});
testWidgets('Bash result folds as a bash code block; in-flight call has no check (T-262)', (tester) async {
final handle = tester.ensureSemantics();
// In-flight: a call with no result yet → no check, no folded result.
await pumpWith(tester, [
_tool('Bash', {'command': 'ls'})
]);
expect(find.bySemanticsLabel('succeeded'), findsNothing);
expect(find.bySemanticsLabel('failed'), findsNothing);
handle.dispose();
});
testWidgets('Bash success result infers the bash language for the folded block (T-262)', (tester) async {
await pumpWith(tester, [
_tool('Bash', {'command': 'echo hi'}),
_result('hi\nthere'),
]);
await tester.tap(find.bySemanticsLabel('Expand'));
await tester.pumpAndSettle();
final blocks = tester.widgetList<ClideCodeBlock>(find.byType(ClideCodeBlock)).toList();
expect(blocks.any((b) => b.language == 'bash' && b.source == 'hi\nthere'), isTrue);
});
testWidgets('a failed result is NOT folded: standalone error card + red header mark (T-262)', (tester) async {
final handle = tester.ensureSemantics();
await pumpWith(tester, [
_tool('Bash', {'command': 'boom'}),
_result('permission denied', isError: true),
]);
// The error stays a separate prominent card…
expect(find.text('Bash · error'), findsOneWidget);
expect(find.text('permission denied'), findsOneWidget);
// …and the call card carries a red failure mark for symmetry (note C).
expect(find.bySemanticsLabel('failed'), findsOneWidget);
handle.dispose();
});
testWidgets('error result label includes paired tool name (T-168)', (tester) async {
@@ -0,0 +1,78 @@
import 'package:alchemist/alchemist.dart';
import 'package:clide/builtin/claude/src/conversation_card.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import '../helpers/kernel_fixture.dart';
import '../helpers/widget_harness.dart';
void main() {
late KernelFixture f;
setUp(() async => f = await KernelFixture.create());
tearDown(() async => f.dispose());
goldenTest(
'ConversationCard merged tool card (T-262): status mark + folded result segment',
fileName: 'conversation_card_merged',
builder: () => GoldenTestGroup(
columns: 1,
children: [
GoldenTestScenario(
name: 'success / collapsed (check + summary)',
child: _wrap(
f,
const ConversationCard(
variant: ConversationCardVariant.bordered,
accent: Color(0xFF4C9AFF),
label: 'Read',
status: ConversationCardStatus.success,
collapsible: true,
collapsedByDefault: true,
collapsedSummary: '/lib/main.dart',
body: Text('/lib/main.dart', textDirection: TextDirection.ltr),
extraSegments: [CardSegment(label: 'result', child: Text('void main() {}', textDirection: TextDirection.ltr))],
),
),
),
GoldenTestScenario(
name: 'success / expanded (call → result segment)',
child: _wrap(
f,
const ConversationCard(
variant: ConversationCardVariant.bordered,
accent: Color(0xFF4C9AFF),
label: 'Read',
status: ConversationCardStatus.success,
collapsible: true,
collapsedByDefault: false,
body: Text('/lib/main.dart', textDirection: TextDirection.ltr),
extraSegments: [CardSegment(label: 'result', child: Text('void main() {}', textDirection: TextDirection.ltr))],
),
),
),
GoldenTestScenario(
name: 'error / header mark (call card stays separate from the red result card)',
child: _wrap(
f,
const ConversationCard(
variant: ConversationCardVariant.bordered,
accent: Color(0xFF4C9AFF),
label: 'Bash',
status: ConversationCardStatus.error,
collapsible: true,
collapsedByDefault: true,
collapsedSummary: 'cat nonexistent',
body: Text('cat nonexistent', textDirection: TextDirection.ltr),
),
),
),
],
),
);
}
Widget _wrap(KernelFixture f, Widget child) => SizedBox(
width: 360,
child: harness(f, child),
);
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB