group consecutive same-file edits into one collapsed card (T-296)
A run of 2+ consecutive edits to the same file now folds into one ClideHolderCard labelled '# edits' (coalesceEditRuns, run after groupConversation) instead of a stack of cards; a different file or an interleaving step splits the run. Every edit stays reachable on expand. The holder gained an optional aggregate status. New owned primitives: ClideSpinner (the logo mark, monochrome, 3D Y-axis rotation, reduced-motion-aware) and ClideStatusIndicator (running→spinner / success→check / error→cross, with an AnimatedSwitcher seam for a richer transition later — kept self-contained, not built on ConversationCard's mark). The activity card shares the same indicator. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,8 @@ AssistantTextMessage _prose([String t = 'sure']) => AssistantTextMessage(uuid: '
|
||||
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 {});
|
||||
AssistantToolUse _edit(String id, String path, {String name = 'Edit'}) =>
|
||||
AssistantToolUse(uuid: 'tu${_n++}', timestamp: _ts, isSidechain: false, toolUseId: id, name: name, input: {'file_path': path});
|
||||
ToolResultMessage _result(String id, {bool isError = false}) =>
|
||||
ToolResultMessage(uuid: 'r${_n++}', timestamp: _ts, isSidechain: false, toolUseId: id, content: '', isError: isError);
|
||||
|
||||
@@ -87,4 +89,57 @@ void main() {
|
||||
expect((groups[1] as StickyItem).item, isA<ImageMessage>());
|
||||
});
|
||||
});
|
||||
|
||||
group('editFilePath', () {
|
||||
test('reads the file of an edit tool-use; null otherwise', () {
|
||||
expect(editFilePath(_edit('1', '/a/b.dart')), '/a/b.dart');
|
||||
expect(editFilePath(_edit('1', '/a/b.dart', name: 'Write')), '/a/b.dart');
|
||||
expect(editFilePath(_tool('1', 'Bash')), isNull); // not a diff tool
|
||||
expect(editFilePath(_tool('1', 'Edit')), isNull); // diff tool, but no file_path
|
||||
expect(editFilePath(_prose()), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('coalesceEditRuns (T-296)', () {
|
||||
test('consecutive same-file edits bundle into one EditRun', () {
|
||||
final out = coalesceEditRuns([StickyItem(_edit('1', '/a')), StickyItem(_edit('2', '/a')), StickyItem(_edit('3', '/a'))]);
|
||||
expect(out, hasLength(1));
|
||||
expect(out.single, isA<EditRun>());
|
||||
final run = out.single as EditRun;
|
||||
expect(run.edits, hasLength(3));
|
||||
expect(run.filePath, '/a');
|
||||
});
|
||||
|
||||
test('a lone edit stays a StickyItem (not a one-item run)', () {
|
||||
final out = coalesceEditRuns([StickyItem(_edit('1', '/a')), StickyItem(_prose())]);
|
||||
expect(out.first, isA<StickyItem>());
|
||||
expect((out.first as StickyItem).item, isA<AssistantToolUse>());
|
||||
});
|
||||
|
||||
test('a different file starts a new run', () {
|
||||
final out = coalesceEditRuns([StickyItem(_edit('1', '/a')), StickyItem(_edit('2', '/a')), StickyItem(_edit('3', '/b')), StickyItem(_edit('4', '/b'))]);
|
||||
expect(out.map((g) => g.runtimeType.toString()), ['EditRun', 'EditRun']);
|
||||
expect((out[0] as EditRun).filePath, '/a');
|
||||
expect((out[1] as EditRun).filePath, '/b');
|
||||
});
|
||||
|
||||
test('an interleaving non-edit group splits the run (the worked example)', () {
|
||||
// 3 edits to /a, a folded Read cluster, then 7 edits to /a → [3 edits][cluster][7 edits].
|
||||
final groups = <RenderGroup>[
|
||||
for (var i = 0; i < 3; i++) StickyItem(_edit('a$i', '/a')),
|
||||
FoldedCluster([_tool('r', 'Read'), _result('r')]),
|
||||
for (var i = 0; i < 7; i++) StickyItem(_edit('b$i', '/a')),
|
||||
];
|
||||
final out = coalesceEditRuns(groups);
|
||||
expect(out.map((g) => g.runtimeType.toString()), ['EditRun', 'FoldedCluster', 'EditRun']);
|
||||
expect((out[0] as EditRun).edits, hasLength(3));
|
||||
expect((out[2] as EditRun).edits, hasLength(7));
|
||||
});
|
||||
|
||||
test('a sticky non-edit between edits breaks the run', () {
|
||||
final out = coalesceEditRuns([StickyItem(_edit('1', '/a')), StickyItem(_prose()), StickyItem(_edit('2', '/a'))]);
|
||||
// edit (lone) → sticky, prose → sticky, edit (lone) → sticky.
|
||||
expect(out.map((g) => g.runtimeType.toString()), ['StickyItem', 'StickyItem', 'StickyItem']);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import 'package:clide/builtin/claude/src/transcript_reader.dart';
|
||||
import 'package:clide/kernel/src/events/message_bus.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart' show Image, FileImage, ValueKey;
|
||||
import 'package:flutter/widgets.dart' show Builder, Image, FileImage, MediaQuery, ValueKey;
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../helpers/kernel_fixture.dart';
|
||||
@@ -159,8 +159,17 @@ 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, foldLevel: foldLevel)));
|
||||
// Disable animations so an in-flight run's ClideSpinner (a perpetual
|
||||
// animation) renders static and pumpAndSettle can settle (T-296).
|
||||
await tester.pumpWidget(harness(
|
||||
f,
|
||||
Builder(
|
||||
builder: (ctx) => MediaQuery(
|
||||
data: MediaQuery.of(ctx).copyWith(disableAnimations: true),
|
||||
child: ConversationView(controller: c, hiddenToolUseIds: hiddenToolUseIds, toolUseOutcomes: toolUseOutcomes, foldLevel: foldLevel),
|
||||
),
|
||||
),
|
||||
));
|
||||
for (final it in items) {
|
||||
stream.add(it);
|
||||
}
|
||||
@@ -199,7 +208,15 @@ void main() {
|
||||
final stream = StreamController<ConversationItem>.broadcast();
|
||||
final c = ConversationController(stream: stream.stream);
|
||||
addTearDown(c.dispose);
|
||||
await tester.pumpWidget(harness(f, ConversationView(controller: c, foldLevel: FoldLevel.tools)));
|
||||
await tester.pumpWidget(harness(
|
||||
f,
|
||||
Builder(
|
||||
builder: (ctx) => MediaQuery(
|
||||
data: MediaQuery.of(ctx).copyWith(disableAnimations: true),
|
||||
child: ConversationView(controller: c, foldLevel: FoldLevel.tools),
|
||||
),
|
||||
),
|
||||
));
|
||||
stream.add(AssistantToolUse(uuid: 'A', timestamp: _t, isSidechain: false, toolUseId: 'A', name: 'Bash', input: const {'command': 'echo a'}));
|
||||
stream.add(AssistantToolUse(uuid: 'B', timestamp: _t, isSidechain: false, toolUseId: 'B', name: 'Read', input: const {'file_path': '/a'}));
|
||||
await tester.pumpAndSettle();
|
||||
@@ -247,6 +264,32 @@ void main() {
|
||||
expect(find.byType(ImageThumbnail), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('consecutive same-file edits collapse into one "# edits" card (T-296)', (tester) async {
|
||||
AssistantToolUse edit(String id, String path) =>
|
||||
AssistantToolUse(uuid: id, timestamp: _t, isSidechain: false, toolUseId: id, name: 'Edit', input: {'file_path': path});
|
||||
ToolResultMessage ok(String id) => ToolResultMessage(uuid: 'r$id', timestamp: _t, isSidechain: false, toolUseId: id, content: 'done', isError: false);
|
||||
await pumpWith(
|
||||
tester,
|
||||
[
|
||||
edit('e1', '/lib/x.dart'),
|
||||
ok('e1'),
|
||||
edit('e2', '/lib/x.dart'),
|
||||
ok('e2'),
|
||||
],
|
||||
foldLevel: FoldLevel.tools);
|
||||
// One bundled card labelled "2 edits" with an aggregate status indicator.
|
||||
expect(find.text('2 edits'), findsOneWidget);
|
||||
expect(find.byType(ClideStatusIndicator), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('an edit to a different file is not bundled with the first (T-296)', (tester) async {
|
||||
AssistantToolUse edit(String id, String path) =>
|
||||
AssistantToolUse(uuid: id, timestamp: _t, isSidechain: false, toolUseId: id, name: 'Edit', input: {'file_path': path});
|
||||
await pumpWith(tester, [edit('e1', '/a.dart'), edit('e2', '/b.dart')], foldLevel: FoldLevel.tools);
|
||||
// Two lone edits, different files → no "edits" bundle.
|
||||
expect(find.textContaining('edits'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('meta items fold into a collapsed activity card; tap expands (T-230)', (tester) async {
|
||||
await pumpWith(
|
||||
tester,
|
||||
|
||||
Reference in New Issue
Block a user