add LogRing — bounded retention for Logger records (T-54)

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-06 23:10:07 +02:00
co-authored by Claude Opus 4.8
parent da61af810d
commit 38058c17f5
4 changed files with 203 additions and 0 deletions
@@ -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;
+13
View File
@@ -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 <path> --fullscreen, or clide image open <path>), 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);
+94
View File
@@ -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<LogRecord> _records = ListQueue<LogRecord>();
// Per-source / per-level counts over the *retained* window, so [sources]
// and [countAtLeast] stay accurate as records age out.
final Map<String, int> _sourceCounts = {};
final List<int> _levelCounts = List<int>.filled(LogLevel.values.length, 0);
final StreamController<void> _changes = StreamController<void>.broadcast();
/// Fires after any mutation (add or clear). Listeners re-read [records].
Stream<void> get changes => _changes.stream;
/// Snapshot of retained records, oldest first.
List<LogRecord> get records => List<LogRecord>.unmodifiable(_records);
/// Distinct sources currently retained, sorted — drives the filter dropdown.
List<String> 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();
}
+94
View File
@@ -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 = <void>[];
final sub = ring.changes.listen(events.add);
ring.add(_rec(LogLevel.info, 'x', 'a'));
ring.clear();
await Future<void>.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 = <void>[];
final sub = ring.changes.listen(events.add);
ring.clear();
await Future<void>.delayed(Duration.zero);
expect(events, isEmpty);
await sub.cancel();
ring.dispose();
});
}