diff --git a/lib/builtin/claude/src/claude_session_host.dart b/lib/builtin/claude/src/claude_session_host.dart index e8b2251d..121d71b4 100644 --- a/lib/builtin/claude/src/claude_session_host.dart +++ b/lib/builtin/claude/src/claude_session_host.dart @@ -77,6 +77,11 @@ class ClaudeSessionHostState extends State { super.dispose(); } + /// Ids of the open tabs, oldest-first. Exposed for tests that assert the + /// host resets to a lone primary on an in-place workspace switch (T-269). + @visibleForTesting + List get tabIds => _controller.entries.map((e) => e.id).toList(); + /// Public entry point used by the `claude.new-secondary` command. void addSecondary() { final index = _nextSecondary++; diff --git a/test/builtin/claude/claude_pane_test.dart b/test/builtin/claude/claude_pane_test.dart new file mode 100644 index 00000000..5784ab56 --- /dev/null +++ b/test/builtin/claude/claude_pane_test.dart @@ -0,0 +1,281 @@ +/// Widget tests for [ClaudePane] — the spawn/rebind lifecycle (T-269) plus the +/// composer-driven command handling (/clear, /fork, send, mode cycle) and the +/// status / prompt render paths. +/// +/// Harness: a fake [ClaudeSessionOrchestrator] (no real `claude` process) and a +/// connected fake IPC that answers `files.root`. The pane awaits a real +/// File(...).exists() transcript probe during spawn, so the spawn/respawn +/// phases run inside tester.runAsync (fake-async would trap that I/O). +library; + +import 'dart:async'; +import 'dart:convert'; + +import 'package:clide/builtin/claude/src/claude_composer.dart'; +import 'package:clide/builtin/claude/src/claude_pane.dart'; +import 'package:clide/builtin/claude/src/conversation_view.dart'; +import 'package:clide/builtin/claude/src/session_naming.dart'; +import 'package:clide/builtin/claude/src/session_orchestrator.dart'; +import 'package:clide/builtin/claude/src/session_picker.dart'; +import 'package:clide/builtin/claude/src/stream_json_session.dart'; +import 'package:clide/clide.dart'; +import 'package:clide/kernel/kernel.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/kernel_fixture.dart'; + +class _FakeProc implements StreamJsonProcess { + final _ctl = StreamController.broadcast(); + final List writes = []; + bool killed = false; + + @override + Stream get lines => _ctl.stream; + + @override + void writeLine(String line) => writes.add(line); + + @override + Future kill() async { + killed = true; + if (!_ctl.isClosed) await _ctl.close(); + } + + void feed(Map event) { + if (!_ctl.isClosed) _ctl.add(jsonEncode(event)); + } +} + +void main() { + late KernelFixture f; + late ClaudeSessionOrchestrator orch; + late String root; + final created = <_FakeProc>[]; + + setUp(() async { + f = await KernelFixture.create(); + created.clear(); + root = '/repo-a'; + orch = ClaudeSessionOrchestrator( + processFactory: ({required sessionArgs, required cwd, env}) async { + final p = _FakeProc(); + created.add(p); + return p; + }, + ); + activeSessionOrchestrator = orch; + f.ipc.setConnected(true); + f.ipc.stub('files.root', (_) async => IpcResponse.ok(id: '', data: {'path': root})); + }); + + tearDown(() async { + activeSessionOrchestrator = null; + orch.dispose(); + await f.dispose(); + }); + + Widget tree(ClaudePane pane) => Directionality( + textDirection: TextDirection.ltr, + child: ClideKernel( + services: f.services, + child: ClideTheme( + controller: f.services.theme, + child: MediaQuery( + data: const MediaQueryData(), + child: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 900, + height: 700, + child: DialogHost( + router: f.services.dialog, + child: Overlay(initialEntries: [OverlayEntry(builder: (_) => pane)]), + ), + ), + ), + ), + ), + ), + ); + + // Pump the pane and release its project-wait gate so _spawn runs. The whole + // chain (incl. the real transcript-probe I/O) runs in the real zone. + Future mount(WidgetTester tester, ClaudePane pane, {String? openPath}) async { + await tester.runAsync(() async { + await tester.pumpWidget(tree(pane)); + f.services.events.emit(ProjectOpened(path: openPath ?? root)); + await Future.delayed(const Duration(milliseconds: 60)); + }); + await tester.pump(); + } + + // Run [fn] (a stream feed or an async command) in the real zone, then settle. + Future act(WidgetTester tester, FutureOr Function() fn) async { + await tester.runAsync(() async { + await fn(); + await Future.delayed(const Duration(milliseconds: 60)); + }); + await tester.pump(); + } + + ClaudeComposer composer(WidgetTester tester) => tester.widget(find.byType(ClaudeComposer)); + + testWidgets('spawns a session bound to the active repo and renders', (tester) async { + await mount(tester, const ClaudePane(showChrome: false)); + + final primary = orch.byId('primary'); + expect(primary, isNotNull); + expect(primary!.cwd, '/repo-a'); + expect(primary.sessionId, primarySessionId('/repo-a')); + expect(find.byType(ConversationView), findsOneWidget); + expect(find.byType(ClaudeComposer), findsOneWidget); + }); + + testWidgets('switching the workspace in place rebinds to the new repo (T-269)', (tester) async { + await mount(tester, const ClaudePane(showChrome: false)); + expect(orch.byId('primary')!.cwd, '/repo-a'); + final firstProc = created.single; + + root = '/repo-b'; + await act(tester, () => f.services.events.emit(const ProjectOpened(path: '/repo-b'))); + + final primary = orch.byId('primary')!; + expect(primary.cwd, '/repo-b'); + expect(primary.sessionId, primarySessionId('/repo-b')); + expect(firstProc.killed, isTrue); + expect(created, hasLength(2)); + }); + + testWidgets('a re-open of the same repo does not respawn', (tester) async { + await mount(tester, const ClaudePane(showChrome: false)); + expect(created, hasLength(1)); + await act(tester, () => f.services.events.emit(ProjectOpened(path: root))); + expect(created, hasLength(1), reason: 'same path → no rebind'); + }); + + testWidgets('sending a message writes to the session', (tester) async { + await mount(tester, const ClaudePane(showChrome: false)); + final proc = created.single; + composer(tester).onSubmit('hello there'); + await tester.pump(); + expect(proc.writes.any((w) => w.contains('hello there')), isTrue); + }); + + testWidgets('/clear empties the deterministic session in place', (tester) async { + await mount(tester, const ClaudePane(showChrome: false)); + final firstProc = created.single; + final id = orch.byId('primary')!.sessionId; + + await act(tester, () => composer(tester).onSubmit('/clear')); + + expect(firstProc.killed, isTrue); + expect(created, hasLength(2)); + // Re-bound to the SAME deterministic id (cleared in place, not a random id). + expect(orch.byId('primary')!.sessionId, id); + }); + + testWidgets('/fork delegates to the onFork callback with the session id', (tester) async { + String? forkedWith; + await mount(tester, ClaudePane(showChrome: false, onFork: (sid) => forkedWith = sid)); + composer(tester).onSubmit('/fork'); + await tester.pump(); + expect(forkedWith, primarySessionId('/repo-a')); + }); + + testWidgets('cycling permission mode sends a control message', (tester) async { + await mount(tester, const ClaudePane(showChrome: false)); + final proc = created.single; + composer(tester).onCycleMode!(); + await tester.pump(); + expect(proc.writes.any((w) => w.contains('permission')), isTrue); + }); + + testWidgets('composer draft is retained then cleared', (tester) async { + await mount(tester, const ClaudePane(showChrome: false)); + final c = composer(tester); + c.onDraftChanged!(const TextEditingValue(text: 'a draft')); + c.onDraftChanged!(TextEditingValue.empty); + await tester.pump(); + // No throw / no crash; draft round-trips through the pane's per-session map. + expect(find.byType(ClaudeComposer), findsOneWidget); + }); + + testWidgets('an init event populates the status line', (tester) async { + await mount(tester, const ClaudePane(showChrome: false)); + final proc = created.single; + await act( + tester, + () => proc.feed({ + 'type': 'system', + 'subtype': 'init', + 'model': 'claude-opus-4-8', + 'permissionMode': 'plan', + 'session_id': primarySessionId('/repo-a'), + })); + // The init event flows through the session into the pane's status path + // (the rendered slot lives in the status bar, absent from this harness). + expect(orch.byId('primary')!.session.status.permissionMode, 'plan'); + expect(orch.byId('primary')!.session.status.model, 'claude-opus-4-8'); + }); + + testWidgets('a can_use_tool request renders a prompt card in place of the composer', (tester) async { + await mount(tester, const ClaudePane(showChrome: false)); + final proc = created.single; + await act( + tester, + () => proc.feed({ + 'type': 'control_request', + 'request_id': 'req-1', + 'request': { + 'subtype': 'can_use_tool', + 'tool_name': 'Bash', + 'tool_use_id': 'tu-1', + 'input': {'command': 'ls'}, + }, + })); + expect(find.byType(ClaudeComposer), findsNothing, reason: 'prompt takes the composer slot (D-78)'); + }); + + testWidgets('a disconnected daemon surfaces an error instead of spawning', (tester) async { + f.ipc.setConnected(false); + await mount(tester, const ClaudePane(showChrome: false)); + expect(orch.byId('primary'), isNull); + expect(find.textContaining('not connected'), findsOneWidget); + }); + + testWidgets('a secondary pane spawns a fresh session under its own key', (tester) async { + await mount(tester, const ClaudePane(isPrimary: false, secondaryIndex: 1, showChrome: false)); + final secondary = orch.byId('secondary-1'); + expect(secondary, isNotNull); + expect(secondary!.cwd, '/repo-a'); + // Secondaries get a fresh random id, not the deterministic primary one. + expect(secondary.sessionId, isNot(primarySessionId('/repo-a'))); + // Disposing the secondary closes its session (the !isPrimary branch). + // Pump a different widget type so the pane's State is torn down, not reused. + await tester.pumpWidget(const SizedBox()); + await tester.pump(); + expect(orch.byId('secondary-1'), isNull); + }); + + testWidgets('tapping the conversation area focuses the composer', (tester) async { + await mount(tester, const ClaudePane(showChrome: false)); + await tester.tap(find.byType(ConversationView)); + await tester.pump(); + expect(find.byType(ClaudeComposer), findsOneWidget); + }); + + testWidgets('/resume opens the session picker; cancelling leaves the session', (tester) async { + await mount(tester, const ClaudePane(showChrome: false)); + final proc = created.single; + + await act(tester, () => composer(tester).onSubmit('/resume')); + expect(find.byType(SessionPickerDialog), findsOneWidget); + + // Cancel the picker → no rebind, original session untouched. + await act(tester, () => f.services.dialog.dismiss()); + expect(find.byType(SessionPickerDialog), findsNothing); + expect(created, hasLength(1)); + expect(proc.killed, isFalse); + }); +} diff --git a/test/builtin/claude/claude_session_host_test.dart b/test/builtin/claude/claude_session_host_test.dart new file mode 100644 index 00000000..dd9c4971 --- /dev/null +++ b/test/builtin/claude/claude_session_host_test.dart @@ -0,0 +1,94 @@ +/// Host-level behaviour for the Claude session tabs (T-269): an in-place +/// workspace switch (Open Project/Folder) must reset to a lone primary tab — +/// the previous repo's secondaries/forks don't belong in the new workspace. +/// +/// The embedded [ClaudePane]s take the "daemon not connected" path under the +/// fake IPC, so no real `claude` process is spawned; this exercises the host's +/// tab bookkeeping in isolation. +library; + +import 'package:clide/builtin/claude/src/claude_session_host.dart'; +import 'package:clide/kernel/kernel.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/kernel_fixture.dart'; + +Widget _host(KernelFixture f, Key key) => Directionality( + textDirection: TextDirection.ltr, + child: ClideKernel( + services: f.services, + child: ClideTheme( + controller: f.services.theme, + child: MediaQuery( + data: const MediaQueryData(), + child: Align( + alignment: Alignment.topLeft, + child: SizedBox( + // Bounded size so the reorderable tab strip's Draggable has a + // real width and the Overlay below isn't asked to self-size. + width: 800, + height: 600, + child: Overlay( + initialEntries: [OverlayEntry(builder: (_) => ClaudeSessionHost(key: key))], + ), + ), + ), + ), + ), + ), + ); + +void main() { + late KernelFixture f; + setUp(() async => f = await KernelFixture.create()); + tearDown(() => f.dispose()); + + testWidgets('in-place workspace switch drops secondaries, keeps primary (T-269)', (tester) async { + final key = GlobalKey(); + await tester.pumpWidget(_host(f, key)); + await tester.pump(); + final state = key.currentState!; + + state.addSecondary(); + state.addSecondary(); + await tester.pump(); + expect(state.tabIds, ['primary', 'secondary-1', 'secondary-2']); + + // Establish the baseline workspace; the first ProjectOpened is the initial + // open, not a switch, so it must NOT reset anything. (It also resolves each + // pane's "wait for project" so no spawn timer outlives the widget tree.) + f.services.events.emit(const ProjectOpened(path: '/repo-a')); + await tester.pump(); + expect(state.tabIds, ['primary', 'secondary-1', 'secondary-2']); + + // Switch to a different repo in place → back to just the primary. + f.services.events.emit(const ProjectOpened(path: '/repo-b')); + await tester.pump(); + expect(state.tabIds, ['primary']); + + // The secondary counter resets, so the next secondary is index 1 again. + // The redundant /repo-b emit resolves the new pane's wait without a reset. + state.addSecondary(); + f.services.events.emit(const ProjectOpened(path: '/repo-b')); + await tester.pump(); + expect(state.tabIds, ['primary', 'secondary-1']); + }); + + testWidgets('re-opening the same workspace is not a switch (no reset) (T-269)', (tester) async { + final key = GlobalKey(); + await tester.pumpWidget(_host(f, key)); + await tester.pump(); + final state = key.currentState!; + + f.services.events.emit(const ProjectOpened(path: '/repo-a')); + await tester.pump(); + state.addSecondary(); + await tester.pump(); + + // Same path again — a redundant re-open must keep the secondary. + f.services.events.emit(const ProjectOpened(path: '/repo-a')); + await tester.pump(); + expect(state.tabIds, ['primary', 'secondary-1']); + }); +}