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:
@@ -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 |
Reference in New Issue
Block a user