persist the Claude composer draft across UI swaps

T-228. Typing in the composer was lost whenever the input was torn down
and rebuilt — most visibly when a permission prompt takes the composer's
place (D-78), since the prompt card replaces the composer widget and its
TextEditingController went with it.

Hoist the draft out of the widget: ClaudeComposer gains initialValue +
onDraftChanged, and the pane holds a per-session draft map, seeding the
composer on (re)mount and clearing the entry on submit. Keying the
composer by session id means switching sessions in a pane swaps to that
session's own draft. Text and caret both survive.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-03 12:00:48 +02:00
co-authored by Claude Opus 4.8
parent 78c1e28c0e
commit b4a02dc533
6 changed files with 195 additions and 0 deletions
@@ -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<TextEditingValue>? onDraftChanged;
@override
State<ClaudeComposer> createState() => _ClaudeComposerState();
}
@@ -93,6 +105,12 @@ class _ClaudeComposerState extends State<ClaudeComposer> {
@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<ClaudeComposer> {
void _onTextChanged() {
setState(() {});
widget.onDraftChanged?.call(_controller.value);
_syncTypeahead();
}
+25
View File
@@ -77,6 +77,13 @@ class _ClaudePaneState extends State<ClaudePane> {
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<String, TextEditingValue> _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<ClaudePane> {
_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<ClaudePane> {
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,
),
),
],