fix decision first-click + editor reveal via retained reader nav

Two reveal-on-open bugs:

Decisions opened only on the second click (T-196): the detail view
subscribed in didChangeDependencies, which runs after the tab is
revealed, so the broadcast 'selection' that triggered the reveal was
already gone. Hoist the back/forward history out of per-view State into
a retained per-reader ReaderNav (kernel ChangeNotifier in a
ReaderNavRegistry, D-81). The nav records selections, emits 'load' (the
single channel readers display from), and survives mount/unmount — the
reader grabs nav.current on mount, so the first selection lands. Both
the markdown and decisions readers move to this model; the per-view
ReaderHistoryMixin and the markdown post-frame forward hack are gone.

The editor pane never opened (T-197): EditorExtension contributed a
workspace tab but nothing activated it on editor.open. Add an activate()
that reveals the tab on editor.opened / editor.active-changed; the
view's hydrate() pulls the active buffer on mount.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-01 12:45:48 +02:00
co-authored by Claude Opus 4.8
parent 1db65f8481
commit 0eb7b0df2f
17 changed files with 499 additions and 460 deletions
@@ -14,32 +14,43 @@ class DecisionDetailView extends StatefulWidget {
State<DecisionDetailView> createState() => _DecisionDetailViewState();
}
class _DecisionDetailViewState extends State<DecisionDetailView> with ReaderHistoryMixin<String, DecisionDetailView> {
class _DecisionDetailViewState extends State<DecisionDetailView> {
Map<String, Object?>? _decision;
bool _loading = false;
StreamSubscription<Message>? _sub;
ReaderNav? _nav;
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_sub != null) return;
final kernel = ClideKernel.of(context);
_sub = kernel.messages.subscribe(publisher: 'builtin.decisions', channel: 'selection').listen((msg) {
// The back/forward history is the retained right-pane nav (T-196):
// it records selections (even before this view mounts) and re-emits
// them on the single 'load' channel this view loads from.
_nav = kernel.readerNav.navFor('builtin.decisions', dataKey: 'id')..addListener(_onNavChanged);
_sub = kernel.messages.subscribe(publisher: 'builtin.decisions', channel: 'load').listen((msg) {
final id = msg.data['id'] as String?;
if (id != null) unawaited(_load(id));
});
if (widget.initialId != null) {
unawaited(_load(widget.initialId!));
}
// Grab the latest entry the nav already holds — the selection that
// revealed this tab arrived before we could subscribe to 'load'.
final current = _nav!.current ?? widget.initialId;
if (current != null) unawaited(_load(current));
}
void _onNavChanged() {
if (mounted) setState(() {}); // refresh action-bar button state (pin, etc.)
}
@override
void dispose() {
_sub?.cancel();
_nav?.removeListener(_onNavChanged);
super.dispose();
}
/// Load [id] and push it onto the history stack (external navigation).
/// Fetch + display [id]. History lives in [ReaderNav]; this never pushes.
Future<void> _load(String id) async {
setState(() => _loading = true);
final kernel = ClideKernel.of(context);
@@ -52,42 +63,12 @@ class _DecisionDetailViewState extends State<DecisionDetailView> with ReaderHist
_loading = false;
_decision = resp.ok ? resp.data : null;
});
if (resp.ok) historyPush(id);
}
/// Load [id] WITHOUT pushing onto the history stack (back/forward nav).
Future<void> _loadInPlace(String id) async {
setState(() => _loading = true);
final kernel = ClideKernel.of(context);
final resp = await kernel.ipc.request('pql.decisions.read', args: {'id': id});
if (!mounted) return;
if (resp.ok) {
kernel.messages.publish('builtin.decisions', 'focus', {'id': id});
}
setState(() {
_loading = false;
_decision = resp.ok ? resp.data : null;
});
}
void _onBack() {
final entry = historyBack();
if (entry != null) _loadInPlace(entry);
}
void _onForward() {
final entry = historyForward();
if (entry != null) _loadInPlace(entry);
}
void _onPin() {
pinCurrent();
}
void _onJumpToPin() {
final entry = jumpToPin();
if (entry != null) _loadInPlace(entry);
}
void _onBack() => _nav?.back();
void _onForward() => _nav?.forward();
void _onPin() => _nav?.pin();
void _onJumpToPin() => _nav?.jumpToPin();
void _onEdit() {
final filePath = _decision?['file_path'] as String?;
@@ -131,13 +112,13 @@ class _DecisionDetailViewState extends State<DecisionDetailView> with ReaderHist
subtitle: title,
trailing: [
ReaderActionBar(
canGoBack: canGoBack,
canGoForward: canGoForward,
hasPinned: hasPinned,
onBack: canGoBack ? _onBack : null,
onForward: canGoForward ? _onForward : null,
canGoBack: _nav?.canGoBack ?? false,
canGoForward: _nav?.canGoForward ?? false,
hasPinned: _nav?.hasPinned ?? false,
onBack: (_nav?.canGoBack ?? false) ? _onBack : null,
onForward: (_nav?.canGoForward ?? false) ? _onForward : null,
onPin: _decision != null ? _onPin : null,
onJumpToPin: hasPinned ? _onJumpToPin : null,
onJumpToPin: (_nav?.hasPinned ?? false) ? _onJumpToPin : null,
onEdit: filePath != null ? _onEdit : null,
),
],
+4
View File
@@ -20,6 +20,10 @@ class DecisionsExtension extends ClideExtension {
@override
Future<void> activate(ClideExtensionContext ctx) async {
// Ensure the retained nav exists so it records selections + emits
// loads whether or not the reader is mounted (T-196). This handler
// only reveals the tab; the nav owns load + history.
ctx.readerNav.navFor(id, dataKey: 'id');
_sub = ctx.messages.subscribe(publisher: id, channel: 'selection').listen((msg) {
if (msg.data['id'] is! String) return;
ctx.arrangement.setVisible(Slots.contextPanel, true);
+21
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:clide/builtin/editor/src/editor_view.dart';
import 'package:clide/extension/extension.dart';
import 'package:clide/kernel/kernel.dart';
@@ -15,6 +17,25 @@ class EditorExtension extends ClideExtension {
@override
List<String> get dependsOn => const [];
StreamSubscription<DaemonEvent>? _sub;
/// Reveal the editor tab when a buffer opens or becomes active.
/// `editor.open` opens the buffer daemon-side and emits the event,
/// but nothing else brings the workspace tab to front — without this
/// the editor never appears over the Claude pane (T-197). The view's
/// `hydrate()` pulls the active buffer once it mounts.
@override
Future<void> activate(ClideExtensionContext ctx) async {
_sub = ctx.events.on<DaemonEvent>().listen((e) {
if (e.subsystem != 'editor') return;
if (e.kind != 'editor.opened' && e.kind != 'editor.active-changed') return;
ctx.panels.activateTab(Slots.workspace, 'editor.active');
});
}
@override
Future<void> deactivate() async => _sub?.cancel();
@override
List<ContributionPoint> get contributions => [
TabContribution(
+5 -6
View File
@@ -4,7 +4,6 @@ import 'package:clide/builtin/markdown/src/markdown_viewer.dart';
import 'package:clide/extension/extension.dart';
import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
class MarkdownExtension extends ClideExtension {
@override
@@ -31,13 +30,13 @@ class MarkdownExtension extends ClideExtension {
@override
Future<void> activate(ClideExtensionContext ctx) async {
// Ensure the retained nav exists so it records selections + emits
// loads whether or not the viewer is mounted (T-196). This handler
// only reveals the tab; the nav owns load + history.
ctx.readerNav.navFor(id, dataKey: 'path');
_sub = ctx.messages.subscribe(publisher: id, channel: 'selection').listen((msg) {
final path = msg.data['path'] as String?;
if (path == null) return;
if (msg.data['path'] is! String) return;
ctx.panels.activateTab(Slots.contextPanel, 'markdown.viewer');
WidgetsBinding.instance.addPostFrameCallback((_) {
ctx.messages.publish(id, 'load', {'path': path});
});
});
}
+25 -47
View File
@@ -12,30 +12,43 @@ class MarkdownViewer extends StatefulWidget {
State<MarkdownViewer> createState() => _MarkdownViewerState();
}
class _MarkdownViewerState extends State<MarkdownViewer> with ReaderHistoryMixin<String, MarkdownViewer> {
class _MarkdownViewerState extends State<MarkdownViewer> {
String? _path;
String? _content;
String? _error;
StreamSubscription<Message>? _selectionSub;
ReaderNav? _nav;
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_selectionSub != null) return;
final kernel = ClideKernel.of(context);
// Back/forward history is the retained right-pane nav (T-196); it
// records selections and re-emits them on the 'load' channel.
_nav = kernel.readerNav.navFor('builtin.markdown', dataKey: 'path')..addListener(_onNavChanged);
_selectionSub = kernel.messages.subscribe(publisher: 'builtin.markdown', channel: 'load').listen((msg) {
final path = msg.data['path'] as String?;
if (path != null) _loadFile(path);
});
// Grab the latest entry the nav already holds (a selection that
// revealed this tab before we subscribed).
final current = _nav!.current;
if (current != null) _loadFile(current);
}
void _onNavChanged() {
if (mounted) setState(() {}); // refresh action-bar button state
}
@override
void dispose() {
_selectionSub?.cancel();
_nav?.removeListener(_onNavChanged);
super.dispose();
}
/// Load [path] and push it onto the history stack (external navigation).
/// Fetch + display [path]. History lives in [ReaderNav]; never pushes.
Future<void> _loadFile(String path) async {
final kernel = ClideKernel.of(context);
final resp = await kernel.ipc.request('files.read', args: {'path': path});
@@ -47,50 +60,15 @@ class _MarkdownViewerState extends State<MarkdownViewer> with ReaderHistoryMixin
_content = resp.data['content'] as String? ?? '';
_error = null;
});
historyPush(path);
} else {
setState(() => _error = resp.error?.message);
}
}
/// Load [path] WITHOUT pushing onto the history stack (back/forward nav).
Future<void> _loadFileInPlace(String path) async {
final kernel = ClideKernel.of(context);
final resp = await kernel.ipc.request('files.read', args: {'path': path});
if (!mounted) return;
if (resp.ok) {
kernel.messages.publish('builtin.markdown', 'focus', {'path': path});
setState(() {
_path = path;
_content = resp.data['content'] as String? ?? '';
_error = null;
});
} else {
setState(() => _error = resp.error?.message);
}
}
void _onBack() {
final entry = historyBack();
if (entry != null) _loadFileInPlace(entry);
}
void _onForward() {
final entry = historyForward();
if (entry != null) _loadFileInPlace(entry);
}
void _onPin() {
pinCurrent();
}
void _onJumpToPin() {
final entry = jumpToPin();
if (entry != null) {
// Navigate directly without re-pushing (pin jump is a quick-return).
_loadFileInPlace(entry);
}
}
void _onBack() => _nav?.back();
void _onForward() => _nav?.forward();
void _onPin() => _nav?.pin();
void _onJumpToPin() => _nav?.jumpToPin();
void _onEdit() {
final p = _path;
@@ -127,13 +105,13 @@ class _MarkdownViewerState extends State<MarkdownViewer> with ReaderHistoryMixin
subtitle: '${_content!.split('\n').length} lines',
trailing: [
ReaderActionBar(
canGoBack: canGoBack,
canGoForward: canGoForward,
hasPinned: hasPinned,
onBack: canGoBack ? _onBack : null,
onForward: canGoForward ? _onForward : null,
canGoBack: _nav?.canGoBack ?? false,
canGoForward: _nav?.canGoForward ?? false,
hasPinned: _nav?.hasPinned ?? false,
onBack: (_nav?.canGoBack ?? false) ? _onBack : null,
onForward: (_nav?.canGoForward ?? false) ? _onForward : null,
onPin: _path != null ? _onPin : null,
onJumpToPin: hasPinned ? _onJumpToPin : null,
onJumpToPin: (_nav?.hasPinned ?? false) ? _onJumpToPin : null,
onEdit: _path != null ? _onEdit : null,
),
],
+4 -107
View File
@@ -1,118 +1,15 @@
/// Shared action-bar chrome for sidebar reader widgets (T-189, T-190, T-191).
///
/// Provides:
/// - [ReaderHistory] — back/forward history stack with browser semantics.
/// - [ReaderActionBar] — the visible action bar widget: back, forward, pin,
/// edit pencil. Plugs into a [ClidePaneChrome] via its `trailing:` slot.
///
/// Usage: mix [ReaderHistoryMixin] into a [State] to get back/forward/pin state
/// management, then put a [ReaderActionBar] in [ClidePaneChrome.trailing].
/// Provides [ReaderActionBar] — the visible action bar: back, forward, pin,
/// edit pencil. Plugs into a [ClidePaneChrome] via its `trailing:` slot. The
/// retained back/forward history that drives it is the kernel `ReaderNav`
/// (one per right-pane reader); this file is just the buttons.
library;
import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
// ---------------------------------------------------------------------------
// History model
// ---------------------------------------------------------------------------
/// Back/forward history stack. [T] is the entry type (String path for
/// markdown, String id for decisions).
///
/// Standard browser semantics:
/// - [push] appends at [_index+1] and truncates any forward entries.
/// - [back]/[forward] adjust the index without re-pushing.
/// - [canGoBack]/[canGoForward] drive the enabled state of the buttons.
class ReaderHistory<T> {
final List<T> _stack = [];
int _index = -1;
bool get canGoBack => _index > 0;
bool get canGoForward => _index < _stack.length - 1;
T? get current => _index >= 0 && _index < _stack.length ? _stack[_index] : null;
/// Push a new entry, truncating any forward history.
void push(T entry) {
if (_index >= 0 && _stack[_index] == entry) {
// Same entry as current — don't push a duplicate.
return;
}
// Truncate forward history.
if (_index < _stack.length - 1) {
_stack.removeRange(_index + 1, _stack.length);
}
_stack.add(entry);
_index = _stack.length - 1;
}
/// Move back one step. Returns the entry now current, or null.
T? back() {
if (!canGoBack) return null;
_index--;
return _stack[_index];
}
/// Move forward one step. Returns the entry now current, or null.
T? forward() {
if (!canGoForward) return null;
_index++;
return _stack[_index];
}
}
// ---------------------------------------------------------------------------
// Mixin
// ---------------------------------------------------------------------------
/// Mix into a [State] that owns a [ReaderHistory] and an optional pin.
///
/// The concrete state must call [historyPush] whenever it loads a new
/// entry (NOT when navigating back/forward — those call [historyBack] /
/// [historyForward] and then load the returned entry without re-pushing).
mixin ReaderHistoryMixin<T, W extends StatefulWidget> on State<W> {
final ReaderHistory<T> _history = ReaderHistory<T>();
T? _pinned;
bool get canGoBack => _history.canGoBack;
bool get canGoForward => _history.canGoForward;
bool get hasPinned => _pinned != null;
T? get pinnedEntry => _pinned;
/// Record that the reader is now showing [entry]. Must be called AFTER
/// setState has been applied so the action-bar buttons rebuild.
void historyPush(T entry) {
setState(() => _history.push(entry));
}
/// Navigate back. Returns the entry to load, or null if already at start.
T? historyBack() {
final entry = _history.back();
if (entry != null) setState(() {});
return entry;
}
/// Navigate forward. Returns the entry to load, or null if at end.
T? historyForward() {
final entry = _history.forward();
if (entry != null) setState(() {});
return entry;
}
/// Set or replace the pin with the current entry.
void pinCurrent() {
final cur = _history.current;
if (cur == null) return;
setState(() => _pinned = cur);
}
/// Returns the pinned entry, or null if none set.
T? jumpToPin() {
return _pinned;
}
}
// ---------------------------------------------------------------------------
// Action bar widget
// ---------------------------------------------------------------------------
+2
View File
@@ -2,6 +2,7 @@ import 'package:clide/extension/src/contribution.dart';
import 'package:clide/kernel/src/clipboard.dart';
import 'package:clide/kernel/src/commands/palette.dart';
import 'package:clide/kernel/src/commands/registry.dart';
import 'package:clide/kernel/src/reader_nav.dart';
import 'package:clide/kernel/src/dialog.dart';
import 'package:clide/kernel/src/events/bus.dart';
import 'package:clide/kernel/src/events/message_bus.dart';
@@ -63,6 +64,7 @@ abstract class ClideExtensionContext {
LayoutArrangement get arrangement;
CommandRegistry get commands;
PaletteController get palette;
ReaderNavRegistry get readerNav;
ClideClipboard get clipboard;
FileServices get files;
Notifications get notify;
+1
View File
@@ -41,6 +41,7 @@ export 'src/os.dart';
export 'src/panels/arrangement.dart';
export 'src/project.dart';
export 'src/quick_open.dart';
export 'src/reader_nav.dart';
export 'src/recent_files.dart';
export 'src/scheduler.dart';
export 'src/text_zoom.dart';
+5
View File
@@ -5,6 +5,7 @@ import 'package:clide/extension/src/extension.dart';
import 'package:clide/kernel/src/clipboard.dart';
import 'package:clide/kernel/src/commands/keybindings.dart';
import 'package:clide/kernel/src/commands/palette.dart';
import 'package:clide/kernel/src/reader_nav.dart';
import 'package:clide/kernel/src/commands/registry.dart';
import 'package:clide/kernel/src/dialog.dart';
import 'package:clide/kernel/src/events/bus.dart';
@@ -40,6 +41,7 @@ class ExtensionManager extends ChangeNotifier {
required this.arrangement,
required this.commands,
required this.palette,
required this.readerNav,
required this.keybindings,
required this.keymap,
required this.clipboard,
@@ -65,6 +67,7 @@ class ExtensionManager extends ChangeNotifier {
final LayoutArrangement arrangement;
final CommandRegistry commands;
final PaletteController palette;
final ReaderNavRegistry readerNav;
final KeybindingResolver keybindings;
final KeymapService keymap;
final ClideClipboard clipboard;
@@ -289,6 +292,8 @@ class _ExtensionContext implements ClideExtensionContext {
@override
PaletteController get palette => manager.palette;
@override
ReaderNavRegistry get readerNav => manager.readerNav;
@override
ClideClipboard get clipboard => manager.clipboard;
@override
FileServices get files => manager.files;
+7
View File
@@ -24,6 +24,7 @@ import 'package:clide/kernel/src/panels/arrangement.dart';
import 'package:clide/kernel/src/panels/registry.dart';
import 'package:clide/kernel/src/project.dart';
import 'package:clide/kernel/src/quick_open.dart';
import 'package:clide/kernel/src/reader_nav.dart';
import 'package:clide/kernel/src/recent_files.dart';
import 'package:clide/kernel/src/scheduler.dart';
import 'package:clide/kernel/src/secrets.dart';
@@ -53,6 +54,7 @@ class KernelServices {
required this.palette,
required this.quickOpen,
required this.recentFiles,
required this.readerNav,
required this.keybindings,
required this.clipboard,
required this.files,
@@ -85,6 +87,7 @@ class KernelServices {
final PaletteController palette;
final QuickOpenController quickOpen;
final RecentFilesService recentFiles;
final ReaderNavRegistry readerNav;
final KeybindingResolver keybindings;
final ClideClipboard clipboard;
final FileServices files;
@@ -148,6 +151,7 @@ class KernelServices {
final palette = PaletteController(commands);
final recentFiles = RecentFilesService();
final quickOpen = QuickOpenController(recentPaths: () => recentFiles.paths);
final readerNav = ReaderNavRegistry(messages);
final clipboard = ClideClipboard();
final files = FileServices(events);
final notify = Notifications();
@@ -196,6 +200,7 @@ class KernelServices {
arrangement: arrangement,
commands: commands,
palette: palette,
readerNav: readerNav,
keybindings: keybindings,
keymap: keymap,
clipboard: clipboard,
@@ -229,6 +234,7 @@ class KernelServices {
palette: palette,
quickOpen: quickOpen,
recentFiles: recentFiles,
readerNav: readerNav,
keybindings: keybindings,
clipboard: clipboard,
files: files,
@@ -260,6 +266,7 @@ class KernelServices {
palette.dispose();
quickOpen.dispose();
recentFiles.dispose();
readerNav.dispose();
i18n.dispose();
notify.dispose();
dialog.dispose();
+134
View File
@@ -0,0 +1,134 @@
/// Retained back/forward navigation history for the right-pane readers
/// (markdown, decisions — T-196).
///
/// The history is the single source of truth for "what the reader is
/// showing", and it OUTLIVES the reader widget — so a reader that mounts
/// after a selection (its tab was just revealed) grabs [ReaderNav.current]
/// instead of missing a broadcast it subscribed to too late. The bus
/// stays a dumb pipe: a [ReaderNav] subscribes to its reader's
/// `selection` channel, records the entry, and (re-)emits `load` — which
/// is the single channel a reader loads from. Back/forward/pin navigation
/// re-emits `load` the same way, so every load flows through one path.
library;
import 'dart:async';
import 'package:clide/kernel/src/events/message_bus.dart';
import 'package:flutter/foundation.dart';
class ReaderNav extends ChangeNotifier {
ReaderNav({
required MessageBus messages,
required this.publisherId,
required this.dataKey,
}) : _messages = messages {
// Retained recorder: every selection for this reader is captured
// here whether or not the reader widget is mounted.
_selectionSub = _messages.subscribe(publisher: publisherId, channel: 'selection').listen((m) {
final entry = m.data[dataKey];
if (entry is String) open(entry);
});
}
final MessageBus _messages;
/// The reader's publisher id, e.g. `builtin.decisions` / `builtin.markdown`.
final String publisherId;
/// The key under which the entry travels in the bus payload (`id` for
/// decisions, `path` for markdown).
final String dataKey;
StreamSubscription<Message>? _selectionSub;
final List<String> _stack = [];
int _index = -1;
int? _pinnedIndex;
String? get current => _index >= 0 && _index < _stack.length ? _stack[_index] : null;
bool get canGoBack => _index > 0;
bool get canGoForward => _index < _stack.length - 1;
bool get hasPinned => _pinnedIndex != null && _pinnedIndex! < _stack.length;
/// External navigation: record [entry] (browser semantics — truncates
/// forward history) and emit a load. A repeat of the current entry
/// re-emits the load without pushing a duplicate.
void open(String entry) {
if (current == entry) {
_emit(entry);
return;
}
if (_index < _stack.length - 1) {
_stack.removeRange(_index + 1, _stack.length);
}
_stack.add(entry);
_index = _stack.length - 1;
_emit(entry);
}
/// Step back and re-emit the now-current entry. No-op at the start.
void back() {
if (!canGoBack) return;
_index--;
_emit(current!);
}
/// Step forward and re-emit the now-current entry. No-op at the end.
void forward() {
if (!canGoForward) return;
_index++;
_emit(current!);
}
/// Pin the current entry (one slot; a later pin replaces it).
void pin() {
if (_index < 0) return;
_pinnedIndex = _index;
notifyListeners();
}
/// Jump to the pinned entry and re-emit it. No-op when nothing is pinned.
void jumpToPin() {
final p = _pinnedIndex;
if (p == null || p >= _stack.length) return;
_index = p;
_emit(current!);
}
void _emit(String entry) {
_messages.publish(publisherId, 'load', {dataKey: entry});
notifyListeners();
}
@override
void dispose() {
_selectionSub?.cancel();
_selectionSub = null;
super.dispose();
}
}
/// Lazily creates and retains one [ReaderNav] per reader id, so the
/// history persists across reader widget mount/unmount.
class ReaderNavRegistry {
ReaderNavRegistry(this._messages);
final MessageBus _messages;
final Map<String, ReaderNav> _navs = {};
/// The retained [ReaderNav] for [publisherId], created on first use.
/// [dataKey] is the bus-payload key for this reader's entry.
ReaderNav navFor(String publisherId, {required String dataKey}) {
return _navs.putIfAbsent(
publisherId,
() => ReaderNav(messages: _messages, publisherId: publisherId, dataKey: dataKey),
);
}
void dispose() {
for (final n in _navs.values) {
n.dispose();
}
_navs.clear();
}
}