diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index 819aa1a9..ec585d35 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -67,3 +67,8 @@ INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-214', 'status', 'backlog', 'done', NULL, '2026-06-03 09:18:38', '2026-06-03 09:18:38', '2026-06-03 09:18:38', NULL, '6266403d6e099befafc78b5d368e938a', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-220', 'status', 'backlog', 'in_progress', NULL, '2026-06-03 09:27:42', '2026-06-03 09:27:42', '2026-06-03 09:27:42', NULL, '53dffc8221a283770c138cc386633e21', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-219', 'status', 'backlog', 'in_progress', NULL, '2026-06-03 09:27:42', '2026-06-03 09:27:42', '2026-06-03 09:27:42', NULL, '54df040ce5ad0726393f51cf2742e540', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-226', 'status', 'backlog', 'ready', NULL, '2026-06-03 09:45:20', '2026-06-03 09:45:20', '2026-06-03 09:45:20', NULL, 'a0a8931ac0f3cc89e8410d6f910479c4', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-227', 'parent_id', NULL, 'T-229', NULL, '2026-06-03 09:51:40', '2026-06-03 09:51:40', '2026-06-03 09:51:40', NULL, '12656b6b9cde447b592fd199d7d47645', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-228', 'parent_id', NULL, 'T-229', NULL, '2026-06-03 09:51:40', '2026-06-03 09:51:40', '2026-06-03 09:51:40', NULL, '17b018dd9f5b4c6234dad6074155f4fc', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-163', 'parent_id', 'T-132', 'T-229', NULL, '2026-06-03 09:51:40', '2026-06-03 09:51:40', '2026-06-03 09:51:40', NULL, '5dc4470123514f2886b99d983c3f14f9', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-228', 'status', 'backlog', 'in_progress', NULL, '2026-06-03 09:54:14', '2026-06-03 09:54:14', '2026-06-03 09:54:14', NULL, 'e9fad0773bf9d930028187db8f219d9f', 1) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 50ddf290..bcc94672 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -92,3 +92,42 @@ INSERT INTO tickets (id, type, parent_id, title, description, status, priority, INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-219', 'task', 'T-218', 'Register built-in panes into the PaneRegistry the CLI reads', 'Make the kernel layout''s TabContribution panes (Claude / Files / Editor and any viewer) visible to `pane list` / `pane focus` by registering or mirroring them into PaneRegistry (lib/src/panes/registry.dart, read by pane_commands.dart:121). Acceptance: with the GUI open, `clide pane list` enumerates the live panes the user sees, with stable ids and focus state.', 'in_progress', 'high', NULL, NULL, NULL, '2026-06-02 18:13:52', '2026-06-03 09:27:42', NULL, 'dd0ea802eb1ddb83861c55b6fa6e7558', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-220', 'task', 'T-218', 'Reflect active editor buffer + focused file + selection into EditorRegistry', 'For UI-driven (non-IPC) opens and focus changes, mirror the active file + selection into EditorRegistry (lib/src/editor/registry.dart, read by editor_commands.dart:120/136) so `editor active` / `editor list` track what the user is actually looking at, not only IPC-opened buffers. Acceptance: focusing/opening a file in the GUI is reflected in `clide editor active`.', 'in_progress', 'high', NULL, NULL, NULL, '2026-06-02 18:13:52', '2026-06-03 09:27:42', NULL, 'f8be4d7da470d879f0c8e639261afbfa', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-226', 'task', NULL, 'Shift+Tab work-mode cycler on the focused Claude pane', 'Gap found while dogfooding (2026-06-03): the Claude pane shows the permission mode in its status line (permissionModeLabel, claude_status.dart:44) but has NO quick way to CHANGE it, unlike the CLI where Shift+Tab cycles work modes (plan / read / edit / yolo == plan, default, acceptEdits, bypassPermissions). Today the only interactive setter is the team meta-sidebar per-agent control (claude_meta_sidebar.dart:342); the primary pane has none. There is a command claude.agent.set-permission-mode (extension.dart:148) but it needs an explicit id + explicit mode, and there is no ''cycle'' verb. setPermissionMode() over the stream-json control channel already works (stream_json_session.dart:671, D-78). Scope: (1) add a mode-cycle action that targets the focused/primary session and advances the safe trio default -> acceptEdits -> plan -> default (bypassPermissions only via a confirmed/explicit path, per the T-181 footgun guard); (2) bind it to Shift+Tab INTERCEPTED at the focused Claude composer -- note shift+tab is globally focus.previous (default.yaml:31), so this needs consumer-level interception like the Vim editor (D-82), falling back to focus traversal elsewhere; (3) make the status-line mode label a clickable badge that cycles on click (the ''cockpit badge'' the stream_json_session comment already promises at line 668-669). Relates to T-181, D-77/D-78, D-82.', 'backlog', 'medium', NULL, NULL, 'D-78', '2026-06-03 09:37:54', '2026-06-03 09:37:54', NULL, '52f082ae878e8a59995084b25c70086d', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-226', 'task', NULL, 'Shift+Tab work-mode cycler on the focused Claude pane', 'Gap found while dogfooding (2026-06-03): the Claude pane shows the permission mode in its status line (permissionModeLabel, claude_status.dart:44) but has NO quick way to CHANGE it, unlike the CLI where Shift+Tab cycles work modes (plan / read / edit / yolo == plan, default, acceptEdits, bypassPermissions). Today the only interactive setter is the team meta-sidebar per-agent control (claude_meta_sidebar.dart:342); the primary pane has none. There is a command claude.agent.set-permission-mode (extension.dart:148) but it needs an explicit id + explicit mode, and there is no ''cycle'' verb. setPermissionMode() over the stream-json control channel already works (stream_json_session.dart:671, D-78). Scope: (1) add a mode-cycle action that targets the focused/primary session and advances the safe trio default -> acceptEdits -> plan -> default (bypassPermissions only via a confirmed/explicit path, per the T-181 footgun guard); (2) bind it to Shift+Tab INTERCEPTED at the focused Claude composer -- note shift+tab is globally focus.previous (default.yaml:31), so this needs consumer-level interception like the Vim editor (D-82), falling back to focus traversal elsewhere; (3) make the status-line mode label a clickable badge that cycles on click (the ''cockpit badge'' the stream_json_session comment already promises at line 668-669). Relates to T-181, D-77/D-78, D-82.', 'ready', 'medium', NULL, NULL, 'D-78', '2026-06-03 09:37:54', '2026-06-03 09:45:20', NULL, '066af028173ddba082553e1ea4d17cd0', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-229', 'epic', NULL, 'Claude composer ergonomics: focus, persistence, and history', 'Make the Claude composer (the prompt input at the bottom of the Claude pane) feel effortless to use. Groups the input-box ergonomics work: + +- T-227 — click empty pane area to focus the composer +- T-228 — preserve the in-progress draft across UI changes (permission prompts etc.) +- T-163 — Up/Down arrow recall of prompt history (Claude-CLI-style), triggered when the caret hits the top/bottom edge of the field + +Theme: the composer should be easy to focus, never lose what you typed, and let you recall and re-send prior prompts the way the Claude CLI does. These are independent leaves that share the composer surface and per-session draft/history state, so they''re worth coordinating.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-03 09:51:28', '2026-06-03 09:51:28', NULL, '5486c56c01d5959e253e644daa05abb9', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-163', 'task', 'T-229', 'Composer prompt history via Up/Down (Claude-CLI-style)', 'Arrow Up/Down in the Claude composer should scroll through previously-sent prompts, matching the Claude Code CLI UX: +- Up: if the caret isn''t on the first visual line, move up one line (normal editing); once on the first line, Up steps to the previous prompt in history. Down mirrors it (move down within multiline; at the last line, step to the next/newer entry). +- When entering history, stash the current draft as the newest entry so Down past the most recent restores exactly what the user was typing. +- History is the sequence of prompts submitted from this composer (this session at least; persisting across restarts is a nice-to-have). +- Plays well with the existing Enter-submit / Shift+Enter-newline and the slash typeahead (Up/Down already drive the typeahead when it''s open — history nav only applies when the typeahead is closed). + +Implementation: a small history controller + caret-line detection in ClaudeComposer (EditableText); the Up/Down handling lives alongside the existing _onKey/Shortcuts. Acceptance: send a few prompts, Up walks back through them, Down returns through them to the restored draft; multiline prompts navigate line-by-line before crossing into history; widget tests for the bound conditions (first/last line) and draft stash/restore.', 'backlog', 'medium', NULL, NULL, NULL, '2026-05-24 09:42:44', '2026-06-03 09:51:40', NULL, '6782b760791a181507df5c3f3d9f6847', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-227', 'story', 'T-229', 'Click empty Claude-pane area to focus the composer', 'Clicking anywhere in the Claude pane that is NOT an interactive control (copy button, approve/reject, AskUserQuestion options, links, selectable transcript text, scrollbars, tabs) should move keyboard focus to the composer text field at the bottom. Today focusing the composer requires precisely clicking the field; a click on the conversation background does nothing, so the input is fiddly to focus. + +Goal: make the composer easy to focus — a click on dead space in the pane lands the cursor in the input, like clicking an empty area of a chat app focuses its reply box. + +Acceptance: +1. A tap on non-interactive Claude-pane background focuses the composer input (cursor ready to type). +2. Taps on actual controls keep their behavior (copy, approve/reject, AskUserQuestion choices, tab strip, links) — the focus-grab must not swallow or pre-empt their gestures. +3. Text selection in the transcript still works: a click-drag to select must NOT steal focus to the composer (only a plain tap/click-release with no selection focuses it). +4. Interaction-zone nuance (D-78): when a permission / AskUserQuestion prompt has replaced the composer in the interaction zone, a background click should focus that prompt''s primary control instead of the (absent) composer — or do nothing — never resurrect the composer. + +Implementation sketch: wrap the pane body in a tap recognizer (behavior: translucent / deferToChild) that, on a tap that did not hit a child gesture and produced no text selection, requests focus on the composer FocusNode. Mind the SelectableRegion so selection drags aren''t hijacked.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-03 09:48:23', '2026-06-03 09:51:40', NULL, '77761c0034f51145c210cecb75a104cb', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-228', 'bug', 'T-229', 'Composer draft lost when the Claude pane UI changes', 'Text typed into the Claude composer is lost whenever the pane UI changes underneath it. The clearest trigger: a permission prompt / AskUserQuestion arrives and the interaction zone replaces the composer; when it resolves and the composer returns, the half-typed message is gone. It can also drop on any pane rebuild/refresh (new transcript message, status change, tab re-render) if the draft lives only in ephemeral widget State. + +Make the draft persistent: an unsent composer message must survive UI changes and come back intact. + +Likely cause: the composer''s TextEditingController lives in widget State that is disposed/recreated when the pane rebuilds or the interaction zone swaps the composer out. The draft (text + selection) needs to be hoisted somewhere that outlives the widget. + +Acceptance: +1. Type in the composer, trigger a permission prompt (composer replaced by the interaction zone), resolve it -> the composer returns with the typed text and caret intact. +2. The draft survives an arbitrary Claude-pane rebuild (incoming transcript message, status update, sub-tab switch) without loss. +3. Draft is per-session: switching between Claude sessions/panes preserves each session''s own unsent text; sending or explicitly clearing empties it. +4. Selection/caret position preserved, not just the string (nice-to-have). + +Implementation sketch: store the draft (text + selection) on the per-session model/controller (kernel- or session-scoped), not in the composer widget''s State. Seed the TextEditingController from it on mount and write back on change, so a remount after the interaction zone closes restores it. Relates to T-227 (focusing the composer) and the interaction-zone behavior where prompts replace the composer rather than rendering inline.', 'in_progress', 'high', NULL, NULL, NULL, '2026-06-03 09:49:35', '2026-06-03 09:54:14', NULL, '768ef9874198e50fa92f208e4ada79e0', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); diff --git a/CHANGELOG.md b/CHANGELOG.md index f658cafe..37e40d13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,6 +67,10 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Fixed +- The Claude composer no longer loses a half-typed message when the UI changes + under it — e.g. a permission prompt taking the composer's place. The draft + (text and caret) is kept per session and restored when the composer returns. + (T-228) - File watcher no longer emits change events for files inside ignored directories (`.dart_tool/`, `build/`, etc.): it now checks ancestor dirs, not just the leaf. Most visible on macOS, where FSEvents delivers the nested diff --git a/lib/builtin/claude/src/claude_composer.dart b/lib/builtin/claude/src/claude_composer.dart index db4ec061..15d5a68a 100644 --- a/lib/builtin/claude/src/claude_composer.dart +++ b/lib/builtin/claude/src/claude_composer.dart @@ -48,6 +48,8 @@ class ClaudeComposer extends StatefulWidget { this.slashCommandsResolver, this.onInterrupt, this.busy = false, + this.initialValue, + this.onDraftChanged, }); /// Called with the composed message (typed text plus attachment `@path` @@ -74,6 +76,16 @@ class ClaudeComposer extends StatefulWidget { /// Whether a turn is in flight; shows the Stop affordance. final bool busy; + /// Seed value (text + selection) the composer mounts with — the + /// persisted per-session draft (T-228). The composer restores this on + /// init so an in-progress message survives the composer being torn down + /// and rebuilt (e.g. a permission prompt taking its place, D-78). + final TextEditingValue? initialValue; + + /// Reports every change to the composer's value so the owner can persist + /// the draft. Fires with an empty value on submit/clear (T-228). + final ValueChanged? onDraftChanged; + @override State createState() => _ClaudeComposerState(); } @@ -93,6 +105,12 @@ class _ClaudeComposerState extends State { @override void initState() { super.initState(); + // Restore the persisted draft (text + caret) before wiring listeners, + // so seeding doesn't echo back through onDraftChanged (T-228). + final seed = widget.initialValue; + if (seed != null && seed.text.isNotEmpty) { + _controller.value = seed; + } _controller.addListener(_onTextChanged); _focus.addListener(_onFocusChanged); } @@ -109,6 +127,7 @@ class _ClaudeComposerState extends State { void _onTextChanged() { setState(() {}); + widget.onDraftChanged?.call(_controller.value); _syncTypeahead(); } diff --git a/lib/builtin/claude/src/claude_pane.dart b/lib/builtin/claude/src/claude_pane.dart index e841887f..87fc64c1 100644 --- a/lib/builtin/claude/src/claude_pane.dart +++ b/lib/builtin/claude/src/claude_pane.dart @@ -77,6 +77,13 @@ class _ClaudePaneState extends State { bool _spawned = false; + /// Per-session composer draft (text + caret), held here so an unsent + /// message survives the composer being torn down and rebuilt — e.g. a + /// permission prompt taking the composer's place (D-78) or a session + /// switch within this pane (T-228). Keyed by claude session id; cleared + /// when the message is sent. + final Map _drafts = {}; + /// This pane's stable key in the session orchestrator (T-169). String get _orchId => widget.isPrimary ? 'primary' : 'secondary-${widget.secondaryIndex}'; @@ -256,6 +263,19 @@ class _ClaudePaneState extends State { _session?.send(text); } + /// Persist (or clear) the composer draft for the active session (T-228). + /// The composer reports an empty value on submit/clear, which drops the + /// entry so a sent message doesn't reappear. + void _onDraftChanged(TextEditingValue value) { + final id = _sessionId; + if (id == null) return; + if (value.text.isEmpty) { + _drafts.remove(id); + } else { + _drafts[id] = value; + } + } + /// clide-owned `/fork` (T-172): branch this conversation into a new pane. /// /// Delegates to the [onFork] callback supplied by [ClaudeSessionHost] with @@ -366,11 +386,16 @@ class _ClaudePaneState extends State { stream: _session?.busyStream, initialData: _session?.busy ?? false, builder: (context, busySnap) => ClaudeComposer( + // Key by session so switching sessions in this pane + // remounts the composer with that session's own draft. + key: ValueKey('composer-${_sessionId ?? 'pending'}'), enabled: _session != null, busy: busySnap.data ?? false, onInterrupt: _session?.interrupt, onSubmit: _send, pasteResolver: () => resolveClipboardAttachment(const NativeClipboard()), + initialValue: _sessionId == null ? null : _drafts[_sessionId], + onDraftChanged: _onDraftChanged, ), ), ], diff --git a/test/builtin/claude/claude_composer_test.dart b/test/builtin/claude/claude_composer_test.dart index 27fb29bd..ad94186a 100644 --- a/test/builtin/claude/claude_composer_test.dart +++ b/test/builtin/claude/claude_composer_test.dart @@ -6,6 +6,7 @@ library; import 'package:clide/builtin/claude/src/claude_composer.dart'; import 'package:clide/builtin/claude/src/clipboard_paste.dart'; import 'package:clide/builtin/claude/src/slash_commands.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -334,4 +335,106 @@ void main() { expect(kClideOwnedCommands, contains('fork')); }); }); + + group('ClaudeComposer draft persistence (T-228)', () { + late KernelFixture f; + setUp(() async => f = await KernelFixture.create()); + tearDown(() => f.dispose()); + + testWidgets('seeds the field from initialValue on mount', (tester) async { + await tester.pumpWidget(harness( + f, + ClaudeComposer( + onSubmit: (_) {}, + initialValue: const TextEditingValue( + text: 'half-typed', + selection: TextSelection.collapsed(offset: 4), + ), + ), + )); + final controller = tester.widget(find.byType(EditableText)).controller; + expect(controller.text, 'half-typed'); + expect(controller.selection.baseOffset, 4); // caret restored too + }); + + testWidgets('reports draft changes (text + caret) via onDraftChanged', (tester) async { + final drafts = []; + await tester.pumpWidget(harness( + f, + ClaudeComposer(onSubmit: (_) {}, onDraftChanged: drafts.add), + )); + await tester.enterText(find.byType(EditableText), 'draft text'); + await tester.pump(); + + expect(drafts.last.text, 'draft text'); + }); + + testWidgets('reports an empty draft when submitting clears the field', (tester) async { + final drafts = []; + await tester.pumpWidget(harness( + f, + ClaudeComposer(onSubmit: (_) {}, onDraftChanged: drafts.add), + )); + await tester.enterText(find.byType(EditableText), 'send me'); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + + // The last reported value is empty — owners drop the draft so a sent + // message doesn't come back. + expect(drafts.last.text, isEmpty); + }); + + testWidgets('round-trips a draft across an interaction-zone swap', (tester) async { + // Mirrors the pane's pattern: a host holds the draft and shows either + // the prompt (composer gone) or the composer seeded from that draft. + final showPrompt = ValueNotifier(false); + addTearDown(showPrompt.dispose); + await tester.pumpWidget(harness(f, _DraftSwapHost(showPrompt: showPrompt))); + + await tester.enterText(find.byType(EditableText), 'survived the prompt'); + await tester.pump(); + + // Prompt arrives — the composer is torn down. + showPrompt.value = true; + await tester.pump(); + expect(find.byType(EditableText), findsNothing); + + // Prompt resolved — composer remounts and restores the draft. + showPrompt.value = false; + await tester.pump(); + expect( + tester.widget(find.byType(EditableText)).controller.text, + 'survived the prompt', + ); + }); + }); +} + +/// Mimics the pane's draft handling (T-228): holds a per-host draft and +/// renders either a prompt placeholder (composer torn down) or the +/// composer seeded from that draft. +class _DraftSwapHost extends StatefulWidget { + const _DraftSwapHost({required this.showPrompt}); + final ValueListenable showPrompt; + @override + State<_DraftSwapHost> createState() => _DraftSwapHostState(); +} + +class _DraftSwapHostState extends State<_DraftSwapHost> { + TextEditingValue? _draft; + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: widget.showPrompt, + builder: (context, prompt, _) => prompt + ? const SizedBox.shrink() + : ClaudeComposer( + onSubmit: (_) {}, + initialValue: _draft, + onDraftChanged: (v) => _draft = v, + ), + ); + } }