fold Claude tool activity into a collapsible card (T-230)
A heavy agent turn buried user/Claude prose under a wall of tool-call/ result rows. A pure grouping pass (activity_cluster.dart) folds runs of consecutive meta items into clusters; the conversation view renders each cluster as one collapsible activity card — collapsed by default with a live one-line ticker of the latest step + a step count, click/Enter to expand the steps in order. Sticky items (user messages, Claude prose, and FAILED results) render first-class and seal the cluster. Fold level is switchable (FoldLevel none/tools/thinking/everything); default L1 folds tool calls+results while keeping diffs and thinking first-class. The grouping logic is fully unit-tested; the card is keyboard + screen-reader accessible. Persisting the level via a user setting + control is the tracked follow-up T-235. Closes T-230 (under T-132). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
/// Unit tests for the activity-card grouping pass (T-230).
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/claude/src/activity_cluster.dart';
|
||||
import 'package:clide/builtin/claude/src/transcript_reader.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
var _n = 0;
|
||||
final _ts = DateTime.utc(2026, 1, 1);
|
||||
|
||||
UserMessage _user([String t = 'hi']) => UserMessage(uuid: 'u${_n++}', timestamp: _ts, isSidechain: false, text: t);
|
||||
AssistantTextMessage _prose([String t = 'sure']) => AssistantTextMessage(uuid: 'a${_n++}', timestamp: _ts, isSidechain: false, text: t);
|
||||
AssistantThinkingMessage _think() => AssistantThinkingMessage(uuid: 't${_n++}', timestamp: _ts, isSidechain: false, thinking: '…');
|
||||
AssistantToolUse _tool(String id, String name) =>
|
||||
AssistantToolUse(uuid: 'tu${_n++}', timestamp: _ts, isSidechain: false, toolUseId: id, name: name, input: const {});
|
||||
ToolResultMessage _result(String id, {bool isError = false}) =>
|
||||
ToolResultMessage(uuid: 'r${_n++}', timestamp: _ts, isSidechain: false, toolUseId: id, content: '', isError: isError);
|
||||
|
||||
void main() {
|
||||
group('groupConversation', () {
|
||||
test('L1: a tool call + result fold into one cluster between sticky prose', () {
|
||||
final groups = groupConversation([_user(), _tool('1', 'Bash'), _result('1'), _prose()], FoldLevel.tools);
|
||||
expect(groups, hasLength(3));
|
||||
expect(groups[0], isA<StickyItem>());
|
||||
expect(groups[1], isA<FoldedCluster>());
|
||||
expect((groups[1] as FoldedCluster).items, hasLength(2)); // tool + result, in order
|
||||
expect(groups[2], isA<StickyItem>());
|
||||
});
|
||||
|
||||
test('a sticky message seals the cluster and starts a new one after it', () {
|
||||
final groups = groupConversation([_tool('1', 'Bash'), _result('1'), _prose(), _tool('2', 'Bash'), _result('2')], FoldLevel.tools);
|
||||
expect(groups.map((g) => g.runtimeType.toString()), ['FoldedCluster', 'StickyItem', 'FoldedCluster']);
|
||||
});
|
||||
|
||||
test('L1: diffs (Edit/Write) and thinking stay first-class', () {
|
||||
final groups = groupConversation([_think(), _tool('1', 'Edit'), _result('1')], FoldLevel.tools);
|
||||
// none fold at L1 → three sticky items, no cluster
|
||||
expect(groups.every((g) => g is StickyItem), isTrue);
|
||||
expect(groups, hasLength(3));
|
||||
});
|
||||
|
||||
test('L2: thinking folds, diffs still first-class', () {
|
||||
final folded = groupConversation([_think(), _tool('1', 'Bash'), _result('1')], FoldLevel.thinking);
|
||||
expect(folded, hasLength(1));
|
||||
expect((folded.single as FoldedCluster).items, hasLength(3)); // think + tool + result
|
||||
|
||||
final diff = groupConversation([_tool('1', 'Write'), _result('1')], FoldLevel.thinking);
|
||||
expect(diff.every((g) => g is StickyItem), isTrue);
|
||||
});
|
||||
|
||||
test('L3: everything except user/prose folds, including diffs and thinking', () {
|
||||
final groups = groupConversation([_think(), _tool('1', 'Edit'), _result('1'), _tool('2', 'Bash'), _result('2')], FoldLevel.everything);
|
||||
expect(groups, hasLength(1));
|
||||
expect((groups.single as FoldedCluster).items, hasLength(5));
|
||||
});
|
||||
|
||||
test('a failed result surfaces (sticky) and breaks the cluster', () {
|
||||
final groups = groupConversation([
|
||||
_tool('1', 'Bash'),
|
||||
_result('1', isError: true),
|
||||
_tool('2', 'Bash'),
|
||||
_result('2'),
|
||||
], FoldLevel.tools);
|
||||
// [Folded([tool1]), Sticky(errorResult), Folded([tool2,result2])]
|
||||
expect(groups, hasLength(3));
|
||||
expect((groups[0] as FoldedCluster).items, hasLength(1));
|
||||
expect(groups[1], isA<StickyItem>());
|
||||
expect((groups[1] as StickyItem).item, isA<ToolResultMessage>());
|
||||
expect((groups[2] as FoldedCluster).items, hasLength(2));
|
||||
});
|
||||
|
||||
test('switching level re-groups the same transcript', () {
|
||||
final items = [_tool('1', 'Bash'), _result('1'), _think()];
|
||||
expect(groupConversation(items, FoldLevel.tools).whereType<FoldedCluster>().single.items, hasLength(2));
|
||||
expect(groupConversation(items, FoldLevel.thinking).whereType<FoldedCluster>().single.items, hasLength(3));
|
||||
});
|
||||
|
||||
test('empty input yields no groups', () {
|
||||
expect(groupConversation(const [], FoldLevel.tools), isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -6,6 +6,7 @@ library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:clide/builtin/claude/src/activity_cluster.dart';
|
||||
import 'package:clide/builtin/claude/src/claude_banner.dart';
|
||||
import 'package:clide/builtin/claude/src/conversation_controller.dart';
|
||||
import 'package:clide/builtin/claude/src/conversation_view.dart';
|
||||
@@ -145,7 +146,7 @@ void main() {
|
||||
tearDown(() => f.dispose());
|
||||
|
||||
Future<ConversationController> pumpWith(WidgetTester tester, List<ConversationItem> items,
|
||||
{Set<String> hiddenToolUseIds = const {}, Map<String, bool> toolUseOutcomes = const {}}) async {
|
||||
{Set<String> hiddenToolUseIds = const {}, Map<String, bool> toolUseOutcomes = const {}, FoldLevel foldLevel = FoldLevel.none}) async {
|
||||
tester.view.physicalSize = const Size(900, 700);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(() {
|
||||
@@ -155,7 +156,8 @@ void main() {
|
||||
final stream = StreamController<ConversationItem>.broadcast();
|
||||
final c = ConversationController(stream: stream.stream);
|
||||
addTearDown(c.dispose);
|
||||
await tester.pumpWidget(harness(f, ConversationView(controller: c, hiddenToolUseIds: hiddenToolUseIds, toolUseOutcomes: toolUseOutcomes)));
|
||||
await tester
|
||||
.pumpWidget(harness(f, ConversationView(controller: c, hiddenToolUseIds: hiddenToolUseIds, toolUseOutcomes: toolUseOutcomes, foldLevel: foldLevel)));
|
||||
for (final it in items) {
|
||||
stream.add(it);
|
||||
}
|
||||
@@ -168,6 +170,36 @@ void main() {
|
||||
expect(find.text('Waiting for Claude…'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('meta items fold into a collapsed activity card; tap expands (T-230)', (tester) async {
|
||||
await pumpWith(
|
||||
tester,
|
||||
[
|
||||
_tool('Bash', const {'command': 'echo hi'}),
|
||||
_result('hi'),
|
||||
],
|
||||
foldLevel: FoldLevel.tools);
|
||||
// Collapsed by default: one card with a step count, not the raw rows.
|
||||
expect(find.text('2 steps'), findsOneWidget);
|
||||
expect(find.bySemanticsLabel('Activity, 2 steps, collapsed'), findsOneWidget);
|
||||
// Activating it expands to reveal the folded steps.
|
||||
await tester.tap(find.bySemanticsLabel('Activity, 2 steps, collapsed'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.bySemanticsLabel('Activity, 2 steps, expanded'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a failed result surfaces first-class, not folded (T-230)', (tester) async {
|
||||
await pumpWith(
|
||||
tester,
|
||||
[
|
||||
_tool('Bash', const {'command': 'boom'}),
|
||||
_result('error output', isError: true),
|
||||
],
|
||||
foldLevel: FoldLevel.tools);
|
||||
// The tool call folds (1 step); the error result is sticky → no 2-step card.
|
||||
expect(find.text('1 step'), findsOneWidget);
|
||||
expect(find.textContaining('error output'), findsWidgets);
|
||||
});
|
||||
|
||||
testWidgets('empty controller shows the provided emptyState instead', (tester) async {
|
||||
final stream = StreamController<ConversationItem>.broadcast();
|
||||
final c = ConversationController(stream: stream.stream);
|
||||
|
||||
Reference in New Issue
Block a user