replay-latest ValueStream for session state streams (T-386, T-274)

Broadcast streams drop the current value for late subscribers — the
shape behind T-274: the init event fires while spawn() is still
awaiting the transcript-tail read, before the pane subscribes, so the
status bar stayed blank. New pure-Dart ValueStream<T> (no rxdart —
prefer-zero-deps) replays the latest value to each new subscriber;
statusStream, busyStream, and pendingPromptStream in the claude
builtin now use it. busyStream subscribers see the current state
first (seeded false), which the busy test now asserts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 01:05:08 +02:00
co-authored by Claude Fable 5
parent 0e7353bf9c
commit 5f9c054420
8 changed files with 244 additions and 4 deletions
@@ -19,6 +19,7 @@ import 'dart:convert';
import 'dart:io';
import 'package:clide/builtin/claude/src/transcript_reader.dart';
import 'package:clide/src/util/value_stream.dart';
/// The claude subprocess, abstracted so tests drive it without spawning.
/// Fakes `extend` this and override what they drive; the defaults below
@@ -251,7 +252,9 @@ class StreamJsonSession {
/// round-trips are answered by [_handleMcpMessage].
final List<McpServer> _mcpServers;
final _items = StreamController<ConversationItem>.broadcast();
final _statusCtl = StreamController<SessionStatus>.broadcast();
// State, not events — replay-latest so a subscriber that binds after the
// init event still sees the current status (T-386; root cause of T-274).
final _statusCtl = ValueStream<SessionStatus>();
final _sessionIdCtl = StreamController<String>.broadcast();
StreamSubscription<String>? _sub;
SessionStatus _status = const SessionStatus();
@@ -284,7 +287,7 @@ class StreamJsonSession {
/// Prompts awaiting a [resolvePrompt] decision, in arrival order. The head
/// is the one currently shown in the composer zone.
final _queue = <ToolPrompt>[];
final _pendingCtl = StreamController<ToolPrompt?>.broadcast();
final _pendingCtl = ValueStream<ToolPrompt?>.seeded(null);
/// tool_use_ids that surfaced as a prompt — the view hides their raw
/// tool-use card while pending (it shows as a prompt) but keeps the result.
@@ -309,7 +312,7 @@ class StreamJsonSession {
/// Whether a turn is in flight (between a send and claude's `result`). Drives
/// the composer's Stop affordance.
bool _busy = false;
final _busyCtl = StreamController<bool>.broadcast();
final _busyCtl = ValueStream<bool>.seeded(false);
bool get busy => _busy;
Stream<bool> get busyStream => _busyCtl.stream;
+1
View File
@@ -26,6 +26,7 @@ export 'src/ipc/paths.dart';
export 'src/ipc/schema_v1.dart';
export 'src/panes/event_sink.dart';
export 'src/panes/pane.dart' show Pane, PaneKind;
export 'src/util/value_stream.dart' show ValueStream;
// clideName, clideTagline, clideVersion, clideRepository, clideCommit,
// clideDate live in lib/src/build_info.g.dart, regenerated by every
+70
View File
@@ -0,0 +1,70 @@
/// Replay-latest broadcast value holder (T-386).
///
/// Broadcast streams drop the current value for late subscribers — the
/// recurring bug factory behind T-274 (status bar blank because the
/// `system/init` event fired before the pane subscribed) and the
/// per-site `initialData` workarounds. A [ValueStream] carries STATE,
/// not events: every new subscriber immediately receives the latest
/// value (when one exists), then live updates.
///
/// Pure Dart — usable from the IPC/daemon layer and under `dart test`.
library;
import 'dart:async';
class ValueStream<T> {
ValueStream();
ValueStream.seeded(T value) : _value = value, _hasValue = true;
final StreamController<T> _ctl = StreamController<T>.broadcast();
T? _value;
bool _hasValue = false;
/// Whether a value has been added (or seeded) yet. A fresh, unseeded
/// holder replays nothing — subscribers wait for the first [add].
bool get hasValue => _hasValue;
/// The latest value, or null before the first [add]. For a nullable
/// [T], disambiguate with [hasValue].
T? get valueOrNull => _value;
/// The latest value. Throws [StateError] before the first [add] —
/// callers that can race the first value should use [valueOrNull].
T get value {
if (!_hasValue) throw StateError('ValueStream has no value yet');
return _value as T;
}
void add(T value) {
_value = value;
_hasValue = true;
if (!_ctl.isClosed) _ctl.add(value);
}
/// A stream that replays the latest value (if any) to its subscriber,
/// then follows live updates. Each access returns a fresh
/// single-subscription stream, so every listener gets its own replay.
Stream<T> get stream {
late StreamController<T> out;
StreamSubscription<T>? sub;
out = StreamController<T>(
onListen: () {
if (_hasValue) out.add(_value as T);
if (_ctl.isClosed) {
out.close();
return;
}
sub = _ctl.stream.listen(out.add, onError: out.addError, onDone: out.close);
},
onPause: () => sub?.pause(),
onResume: () => sub?.resume(),
onCancel: () => sub?.cancel(),
);
return out.stream;
}
bool get isClosed => _ctl.isClosed;
Future<void> close() => _ctl.close();
}