feat(markdown): live-sync read-mirror of the open editor buffer (T-36)

D-50 behavior 4: opening a renderable .md in the editor now auto-reveals the
context-panel reader, which mirrors the buffer read-only and re-renders as the
user types — rather than a one-shot disk read.

The extension reveals the tab on editor.opened (renderable only — D-50 behavior
5 leaves non-.md files alone). The viewer owns the mirror: on mount it picks up
the active buffer (editor.read, no id), enters mirror mode on editor.opened /
active-changed for a renderable file, re-reads the in-memory buffer on
editor.edited, and drops the mirror (with its edit affordance back) on a disk
load or a switch to a non-renderable buffer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-28 11:08:31 +02:00
co-authored by Claude Opus 4.8
parent 84f4b20cb8
commit 095c45a023
7 changed files with 207 additions and 3 deletions
@@ -7267,3 +7267,6 @@ INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, chang
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGSQP1YPG70RPQJ4XZDH2T1M', 'status', 'backlog', 'in_progress', NULL, '2026-06-28 08:32:16', '2026-06-28 08:32:16.329', '2026-06-28 08:32:16.329', NULL, 'b212ea2e61acbb81bd5d089e97240c97', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGSQP1YPG70RPQJ4XZDH2T1M', 'status', 'in_progress', 'in_progress', NULL, '2026-06-28 08:32:36', '2026-06-28 08:32:36.557', '2026-06-28 08:32:36.557', NULL, '09e582bb30f33f9c7330e041067a48b6', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGSQP1YPG70RPQJ4XZDH2T1M', 'status', 'in_progress', 'done', NULL, '2026-06-28 08:53:08', '2026-06-28 08:53:08.149', '2026-06-28 08:53:08.149', NULL, 'fbe0fcce351e196779c912689805075f', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5JK0RHMRAH47K8ND4', 'status', 'backlog', 'in_progress', NULL, '2026-06-28 08:55:04', '2026-06-28 08:55:04.660', '2026-06-28 08:55:04.660', NULL, 'e5c075285fc2422545f90d372c175e28', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5JK0RHMRAH47K8ND4', 'status', 'in_progress', 'in_progress', NULL, '2026-06-28 08:55:15', '2026-06-28 08:55:15.737', '2026-06-28 08:55:15.737', NULL, '793c4f2984d01abfb3a52d860e60f6d7', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5JK0RHMRAH47K8ND4', 'status', 'in_progress', 'done', NULL, '2026-06-28 09:08:31', '2026-06-28 09:08:31.757', '2026-06-28 09:08:31.757', NULL, '168bec5a771d23ab7f0d850a8202ef0c', 2) ON CONFLICT(hash) DO NOTHING;
+15
View File
@@ -9446,3 +9446,18 @@ Considerations:
2. The post-/clear respawn also gets it (it spawns through the same path).
3. A --resume of an existing session does NOT get re-injected.
4. The text is short and does not crowd out clideContextNote.', 'done', 'medium', NULL, NULL, NULL, '2026-06-28 06:15:53.077', '2026-06-28 08:53:08.149', NULL, '1c8842605724aab0461805297ad03ac7', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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 (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5JK0RHMRAH47K8ND4', 'story', '06FB0TNQM7RQMN1GKEZ5JQKJT0', 'Live-sync read-mirror: context panel tracks the open editor buffer', 'When an editor is open in edit mode on a renderable doc (.md), the right-hand context panel auto-opens a read-only view that mirrors the buffer and updates live as the user types (D-50 behavior 4). If the editor is on a non-renderable file, no auto-viewer (D-50 behavior 5, already holds).
Wiring sketch: EditorRegistry already emits editor.opened / editor.active-changed / editor.edited (lib/src/editor/registry.dart:85). The markdown reader (lib/builtin/markdown/src/markdown_viewer.dart) currently pulls content once via files.read with no subscription. The live-sync work is: (1) on editor.opened for a renderable file, auto-activate the markdown reader in the context panel; (2) subscribe the reader to editor.edited and re-render from the in-memory buffer rather than re-reading disk; (3) read-only no edit affordances in the mirror.
HISTORY: T-36 originally bundled four D-50 clauses (parse Claude''s output for file references, swap the open viewer, badge the spine when collapsed, live-sync). The give-clide-hands push (T-208) superseded the first three: instead of clide scraping the terminal for references, the agent explicitly drives the reader via `clide ui open markdown <path>` (T-231) and ui.open -> diff (T-233). The spine badge was dropped (the open is now an intentional agent act, not a passive notification). Re-scoped 2026-06-06 to the one UI-owned piece that give-clide-hands did not deliver: the live-sync read-mirror. Re-homed from T-7 (Tier 5 canvas/graph, a mis-parent) to T-259 (interaction model). See the D-50 amendment.', 'in_progress', 'low', NULL, NULL, 'D-50', '2026-04-22 20:34:09', '2026-06-28 08:55:04.660', NULL, '4efae1f339b66f4ee9ff7aa14581867e', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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 (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5JK0RHMRAH47K8ND4', 'story', '06FB0TNQM7RQMN1GKEZ5JQKJT0', 'Live-sync read-mirror: context panel tracks the open editor buffer', 'When an editor is open in edit mode on a renderable doc (.md), the right-hand context panel auto-opens a read-only view that mirrors the buffer and updates live as the user types (D-50 behavior 4). If the editor is on a non-renderable file, no auto-viewer (D-50 behavior 5, already holds).
Wiring sketch: EditorRegistry already emits editor.opened / editor.active-changed / editor.edited (lib/src/editor/registry.dart:85). The markdown reader (lib/builtin/markdown/src/markdown_viewer.dart) currently pulls content once via files.read with no subscription. The live-sync work is: (1) on editor.opened for a renderable file, auto-activate the markdown reader in the context panel; (2) subscribe the reader to editor.edited and re-render from the in-memory buffer rather than re-reading disk; (3) read-only no edit affordances in the mirror.
HISTORY: T-36 originally bundled four D-50 clauses (parse Claude''s output for file references, swap the open viewer, badge the spine when collapsed, live-sync). The give-clide-hands push (T-208) superseded the first three: instead of clide scraping the terminal for references, the agent explicitly drives the reader via `clide ui open markdown <path>` (T-231) and ui.open -> diff (T-233). The spine badge was dropped (the open is now an intentional agent act, not a passive notification). Re-scoped 2026-06-06 to the one UI-owned piece that give-clide-hands did not deliver: the live-sync read-mirror. Re-homed from T-7 (Tier 5 canvas/graph, a mis-parent) to T-259 (interaction model). See the D-50 amendment.', 'in_progress', 'low', NULL, NULL, 'D-50', '2026-04-22 20:34:09', '2026-06-28 08:55:15.737', NULL, 'eb473131afb64548a097a45d74f3637c', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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 (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5JK0RHMRAH47K8ND4', 'story', '06FB0TNQM7RQMN1GKEZ5JQKJT0', 'Live-sync read-mirror: context panel tracks the open editor buffer', 'When an editor is open in edit mode on a renderable doc (.md), the right-hand context panel auto-opens a read-only view that mirrors the buffer and updates live as the user types (D-50 behavior 4). If the editor is on a non-renderable file, no auto-viewer (D-50 behavior 5, already holds).
Wiring sketch: EditorRegistry already emits editor.opened / editor.active-changed / editor.edited (lib/src/editor/registry.dart:85). The markdown reader (lib/builtin/markdown/src/markdown_viewer.dart) currently pulls content once via files.read with no subscription. The live-sync work is: (1) on editor.opened for a renderable file, auto-activate the markdown reader in the context panel; (2) subscribe the reader to editor.edited and re-render from the in-memory buffer rather than re-reading disk; (3) read-only no edit affordances in the mirror.
HISTORY: T-36 originally bundled four D-50 clauses (parse Claude''s output for file references, swap the open viewer, badge the spine when collapsed, live-sync). The give-clide-hands push (T-208) superseded the first three: instead of clide scraping the terminal for references, the agent explicitly drives the reader via `clide ui open markdown <path>` (T-231) and ui.open -> diff (T-233). The spine badge was dropped (the open is now an intentional agent act, not a passive notification). Re-scoped 2026-06-06 to the one UI-owned piece that give-clide-hands did not deliver: the live-sync read-mirror. Re-homed from T-7 (Tier 5 canvas/graph, a mis-parent) to T-259 (interaction model). See the D-50 amendment.', 'done', 'low', NULL, NULL, 'D-50', '2026-04-22 20:34:09', '2026-06-28 09:08:31.757', NULL, 'cb89bbf9b488b2611acbe49cc3d59a6a', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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);
+3
View File
@@ -18,6 +18,9 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
### Added
- **Live-sync markdown read-mirror.** Editing a `.md` in the editor auto-opens a
read-only preview in the context panel that mirrors the buffer and re-renders
as you type; non-renderable files get no auto-viewer. (T-36, D-50)
- **New project flow.** A "New project…" action in the welcome view creates +
opens a project, then prompts for the Claude account to bind it to — the
per-repo account roadblock fires only for freshly-created projects. (T-488,
+15 -1
View File
@@ -16,6 +16,7 @@ class MarkdownExtension extends ClideExtension {
List<String> get dependsOn => const [];
StreamSubscription<Message>? _sub;
StreamSubscription<DaemonEvent>? _editorSub;
@override
List<ContributionPoint> get contributions => [
@@ -38,8 +39,21 @@ class MarkdownExtension extends ClideExtension {
if (msg.data['path'] is! String) return;
ctx.panels.activateTab(Slots.contextPanel, 'markdown.viewer');
});
// Live-sync read-mirror (T-36, D-50 behavior 4): opening a renderable .md in
// the editor auto-reveals the reader, which then mirrors the buffer live.
// Non-renderable files reveal nothing (D-50 behavior 5).
_editorSub = ctx.events.on<DaemonEvent>().listen((e) {
if (e.subsystem != 'editor' || e.kind != 'editor.opened') return;
final path = e.data['path'] as String?;
if (path != null && isRenderableMarkdownPath(path)) {
ctx.panels.activateTab(Slots.contextPanel, 'markdown.viewer');
}
});
}
@override
Future<void> deactivate() async => _sub?.cancel();
Future<void> deactivate() async {
await _sub?.cancel();
await _editorSub?.cancel();
}
}
+78 -2
View File
@@ -5,6 +5,13 @@ import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
/// Whether [path] is a markdown file the reader can mirror live (T-36, D-50).
/// Non-renderable files get no auto-viewer (D-50 behavior 5).
bool isRenderableMarkdownPath(String path) {
final p = path.toLowerCase();
return p.endsWith('.md') || p.endsWith('.markdown');
}
class MarkdownViewer extends StatefulWidget {
const MarkdownViewer({super.key});
@@ -17,8 +24,15 @@ class _MarkdownViewerState extends State<MarkdownViewer> {
String? _content;
String? _error;
StreamSubscription<Message>? _selectionSub;
StreamSubscription<DaemonEvent>? _editorSub;
ReaderNav? _nav;
/// Live-sync mirror state (T-36, D-50 behavior 4): when [_mirror] is true the
/// view is a read-only reflection of the open editor buffer [_mirrorId] and
/// re-reads it on every edit, rather than a disk snapshot.
bool _mirror = false;
String? _mirrorId;
@override
void didChangeDependencies() {
super.didChangeDependencies();
@@ -31,12 +45,68 @@ class _MarkdownViewerState extends State<MarkdownViewer> {
final path = msg.data['path'] as String?;
if (path != null) _loadFile(path);
});
// Live-sync: mirror the editor buffer as it's typed (T-36).
_editorSub = kernel.events.on<DaemonEvent>().listen(_onEditorEvent);
// The editor may have opened before this viewer mounted (the extension
// reveals the tab on editor.opened) — pick up the active buffer now.
unawaited(_mirrorActiveIfRenderable(kernel));
// 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);
}
/// On mount, mirror the active editor buffer if it's a renderable file —
/// `editor.read` with no id resolves to the active buffer.
Future<void> _mirrorActiveIfRenderable(KernelServices kernel) async {
final resp = await kernel.ipc.request('editor.read', args: const {});
if (!mounted || !resp.ok) return;
final path = resp.data['path'] as String?;
final id = resp.data['id'] as String?;
if (path != null && id != null && isRenderableMarkdownPath(path)) {
_enterMirror(id, path, resp.data['content'] as String? ?? '');
}
}
void _onEditorEvent(DaemonEvent e) {
if (e.subsystem != 'editor') return;
final id = e.data['id'] as String?;
final path = e.data['path'] as String?;
switch (e.kind) {
case 'editor.opened':
if (id != null && path != null && isRenderableMarkdownPath(path)) _enterMirror(id, path, e.data['content'] as String? ?? '');
case 'editor.active-changed':
// Followed the active buffer: mirror a renderable one, drop the mirror
// for a non-renderable / no buffer (D-50 behavior 5 — no auto-viewer).
if (id != null && path != null && isRenderableMarkdownPath(path)) {
unawaited(_reread(id, path));
} else {
if (_mirror) setState(() => _mirror = false);
}
case 'editor.edited':
if (_mirror && id != null && id == _mirrorId && _path != null) unawaited(_reread(id, _path!));
case 'editor.closed':
if (id == _mirrorId && _mirror) setState(() => _mirror = false);
}
}
/// Re-read the in-memory buffer [id] and refresh the mirror (live edits).
Future<void> _reread(String id, String path) async {
final resp = await ClideKernel.of(context).ipc.request('editor.read', args: {'id': id});
if (!mounted || !resp.ok) return;
_enterMirror(id, path, resp.data['content'] as String? ?? '');
}
void _enterMirror(String id, String path, String content) {
setState(() {
_mirror = true;
_mirrorId = id;
_path = path;
_content = content;
_error = null;
});
}
void _onNavChanged() {
if (mounted) setState(() {}); // refresh action-bar button state
}
@@ -44,11 +114,14 @@ class _MarkdownViewerState extends State<MarkdownViewer> {
@override
void dispose() {
_selectionSub?.cancel();
_editorSub?.cancel();
_nav?.removeListener(_onNavChanged);
super.dispose();
}
/// Fetch + display [path]. History lives in [ReaderNav]; never pushes.
/// Fetch + display [path] from disk. An explicit (agent-driven) selection, so
/// it leaves mirror mode — the edit affordance returns. 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});
@@ -56,6 +129,7 @@ class _MarkdownViewerState extends State<MarkdownViewer> {
if (resp.ok) {
kernel.messages.publish('builtin.markdown', 'focus', {'path': path});
setState(() {
_mirror = false;
_path = path;
_content = resp.data['content'] as String? ?? '';
_error = null;
@@ -121,7 +195,9 @@ class _MarkdownViewerState extends State<MarkdownViewer> {
onBack: (_nav?.canGoBack ?? false) ? _onBack : null,
onForward: (_nav?.canGoForward ?? false) ? _onForward : null,
onJumpToPin: (_nav?.hasPinned ?? false) ? _onJumpToPin : null,
onEdit: _path != null ? _onEdit : null,
// Read-only while mirroring the live buffer (T-36): the file is already
// open in the editor, so no edit affordance here.
onEdit: (!_mirror && _path != null) ? _onEdit : null,
),
],
child: SingleChildScrollView(
@@ -0,0 +1,53 @@
/// T-36 / D-50 behavior 4+5: MarkdownExtension reveals the context-panel reader
/// when a renderable .md opens in the editor, and leaves a non-renderable file
/// alone. (The viewer then mirrors the buffer live — see markdown_viewer_test.)
library;
import 'package:clide/builtin/markdown/src/extension.dart';
import 'package:clide/kernel/kernel.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../helpers/kernel_fixture.dart';
void main() {
late KernelFixture f;
setUp(() async {
f = await KernelFixture.create();
f.services.extensions.register(MarkdownExtension());
await f.services.extensions.activate('builtin.markdown');
});
tearDown(() => f.dispose());
void open(String path) {
f.services.events.emit(DaemonEvent(subsystem: 'editor', kind: 'editor.opened', data: {'id': 'b1', 'path': path}, ts: DateTime.now().toUtc()));
}
test('contributes the markdown.viewer context-panel tab', () {
expect(f.services.panels.tabsFor(Slots.contextPanel).any((t) => t.id == 'markdown.viewer'), isTrue);
});
test('opening a renderable .md reveals the markdown viewer', () async {
// markdown.viewer is the default contextPanel tab; flip to a sentinel first
// so the reveal is observable.
f.services.panels.activateTab(Slots.contextPanel, 'sentinel');
open('docs/notes.md');
await pumpEventQueue();
expect(f.services.panels.activeTabIn(Slots.contextPanel), 'markdown.viewer');
});
test('opening a non-renderable file does not reveal it (D-50 behavior 5)', () async {
f.services.panels.activateTab(Slots.contextPanel, 'sentinel');
open('lib/main.dart');
await pumpEventQueue();
expect(f.services.panels.activeTabIn(Slots.contextPanel), 'sentinel');
});
test('after deactivate, editor.opened no longer reveals the viewer', () async {
await f.services.extensions.deactivate('builtin.markdown');
f.services.panels.activateTab(Slots.contextPanel, 'sentinel');
open('notes.md');
await pumpEventQueue();
expect(f.services.panels.activeTabIn(Slots.contextPanel), 'sentinel');
});
}
@@ -391,4 +391,44 @@ void main() {
expect(editorOpenArgs.first['path'], path);
});
});
// T-36 / D-50 behavior 4: live-sync read-mirror of the open editor buffer.
group('MarkdownViewer — live-sync mirror (T-36)', () {
DaemonEvent opened(String id, String path, String content) =>
DaemonEvent(subsystem: 'editor', kind: 'editor.opened', data: {'id': id, 'path': path, 'content': content}, ts: DateTime.now().toUtc());
DaemonEvent edited(String id) =>
DaemonEvent(subsystem: 'editor', kind: 'editor.edited', data: {'id': id, 'kind': 'replace', 'length': 0}, ts: DateTime.now().toUtc());
bool editVisible() => find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Edit in editor').evaluate().isNotEmpty;
testWidgets('mirrors the buffer on editor.opened, and the mirror is read-only', (tester) async {
await pumpView(tester, f);
f.services.events.emit(opened('b1', 'notes.md', '# Live mirror\n'));
await pumpAsync(tester);
expect(find.textContaining('Live mirror'), findsWidgets);
expect(editVisible(), isFalse, reason: 'no edit affordance while mirroring the live buffer');
});
testWidgets('re-reads the buffer on editor.edited and re-renders', (tester) async {
var content = '# First\n';
f.ipc.stub('editor.read', (args) async => _ok({'id': 'b1', 'path': 'notes.md', 'content': content}));
await pumpView(tester, f);
f.services.events.emit(opened('b1', 'notes.md', content));
await pumpAsync(tester);
expect(find.textContaining('First'), findsWidgets);
content = '# Second\n'; // the user typed in the editor
f.services.events.emit(edited('b1'));
await pumpAsync(tester);
expect(find.textContaining('Second'), findsWidgets);
expect(find.textContaining('First'), findsNothing);
});
testWidgets('a non-renderable editor.opened does not mirror (D-50 behavior 5)', (tester) async {
await pumpView(tester, f);
f.services.events.emit(opened('b9', 'main.py', 'print(1)'));
await pumpAsync(tester);
expect(find.textContaining('print'), findsNothing, reason: 'non-renderable file gets no auto-mirror');
});
});
}