From 38058c17f5d33585026ca3d034cb5a8cdeaa1720 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 6 Jun 2026 23:10:07 +0200 Subject: [PATCH] =?UTF-8?q?add=20LogRing=20=E2=80=94=20bounded=20retention?= =?UTF-8?q?=20for=20Logger=20records=20(T-54)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Logger only live-broadcasts; an output panel opening late would see no history. LogRing is a drop-oldest sink (default 2000 records) that keeps the recent window plus per-source and per-level bookkeeping — distinct sources for the panel's filter dropdown, level counts for the status-bar health badge. Flutter-free; the dock UI wraps it. First slice of the D-87 dock. Co-Authored-By: Claude Opus 4.8 (1M context) --- .pql/changelog/ticket_history/2026-06.sql | 2 + .pql/changelog/tickets/2026-06.sql | 13 ++++ lib/kernel/src/log_ring.dart | 94 +++++++++++++++++++++++ test/kernel/log_ring_test.dart | 94 +++++++++++++++++++++++ 4 files changed, 203 insertions(+) create mode 100644 lib/kernel/src/log_ring.dart create mode 100644 test/kernel/log_ring_test.dart diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index 9ed7bc87..0fe9f28d 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -503,3 +503,5 @@ INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, UX design (2026-06-06, D-87): the panel is a bottom OUTPUT DOCK, read-only, two tabs — Output (the Logger stream, filter by source/level/text, auto-scroll) + Problems (moved out of the sidebar; no duplication). Toggled by a single status-bar widget that REPLACES the app-status indicator (merged health+log: green check when clean, warn/error counts when not, chevron for open state) — opens with click or Cmd/Ctrl+J. Needs a bounded in-memory ring sink on the Logger (no history today). Layout amends D-47 (dock pushes Claude up, capped so Claude stays >=50%). Terminal is NOT in the dock — kept first-class in the editor pane, tracked by T-258. Resolves Q-28. Wireframe: docs/design/wireframes/output-dock/.', NULL, '2026-06-06 20:56:04', '2026-06-06 20:56:04', '2026-06-06 20:56:04', NULL, 'c1df82ae7d048d460fa63288d00ad78b', 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-36', 'status', 'backlog', 'review', NULL, '2026-06-06 20:56:20', '2026-06-06 20:56:20', '2026-06-06 20:56:20', NULL, 'bcdc7e0811d809ddd533e12867c111d5', 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-252', 'status', 'backlog', 'ready', NULL, '2026-06-06 21:00:34', '2026-06-06 21:00:34', '2026-06-06 21:00:34', NULL, '2a079e0a52e8f4072918349942e6089e', 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-54', 'status', 'ready', 'in_progress', NULL, '2026-06-06 21:07:50', '2026-06-06 21:07:50', '2026-06-06 21:07:50', NULL, 'f76cd5804a0dfe4dd8b8a6581c9c3387', 1) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 6d975c4b..248821e6 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -1071,3 +1071,16 @@ INSERT INTO tickets (id, type, parent_id, title, description, status, priority, UX design (2026-06-06, D-87): the panel is a bottom OUTPUT DOCK, read-only, two tabs — Output (the Logger stream, filter by source/level/text, auto-scroll) + Problems (moved out of the sidebar; no duplication). Toggled by a single status-bar widget that REPLACES the app-status indicator (merged health+log: green check when clean, warn/error counts when not, chevron for open state) — opens with click or Cmd/Ctrl+J. Needs a bounded in-memory ring sink on the Logger (no history today). Layout amends D-47 (dock pushes Claude up, capped so Claude stays >=50%). Terminal is NOT in the dock — kept first-class in the editor pane, tracked by T-258. Resolves Q-28. Wireframe: docs/design/wireframes/output-dock/.', 'ready', 'medium', NULL, NULL, NULL, '2026-04-23 20:32:06', '2026-06-06 20:56:04', NULL, 'd7252d09c6f9f8a8533ef74dfc1a094b', 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-36', 'story', 'T-7', 'Context auto-behavior: right panel reacts to Claude references', 'Parse file references from Claude output. Swap viewer content when right panel is open. Badge on spine when collapsed. Live-sync viewer when editing .md.', 'review', 'low', NULL, NULL, 'D-50', '2026-04-22 20:34:09', '2026-06-06 20:56:20', NULL, '014c81c57e20079eb3c67ff0e338ca1f', 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-252', 'story', NULL, 'Full-screen lightbox overlay (zoom + pan) for image cards', 'Click an inline image card (T-249) to open it in a full-screen overlay with zoom + pan — the standard lightbox UX from other apps (dimmed backdrop, image fit-to-screen, scroll/pinch to zoom, drag to pan when zoomed past fit, Esc or backdrop-click to dismiss). + +Build the overlay as a SHARED PRIMITIVE WIDGET, not image-specific. A reusable zoomable-viewer / lightbox primitive under lib/widgets/ (e.g. ClideLightbox + ClideZoomPanViewer) that takes any child/content; the image card is its first consumer, and the canvas, graph, and diff/image previews can adopt it later. Own-the-rendering-stack + prefer-zero-deps still apply: Flutter''s SDK InteractiveViewer is the SDK-first starting point (allowed), but keep the gesture/zoom logic in the primitive so we control the UX, not an opinionated package. + +Scope: +- Shared primitive (lib/widgets/): a full-screen overlay with a dimmed backdrop hosted via the existing DialogRouter (single-at-a-time modal, D-78 interaction surface). Content scales to fit on open; zoom via scroll wheel / pinch / +- ; pan by drag when zoomed in; double-click toggles fit<->100%/2x; Esc and backdrop-click dismiss. Min/max zoom clamp. Keyboard + AT reachable (ClideTappable / Semantics), theme tokens / ui-design for any chrome (close affordance, zoom hint). +- Image card wiring (T-249): the inline card stays display-only per D-78 — the click is a navigation gesture that opens the lightbox, not an inline control. Render the same resolved file at full resolution in the overlay; missing-file degrades to the existing placeholder. +- Parity (D-6): a CLI counterpart to open an image straight into the lightbox (e.g. clide image show --fullscreen, or clide image open ), registered so it surfaces in clide capabilities (T-248). + +Refs: D-78 (interaction zone / display-only conversation widgets), D-6 (CLI/UI parity), DialogRouter (lib/kernel/src/dialog.dart). Builds on T-249 (image card + clide image show). Related: T-248.', 'ready', 'medium', NULL, NULL, 'D-78', '2026-06-06 08:28:43', '2026-06-06 21:00:34', NULL, '3abd5de82d6d82812e51bb9138785adf', 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-54', 'story', NULL, 'output and log panel', 'Bottom panel or context tab showing daemon logs, build output, extension logs, and pql sync output. Filterable by source. Auto-scrolls to latest. Useful for debugging extension and IPC issues. + +UX design (2026-06-06, D-87): the panel is a bottom OUTPUT DOCK, read-only, two tabs — Output (the Logger stream, filter by source/level/text, auto-scroll) + Problems (moved out of the sidebar; no duplication). Toggled by a single status-bar widget that REPLACES the app-status indicator (merged health+log: green check when clean, warn/error counts when not, chevron for open state) — opens with click or Cmd/Ctrl+J. Needs a bounded in-memory ring sink on the Logger (no history today). Layout amends D-47 (dock pushes Claude up, capped so Claude stays >=50%). Terminal is NOT in the dock — kept first-class in the editor pane, tracked by T-258. Resolves Q-28. Wireframe: docs/design/wireframes/output-dock/.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-04-23 20:32:06', '2026-06-06 21:07:50', NULL, '4c049b9428db5ff733899d698fcd8a44', 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/lib/kernel/src/log_ring.dart b/lib/kernel/src/log_ring.dart new file mode 100644 index 00000000..4c23ed13 --- /dev/null +++ b/lib/kernel/src/log_ring.dart @@ -0,0 +1,94 @@ +/// Bounded in-memory retention for [Logger] records — the history the +/// output dock (T-54 / D-87) reads on open. +/// +/// The [Logger] only live-broadcasts to its stream; a panel that opens late +/// would see nothing. [LogRing] is a sink that keeps the last [capacity] +/// records (drop-oldest, same shape as the D-85 event ring) plus enough +/// bookkeeping to drive the panel's filter dropdown (distinct [sources]) and +/// the status-bar health badge (level counts). +/// +/// Flutter-free (only `dart:async`/`dart:collection` + the [LogRecord] type) +/// so it unit-tests without a widget harness. The UI wraps it; the ring +/// itself holds no view concerns. +library; + +import 'dart:async'; +import 'dart:collection'; + +import 'log.dart'; + +class LogRing { + LogRing({this.capacity = 2000}) : assert(capacity > 0, 'capacity must be positive'); + + /// Maximum retained records. Oldest are dropped past this. + final int capacity; + + final ListQueue _records = ListQueue(); + // Per-source / per-level counts over the *retained* window, so [sources] + // and [countAtLeast] stay accurate as records age out. + final Map _sourceCounts = {}; + final List _levelCounts = List.filled(LogLevel.values.length, 0); + final StreamController _changes = StreamController.broadcast(); + + /// Fires after any mutation (add or clear). Listeners re-read [records]. + Stream get changes => _changes.stream; + + /// Snapshot of retained records, oldest first. + List get records => List.unmodifiable(_records); + + /// Distinct sources currently retained, sorted — drives the filter dropdown. + List get sources => _sourceCounts.keys.toList()..sort(); + + int get length => _records.length; + bool get isEmpty => _records.isEmpty; + + /// Append a record (the [Logger] sink). Drops the oldest past [capacity]. + void add(LogRecord r) { + _records.addLast(r); + _sourceCounts.update(r.source, (n) => n + 1, ifAbsent: () => 1); + _levelCounts[r.level.index]++; + while (_records.length > capacity) { + _decr(_records.removeFirst()); + } + _notify(); + } + + /// Drop everything (the panel's "Clear" action). + void clear() { + if (_records.isEmpty) return; + _records.clear(); + _sourceCounts.clear(); + for (var i = 0; i < _levelCounts.length; i++) { + _levelCounts[i] = 0; + } + _notify(); + } + + /// Count of retained records at [level] or higher — e.g. + /// `countAtLeast(LogLevel.warn)` for the status-badge warn+error total. + int countAtLeast(LogLevel level) { + var n = 0; + for (var i = level.index; i < _levelCounts.length; i++) { + n += _levelCounts[i]; + } + return n; + } + + void _decr(LogRecord r) { + final c = _sourceCounts[r.source]; + if (c != null) { + if (c <= 1) { + _sourceCounts.remove(r.source); + } else { + _sourceCounts[r.source] = c - 1; + } + } + _levelCounts[r.level.index]--; + } + + void _notify() { + if (!_changes.isClosed) _changes.add(null); + } + + void dispose() => _changes.close(); +} diff --git a/test/kernel/log_ring_test.dart b/test/kernel/log_ring_test.dart new file mode 100644 index 00000000..dae3d6a5 --- /dev/null +++ b/test/kernel/log_ring_test.dart @@ -0,0 +1,94 @@ +/// T-54: LogRing — bounded drop-oldest retention for Logger records, with +/// per-source / per-level bookkeeping for the panel filter + status badge. +library; + +import 'package:clide/kernel/src/log.dart'; +import 'package:clide/kernel/src/log_ring.dart'; +import 'package:flutter_test/flutter_test.dart'; + +LogRecord _rec(LogLevel level, String source, String message) => + LogRecord(level: level, source: source, message: message, timestamp: DateTime.utc(2026, 6, 6)); + +void main() { + test('retains records in arrival order', () { + final ring = LogRing(capacity: 10); + ring + ..add(_rec(LogLevel.info, 'ipc', 'a')) + ..add(_rec(LogLevel.info, 'mcp', 'b')); + expect(ring.records.map((r) => r.message), ['a', 'b']); + expect(ring.length, 2); + }); + + test('drops oldest past capacity', () { + final ring = LogRing(capacity: 3); + for (final m in ['a', 'b', 'c', 'd', 'e']) { + ring.add(_rec(LogLevel.info, 'ipc', m)); + } + expect(ring.records.map((r) => r.message), ['c', 'd', 'e']); + }); + + test('sources are distinct, sorted, and shrink as records age out', () { + final ring = LogRing(capacity: 2); + ring + ..add(_rec(LogLevel.info, 'git', '1')) + ..add(_rec(LogLevel.info, 'ipc', '2')); + expect(ring.sources, ['git', 'ipc']); + // Pushing past capacity evicts the 'git' record → 'git' leaves the set. + ring.add(_rec(LogLevel.info, 'pql', '3')); + expect(ring.sources, ['ipc', 'pql']); + }); + + test('countAtLeast totals retained records at a level or higher', () { + final ring = LogRing(capacity: 10); + ring + ..add(_rec(LogLevel.debug, 'x', 'd')) + ..add(_rec(LogLevel.info, 'x', 'i')) + ..add(_rec(LogLevel.warn, 'x', 'w')) + ..add(_rec(LogLevel.error, 'x', 'e')); + expect(ring.countAtLeast(LogLevel.warn), 2); // warn + error + expect(ring.countAtLeast(LogLevel.error), 1); + expect(ring.countAtLeast(LogLevel.trace), 4); + }); + + test('eviction decrements level counts', () { + final ring = LogRing(capacity: 2); + ring + ..add(_rec(LogLevel.error, 'x', 'e1')) + ..add(_rec(LogLevel.info, 'x', 'i')) + ..add(_rec(LogLevel.info, 'x', 'i2')); // evicts the error + expect(ring.countAtLeast(LogLevel.error), 0); + }); + + test('clear empties records, sources, and counts', () { + final ring = LogRing(capacity: 10); + ring + ..add(_rec(LogLevel.warn, 'pql', 'w')) + ..clear(); + expect(ring.isEmpty, isTrue); + expect(ring.sources, isEmpty); + expect(ring.countAtLeast(LogLevel.trace), 0); + }); + + test('changes fires on add and on clear', () async { + final ring = LogRing(capacity: 10); + final events = []; + final sub = ring.changes.listen(events.add); + ring.add(_rec(LogLevel.info, 'x', 'a')); + ring.clear(); + await Future.delayed(Duration.zero); + expect(events.length, 2); + await sub.cancel(); + ring.dispose(); + }); + + test('clear on an empty ring does not fire changes', () async { + final ring = LogRing(capacity: 10); + final events = []; + final sub = ring.changes.listen(events.add); + ring.clear(); + await Future.delayed(Duration.zero); + expect(events, isEmpty); + await sub.cancel(); + ring.dispose(); + }); +}