The conversation pane now exploits the structured stream instead of dumping tool input as JSON. ConversationController indexes tool_use by id so a tool_result pairs back to its call and renders the Edit/Write diff or is_error failure in place; per-tool bodies (Bash command+output, Read/Grep file/query) reuse the shared renderers factored out of the permission card. SessionStatus gains cost + contextWindow + rate-limit, read straight off the init/result/rate_limit_event events, so the in-pane status line reflects live state without the config probe. Partial-message streaming is wired behind --include-partial-messages but its event shape is unverified against the live binary and degrades to a no-op if it differs — see T-184. T-168. Co-Authored-By: Claude <noreply@anthropic.com>
387 lines
16 KiB
Dart
387 lines
16 KiB
Dart
/// Widget tests for the native Claude ConversationView + controller
|
|
/// (T-137): renders each transcript item kind as a card, and text
|
|
/// selects + copies across cards via ClideSelectionArea (the terminal
|
|
/// affordance we keep, T-135).
|
|
library;
|
|
|
|
import 'dart:async';
|
|
|
|
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';
|
|
import 'package:clide/builtin/claude/src/transcript_publisher.dart';
|
|
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_test/flutter_test.dart';
|
|
|
|
import '../../helpers/kernel_fixture.dart';
|
|
import '../../helpers/widget_harness.dart';
|
|
|
|
final _t = DateTime.utc(2026, 1, 1);
|
|
|
|
UserMessage _user(String text) => UserMessage(uuid: 'u', timestamp: _t, isSidechain: false, text: text);
|
|
AssistantTextMessage _asst(String text) => AssistantTextMessage(uuid: 'a', timestamp: _t, isSidechain: false, text: text);
|
|
AssistantThinkingMessage _think(String text) => AssistantThinkingMessage(uuid: 't', timestamp: _t, isSidechain: false, thinking: text);
|
|
AssistantToolUse _tool(String name, Map<String, dynamic> input) =>
|
|
AssistantToolUse(uuid: 'tu', timestamp: _t, isSidechain: false, toolUseId: 'x1', name: name, input: input);
|
|
ToolResultMessage _result(String content, {bool isError = false}) =>
|
|
ToolResultMessage(uuid: 'r', timestamp: _t, isSidechain: false, toolUseId: 'x1', content: content, isError: isError);
|
|
|
|
class _MockClipboard {
|
|
Map<String, dynamic> _data = {'text': null};
|
|
Future<Object?> handleMethodCall(MethodCall call) async {
|
|
switch (call.method) {
|
|
case 'Clipboard.setData':
|
|
_data = Map<String, dynamic>.from(call.arguments as Map);
|
|
case 'Clipboard.getData':
|
|
return _data;
|
|
case 'Clipboard.hasStrings':
|
|
final t = _data['text'] as String?;
|
|
return {'value': t != null && t.isNotEmpty};
|
|
}
|
|
return null;
|
|
}
|
|
|
|
String? get text => _data['text'] as String?;
|
|
}
|
|
|
|
void main() {
|
|
group('ConversationController', () {
|
|
test('accumulates items from the stream and notifies', () async {
|
|
final ctrl = StreamController<ConversationItem>();
|
|
final c = ConversationController(stream: ctrl.stream);
|
|
addTearDown(c.dispose);
|
|
var notifications = 0;
|
|
c.addListener(() => notifications++);
|
|
|
|
expect(c.isEmpty, isTrue);
|
|
ctrl.add(_user('hi'));
|
|
ctrl.add(_asst('hello'));
|
|
// Wait past the coalescing timer (zero-duration, fires after the
|
|
// microtask queue drains).
|
|
await Future<void>.delayed(const Duration(milliseconds: 20));
|
|
|
|
expect(c.items, hasLength(2));
|
|
expect(c.items.first, isA<UserMessage>());
|
|
// Notifications are coalesced: a burst of items collapses to a
|
|
// single notify so the view rebuilds once, not per item.
|
|
expect(notifications, 1);
|
|
await ctrl.close();
|
|
});
|
|
|
|
test('onDispose is invoked on dispose', () async {
|
|
final ctrl = StreamController<ConversationItem>();
|
|
var disposed = false;
|
|
final c = ConversationController(stream: ctrl.stream, onDispose: () async => disposed = true);
|
|
c.dispose();
|
|
expect(disposed, isTrue);
|
|
await ctrl.close();
|
|
});
|
|
|
|
test('toolUseById indexes AssistantToolUse by toolUseId (T-168)', () async {
|
|
final ctrl = StreamController<ConversationItem>();
|
|
final c = ConversationController(stream: ctrl.stream);
|
|
addTearDown(c.dispose);
|
|
|
|
ctrl.add(_tool('Bash', {'command': 'ls'}));
|
|
await Future<void>.delayed(const Duration(milliseconds: 20));
|
|
|
|
expect(c.toolUseById['x1'], isNotNull);
|
|
expect(c.toolUseById['x1']!.name, 'Bash');
|
|
await ctrl.close();
|
|
});
|
|
|
|
test('partial-uuid items upsert in the controller (T-168)', () async {
|
|
final ctrl = StreamController<ConversationItem>();
|
|
final c = ConversationController(stream: ctrl.stream);
|
|
addTearDown(c.dispose);
|
|
|
|
// Two partials with the same `partial-` uuid — second replaces first.
|
|
ctrl.add(AssistantTextMessage(uuid: 'partial-m1', timestamp: _t, isSidechain: false, text: 'hello'));
|
|
await Future<void>.delayed(const Duration(milliseconds: 20));
|
|
ctrl.add(AssistantTextMessage(uuid: 'partial-m1', timestamp: _t, isSidechain: false, text: 'hello world'));
|
|
await Future<void>.delayed(const Duration(milliseconds: 20));
|
|
|
|
expect(c.items.whereType<AssistantTextMessage>(), hasLength(1));
|
|
expect(c.items.whereType<AssistantTextMessage>().first.text, 'hello world');
|
|
await ctrl.close();
|
|
});
|
|
});
|
|
|
|
group('ConversationController.fromBus', () {
|
|
test('consumes items published on its publisher/channel', () async {
|
|
final bus = MessageBus();
|
|
addTearDown(bus.dispose);
|
|
final c = ConversationController.fromBus(messages: bus);
|
|
addTearDown(c.dispose);
|
|
|
|
bus.publish(ClaudeConversation.publisher, ClaudeConversation.leadChannel, {ClaudeConversation.itemKey: _user('hi')});
|
|
bus.publish(ClaudeConversation.publisher, ClaudeConversation.leadChannel, {ClaudeConversation.itemKey: _asst('hello')});
|
|
await Future<void>.delayed(const Duration(milliseconds: 20));
|
|
|
|
expect(c.items, hasLength(2));
|
|
expect(c.items.first, isA<UserMessage>());
|
|
});
|
|
|
|
test('ignores other publishers and channels', () async {
|
|
final bus = MessageBus();
|
|
addTearDown(bus.dispose);
|
|
final c = ConversationController.fromBus(messages: bus);
|
|
addTearDown(c.dispose);
|
|
|
|
bus.publish('someone.else', ClaudeConversation.leadChannel, {ClaudeConversation.itemKey: _user('nope')});
|
|
bus.publish(ClaudeConversation.publisher, 'conversation/other', {ClaudeConversation.itemKey: _user('nope')});
|
|
await Future<void>.delayed(const Duration(milliseconds: 20));
|
|
|
|
expect(c.items, isEmpty);
|
|
});
|
|
});
|
|
|
|
group('ConversationView', () {
|
|
late KernelFixture f;
|
|
setUp(() async => f = await KernelFixture.create());
|
|
tearDown(() => f.dispose());
|
|
|
|
Future<ConversationController> pumpWith(WidgetTester tester, List<ConversationItem> items,
|
|
{Set<String> hiddenToolUseIds = const {}, Map<String, bool> toolUseOutcomes = const {}}) async {
|
|
tester.view.physicalSize = const Size(900, 700);
|
|
tester.view.devicePixelRatio = 1.0;
|
|
addTearDown(() {
|
|
tester.view.resetPhysicalSize();
|
|
tester.view.resetDevicePixelRatio();
|
|
});
|
|
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)));
|
|
for (final it in items) {
|
|
stream.add(it);
|
|
}
|
|
await tester.pumpAndSettle();
|
|
return c;
|
|
}
|
|
|
|
testWidgets('empty controller shows the waiting hint', (tester) async {
|
|
await pumpWith(tester, const []);
|
|
expect(find.text('Waiting for Claude…'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('empty controller shows the provided emptyState instead', (tester) async {
|
|
final stream = StreamController<ConversationItem>.broadcast();
|
|
final c = ConversationController(stream: stream.stream);
|
|
addTearDown(c.dispose);
|
|
await tester.pumpWidget(harness(
|
|
f,
|
|
ConversationView(
|
|
controller: c,
|
|
emptyState: const ClideText('CUSTOM EMPTY'),
|
|
),
|
|
));
|
|
expect(find.text('CUSTOM EMPTY'), findsOneWidget);
|
|
expect(find.text('Waiting for Claude…'), findsNothing);
|
|
});
|
|
|
|
testWidgets('renders a card per item kind with role/tool labels', (tester) async {
|
|
await pumpWith(tester, [
|
|
_user('a question'),
|
|
_asst('an answer'),
|
|
_think('hmm'),
|
|
_tool('Bash', {'command': 'ls'}),
|
|
_result('ok'),
|
|
_result('boom', isError: true),
|
|
]);
|
|
expect(find.text('you'), findsOneWidget);
|
|
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);
|
|
expect(find.text('Bash · error'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('AskUserQuestion tool-use and its result are hidden (it shows as a prompt)', (tester) async {
|
|
await pumpWith(tester, [
|
|
_asst('let me ask'),
|
|
AssistantToolUse(uuid: 'au', timestamp: _t, isSidechain: false, toolUseId: 'auq1', name: 'AskUserQuestion', input: const {'questions': []}),
|
|
ToolResultMessage(uuid: 'ar', timestamp: _t, isSidechain: false, toolUseId: 'auq1', content: 'answered', isError: false),
|
|
_asst('thanks'),
|
|
]);
|
|
expect(find.text('AskUserQuestion'), findsNothing);
|
|
expect(find.text('let me ask'), findsOneWidget);
|
|
expect(find.text('thanks'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('a permission-prompted tool-use is hidden but its result is kept', (tester) async {
|
|
await pumpWith(
|
|
tester,
|
|
[
|
|
_tool('Write', {'file_path': '/tmp/x'}),
|
|
_result('done')
|
|
],
|
|
hiddenToolUseIds: {'x1'}, // _tool + _result both use toolUseId 'x1'
|
|
);
|
|
expect(find.text('Write'), findsNothing); // payload hidden
|
|
expect(find.text('done'), findsOneWidget); // result kept
|
|
// Label now includes paired tool name (T-168).
|
|
expect(find.text('Write · result'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('a resolved permission tool-use is shown collapsed, not hidden', (tester) async {
|
|
await pumpWith(
|
|
tester,
|
|
[
|
|
_tool('Write', {'file_path': '/tmp/x'}),
|
|
_result('done')
|
|
],
|
|
hiddenToolUseIds: {'x1'},
|
|
toolUseOutcomes: {'x1': true}, // approved
|
|
);
|
|
expect(find.text('Write'), findsOneWidget); // shown (resolved)
|
|
expect(find.byType(ClideIcon), findsOneWidget); // collapsed caret
|
|
});
|
|
|
|
testWidgets('an injected user message renders as a muted "context" card, not "you"', (tester) async {
|
|
await pumpWith(tester, [
|
|
UserMessage(uuid: 'i', timestamp: _t, isSidechain: false, text: 'Base directory for this skill: /x\n\n# pql', injected: true),
|
|
_user('a real question'),
|
|
]);
|
|
expect(find.text('context'), findsOneWidget);
|
|
expect(find.text('you'), findsOneWidget); // the real one
|
|
});
|
|
|
|
testWidgets('tool-use body: Bash shows the command in the collapsed summary (T-168)', (tester) async {
|
|
await pumpWith(tester, [
|
|
_tool('Bash', {'command': 'ls -la'})
|
|
]);
|
|
// Card starts collapsed — the command appears as the collapsed summary.
|
|
expect(find.text('ls -la'), findsOneWidget);
|
|
// Expand to verify the body is a bash code block.
|
|
await tester.tap(find.byType(ClideIcon));
|
|
await tester.pump();
|
|
final blocks = tester.widgetList<ClideCodeBlock>(find.byType(ClideCodeBlock)).toList();
|
|
expect(blocks.any((b) => b.language == 'bash' && b.source.contains('ls -la')), isTrue);
|
|
});
|
|
|
|
testWidgets('tool-use body: Read/Grep/LS shows a compact path label (T-168)', (tester) async {
|
|
await pumpWith(tester, [
|
|
_tool('Read', {'file_path': '/foo/bar.dart'})
|
|
]);
|
|
// The path label appears (collapsed summary or body).
|
|
expect(find.text('/foo/bar.dart'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('result label includes paired tool name (T-168)', (tester) async {
|
|
await pumpWith(tester, [
|
|
_tool('Read', {'file_path': '/x'}),
|
|
_result('file content'),
|
|
]);
|
|
expect(find.text('Read · result'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('error result label includes paired tool name (T-168)', (tester) async {
|
|
await pumpWith(tester, [
|
|
_tool('Bash', {'command': 'cat nonexistent'}),
|
|
_result('No such file', isError: true),
|
|
]);
|
|
expect(find.text('Bash · error'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('result without a paired tool_use uses plain "result" label (T-168)', (tester) async {
|
|
// Orphan result (no matching tool_use in the controller).
|
|
await pumpWith(tester, [
|
|
ToolResultMessage(
|
|
uuid: 'r-orphan',
|
|
timestamp: _t,
|
|
isSidechain: false,
|
|
toolUseId: 'unknown-id',
|
|
content: 'ok',
|
|
isError: false,
|
|
),
|
|
]);
|
|
expect(find.text('result'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('error result defaults expanded so it is visible (T-168)', (tester) async {
|
|
// An error result should show its content without requiring an expand tap.
|
|
await pumpWith(tester, [
|
|
_tool('Bash', {'command': 'bad'}),
|
|
_result('permission denied', isError: true),
|
|
]);
|
|
// Error content visible without expand.
|
|
expect(find.text('permission denied'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('a one-line tool result renders inline (no collapse caret)', (tester) async {
|
|
await pumpWith(tester, [_result('hello-from-spike')]);
|
|
expect(find.text('hello-from-spike'), findsOneWidget);
|
|
expect(find.byType(ClideIcon), findsNothing); // not collapsible → no caret
|
|
});
|
|
|
|
testWidgets('a multi-line tool result starts collapsed with a first-line summary', (tester) async {
|
|
await pumpWith(tester, [_result('first line\nsecond line\nthird line')]);
|
|
// Collapsed: caret present, summary (first line) shown, full body hidden.
|
|
expect(find.byType(ClideIcon), findsOneWidget);
|
|
expect(find.text('first line'), findsOneWidget);
|
|
expect(find.text('first line\nsecond line\nthird line'), findsNothing);
|
|
|
|
await tester.tap(find.byType(ClideIcon));
|
|
await tester.pump();
|
|
expect(find.text('first line\nsecond line\nthird line'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('select-all + copy spans multiple cards', (tester) async {
|
|
final clipboard = _MockClipboard();
|
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, clipboard.handleMethodCall);
|
|
addTearDown(() => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, null));
|
|
|
|
await pumpWith(tester, [_user('question text'), _asst('answer text')]);
|
|
|
|
// Focus the selection region, select all, copy.
|
|
final region = find.byType(ClideSelectionArea);
|
|
expect(region, findsOneWidget);
|
|
await tester.tap(region);
|
|
await tester.pump();
|
|
Future<void> keys(LogicalKeyboardKey k) async {
|
|
await tester.sendKeyDownEvent(LogicalKeyboardKey.control);
|
|
await tester.sendKeyDownEvent(k);
|
|
await tester.sendKeyUpEvent(k);
|
|
await tester.sendKeyUpEvent(LogicalKeyboardKey.control);
|
|
await tester.pump();
|
|
}
|
|
|
|
await keys(LogicalKeyboardKey.keyA);
|
|
await keys(LogicalKeyboardKey.keyC);
|
|
await tester.pump();
|
|
|
|
final copied = clipboard.text ?? '';
|
|
expect(copied, contains('question text'));
|
|
expect(copied, contains('answer text'));
|
|
});
|
|
});
|
|
|
|
group('ClaudeBanner', () {
|
|
late KernelFixture f;
|
|
setUp(() async => f = await KernelFixture.create());
|
|
tearDown(() => f.dispose());
|
|
|
|
testWidgets('shows role, workspace, status, and a hint', (tester) async {
|
|
await tester.pumpWidget(harness(
|
|
f,
|
|
const ClaudeBanner(
|
|
role: 'primary',
|
|
workspace: '/work/space',
|
|
statusLine: 'tmux · clide-claude-x',
|
|
),
|
|
));
|
|
await tester.pump();
|
|
expect(find.text('Claude'), findsOneWidget);
|
|
expect(find.text('primary'), findsOneWidget);
|
|
expect(find.text('/work/space'), findsOneWidget);
|
|
expect(find.text('tmux · clide-claude-x'), findsOneWidget);
|
|
expect(find.textContaining('Warming up'), findsOneWidget);
|
|
});
|
|
});
|
|
}
|