From ad1261fdc2363e915a1a36d0398bfa8e9a6b6621 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 24 Apr 2026 10:50:25 +0200 Subject: [PATCH] auto-refresh sidebar panels, pin/focus accordion logic Decisions: file-change subscription for decisions/*.md plus 1-minute scheduler tick. Tickets: 1-minute scheduler tick plus changed-event subscription from status button clicks. PQL markdown tab: file-change subscription for *.md files. All panels get a manual refresh button. Accordion sections use pin/focus logic: manually toggled sections are pinned and stay open; the focused item's section auto-opens; unpinned sections without focus auto-collapse. Both decisions and tickets views use ClideAccordion with this pattern. Ticket sidebar split into six status sections (IN PROGRESS, REVIEW, READY, BACKLOG, DONE, CANCELLED) matching pql's actual statuses. Status changes scroll the ticket into its new section. Co-Authored-By: Claude --- lib/builtin/decisions/src/decisions_view.dart | 123 ++++++++------- lib/builtin/pql/src/pql_panel_view.dart | 7 + lib/builtin/tickets/src/tickets_view.dart | 145 +++++++++++------- 3 files changed, 156 insertions(+), 119 deletions(-) diff --git a/lib/builtin/decisions/src/decisions_view.dart b/lib/builtin/decisions/src/decisions_view.dart index c09cfb19..6b3a4148 100644 --- a/lib/builtin/decisions/src/decisions_view.dart +++ b/lib/builtin/decisions/src/decisions_view.dart @@ -19,8 +19,12 @@ class _DecisionsViewState extends State { String _filter = ''; String? _focusedId; final _focusedKey = GlobalKey(); - final Set _expanded = {'confirmed'}; + final Set _pinned = {'confirmed'}; StreamSubscription? _focusSub; + StreamSubscription? _fileSub; + StreamSubscription? _schedulerSub; + bool _refreshing = false; + bool _pendingRefresh = false; @override void didChangeDependencies() { @@ -28,11 +32,28 @@ class _DecisionsViewState extends State { if (_focusSub == null) { final kernel = ClideKernel.of(context); _focusSub = kernel.messages.subscribe(publisher: 'builtin.decisions', channel: 'focus').listen(_onFocus); + _fileSub = kernel.events.on().where((e) => e.subsystem == 'files' && e.kind == 'files.changed' && _isDecisionPath(e.data['path'] as String? ?? '')).listen((_) => _refresh()); + _schedulerSub = kernel.events.on().where((e) => e.tier == SchedulerTier.oneMinute).listen((_) => _refresh()); } if (!_loading || _decisions.isNotEmpty) return; unawaited(_load()); } + static bool _isDecisionPath(String path) => path.startsWith('decisions/') && path.endsWith('.md'); + + Future _refresh() async { + if (!mounted) return; + if (_refreshing) { _pendingRefresh = true; return; } + _refreshing = true; + _pendingRefresh = false; + await _load(); + _refreshing = false; + if (_pendingRefresh && mounted) { + _pendingRefresh = false; + unawaited(_refresh()); + } + } + Future _load() async { final kernel = ClideKernel.of(context); await kernel.ipc.request('pql.decisions.sync'); @@ -59,18 +80,22 @@ class _DecisionsViewState extends State { @override void dispose() { _focusSub?.cancel(); + _fileSub?.cancel(); + _schedulerSub?.cancel(); super.dispose(); } + bool _isSectionExpanded(String type) { + if (_pinned.contains(type)) return true; + if (_focusedId == null) return false; + final entry = _decisions.where((d) => d.id == _focusedId).firstOrNull; + return (entry?.type ?? 'confirmed') == type; + } + void _onFocus(Message msg) { final id = msg.data['id'] as String?; if (id == null || id == _focusedId) return; - final entry = _decisions.where((d) => d.id == id).firstOrNull; - final section = entry?.type ?? 'confirmed'; - setState(() { - _focusedId = id; - _expanded.add(section); - }); + setState(() => _focusedId = id); WidgetsBinding.instance.addPostFrameCallback((_) { final ctx = _focusedKey.currentContext; if (ctx != null) Scrollable.ensureVisible(ctx, duration: const Duration(milliseconds: 200), alignment: 0.3); @@ -79,10 +104,10 @@ class _DecisionsViewState extends State { void _toggleSection(String key) { setState(() { - if (_expanded.contains(key)) { - _expanded.remove(key); + if (_pinned.contains(key)) { + _pinned.remove(key); } else { - _expanded.add(key); + _pinned.add(key); } }); } @@ -107,31 +132,43 @@ class _DecisionsViewState extends State { return Column( children: [ - ClideFilterBox(hint: 'Filter decisions…', onChanged: (v) => setState(() => _filter = v)), + Row( + children: [ + Expanded(child: ClideFilterBox(hint: 'Filter decisions…', onChanged: (v) => setState(() => _filter = v))), + Padding( + padding: const EdgeInsets.only(right: 8), + child: ClideTappable( + onTap: _refreshing ? null : _refresh, + tooltip: 'Refresh decisions', + builder: (ctx, hovered, _) => ClideIcon(PhosphorIcons.arrowClockwise, size: 13, color: hovered ? tokens.globalForeground : tokens.globalTextMuted), + ), + ), + ], + ), Expanded( child: SingleChildScrollView( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - if (confirmed.isNotEmpty) _AccordionSection( - label: 'CONFIRMED', count: confirmed.length, tokens: tokens, - color: typeColors.confirmed, - expanded: hasFilter || _expanded.contains('confirmed'), + if (confirmed.isNotEmpty) ClideAccordion( + label: 'CONFIRMED', count: confirmed.length, + leading: Container(width: 8, height: 8, decoration: BoxDecoration(color: typeColors.confirmed, shape: BoxShape.circle)), + expanded: hasFilter || _isSectionExpanded('confirmed'), onToggle: () => _toggleSection('confirmed'), children: [for (final d in confirmed) _DecisionCard(entry: d, tokens: tokens, typeColors: typeColors, focused: d.id == _focusedId, focusKey: d.id == _focusedId ? _focusedKey : null)], ), - if (questions.isNotEmpty) _AccordionSection( - label: 'QUESTIONS', count: questions.length, tokens: tokens, - color: typeColors.question, - expanded: hasFilter || _expanded.contains('question'), + if (questions.isNotEmpty) ClideAccordion( + label: 'QUESTIONS', count: questions.length, + leading: Container(width: 8, height: 8, decoration: BoxDecoration(color: typeColors.question, shape: BoxShape.circle)), + expanded: hasFilter || _isSectionExpanded('question'), onToggle: () => _toggleSection('question'), children: [for (final d in questions) _DecisionCard(entry: d, tokens: tokens, typeColors: typeColors, focused: d.id == _focusedId, focusKey: d.id == _focusedId ? _focusedKey : null)], ), - if (rejected.isNotEmpty) _AccordionSection( - label: 'REJECTED', count: rejected.length, tokens: tokens, - color: typeColors.rejected, - expanded: hasFilter || _expanded.contains('rejected'), + if (rejected.isNotEmpty) ClideAccordion( + label: 'REJECTED', count: rejected.length, + leading: Container(width: 8, height: 8, decoration: BoxDecoration(color: typeColors.rejected, shape: BoxShape.circle)), + expanded: hasFilter || _isSectionExpanded('rejected'), onToggle: () => _toggleSection('rejected'), children: [for (final d in rejected) _DecisionCard(entry: d, tokens: tokens, typeColors: typeColors, focused: d.id == _focusedId, focusKey: d.id == _focusedId ? _focusedKey : null)], ), @@ -161,46 +198,6 @@ class _DecisionEntry { ); } -class _AccordionSection extends StatelessWidget { - const _AccordionSection({ - required this.label, required this.count, required this.tokens, - required this.color, required this.expanded, required this.onToggle, - required this.children, - }); - final String label; - final int count; - final SurfaceTokens tokens; - final Color color; - final bool expanded; - final VoidCallback onToggle; - final List children; - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - ClideTappable( - onTap: onToggle, - builder: (ctx, hovered, _) => Padding( - padding: const EdgeInsets.only(left: 4, top: 10, bottom: 4), - child: Row( - children: [ - ClideIcon(expanded ? PhosphorIcons.caretDown : PhosphorIcons.caretRight, size: 10, color: tokens.globalTextMuted), - const SizedBox(width: 6), - Container(width: 8, height: 8, decoration: BoxDecoration(color: color, shape: BoxShape.circle)), - const SizedBox(width: 6), - ClideText('$label · $count', fontSize: clideFontSmall, color: hovered ? tokens.globalForeground : tokens.sidebarSectionHeader, fontFamily: clideMonoFamily), - ], - ), - ), - ), - if (expanded) ...children, - ], - ); - } -} - class _DecisionCard extends StatelessWidget { const _DecisionCard({required this.entry, required this.tokens, required this.typeColors, this.focused = false, this.focusKey}); final _DecisionEntry entry; diff --git a/lib/builtin/pql/src/pql_panel_view.dart b/lib/builtin/pql/src/pql_panel_view.dart index 612f92af..496aaf1c 100644 --- a/lib/builtin/pql/src/pql_panel_view.dart +++ b/lib/builtin/pql/src/pql_panel_view.dart @@ -21,6 +21,7 @@ class _PqlPanelViewState extends State { String? _focusedPath; final _focusedKey = GlobalKey(); StreamSubscription? _focusSub; + StreamSubscription? _fileSub; @override void didChangeDependencies() { @@ -42,11 +43,17 @@ class _PqlPanelViewState extends State { if (ctx != null) Scrollable.ensureVisible(ctx, duration: const Duration(milliseconds: 200), alignment: 0.3); }); }); + _fileSub = kernel.events.on().where((e) => e.subsystem == 'files' && e.kind == 'files.changed' && (e.data['path'] as String? ?? '').endsWith('.md')).listen((_) { + if (_controller?.view == PqlView.markdown) { + unawaited(_controller!.loadMarkdownFiles()); + } + }); } @override void dispose() { _focusSub?.cancel(); + _fileSub?.cancel(); _controller?.dispose(); super.dispose(); } diff --git a/lib/builtin/tickets/src/tickets_view.dart b/lib/builtin/tickets/src/tickets_view.dart index 86311632..d88bcad0 100644 --- a/lib/builtin/tickets/src/tickets_view.dart +++ b/lib/builtin/tickets/src/tickets_view.dart @@ -20,31 +20,40 @@ class _TicketsViewState extends State { String _filter = ''; String? _focusedId; final _focusedKey = GlobalKey(); - final Set _expanded = {'active', 'backlog'}; + final Set _pinned = {'in_progress', 'ready', 'backlog'}; StreamSubscription? _focusSub; + StreamSubscription? _schedulerSub; + StreamSubscription? _changedSub; + bool _refreshing = false; + bool _pendingRefresh = false; + + bool _isSectionExpanded(String status) { + if (_pinned.contains(status)) return true; + if (_focusedId == null) return false; + final entry = _tickets.where((t) => t.id == _focusedId).firstOrNull; + return _sectionForStatus(entry?.status) == status; + } void _toggle(String key) { setState(() { - if (_expanded.contains(key)) _expanded.remove(key); else _expanded.add(key); + if (_pinned.contains(key)) { + _pinned.remove(key); + } else { + _pinned.add(key); + } }); } - static String _sectionForStatus(String? status) => switch (status) { - 'in_progress' => 'active', - 'backlog' || 'ready' => 'backlog', - 'done' => 'done', - _ => 'other', - }; + static String _sectionForStatus(String? status) => status ?? 'backlog'; void _onFocus(Message msg) { final id = msg.data['id'] as String?; if (id == null || id == _focusedId) return; - final entry = _tickets.where((t) => t.id == id).firstOrNull; - final section = _sectionForStatus(entry?.status); - setState(() { - _focusedId = id; - _expanded.add(section); - }); + _scrollToFocused(id); + } + + void _scrollToFocused(String id) { + setState(() => _focusedId = id); WidgetsBinding.instance.addPostFrameCallback((_) { final ctx = _focusedKey.currentContext; if (ctx != null) Scrollable.ensureVisible(ctx, duration: const Duration(milliseconds: 200), alignment: 0.3); @@ -57,6 +66,13 @@ class _TicketsViewState extends State { if (_focusSub == null) { final kernel = ClideKernel.of(context); _focusSub = kernel.messages.subscribe(publisher: 'builtin.tickets', channel: 'focus').listen(_onFocus); + _changedSub = kernel.messages.subscribe(publisher: 'builtin.tickets', channel: 'changed').listen((msg) { + final id = msg.data['id'] as String?; + unawaited(_refresh().then((_) { + if (id != null && mounted) _scrollToFocused(id); + })); + }); + _schedulerSub = kernel.events.on().where((e) => e.tier == SchedulerTier.oneMinute).listen((_) => _refresh()); } if (!_loading || _tickets.isNotEmpty) return; unawaited(_load()); @@ -65,9 +81,24 @@ class _TicketsViewState extends State { @override void dispose() { _focusSub?.cancel(); + _changedSub?.cancel(); + _schedulerSub?.cancel(); super.dispose(); } + Future _refresh() async { + if (!mounted) return; + if (_refreshing) { _pendingRefresh = true; return; } + _refreshing = true; + _pendingRefresh = false; + await _load(); + _refreshing = false; + if (_pendingRefresh && mounted) { + _pendingRefresh = false; + unawaited(_refresh()); + } + } + Future _load() async { final kernel = ClideKernel.of(context); final resp = await kernel.ipc.request('pql.tickets.list'); @@ -101,27 +132,62 @@ class _TicketsViewState extends State { final hasFilter = lf.isNotEmpty; final filtered = hasFilter ? _tickets.where((t) => t.id.toLowerCase().contains(lf) || t.title.toLowerCase().contains(lf) || (t.status ?? '').contains(lf) || (t.type ?? '').contains(lf)).toList() : _tickets; - final active = filtered.where((t) => t.status == 'in_progress').toList(); - final backlog = filtered.where((t) => t.status == 'backlog' || t.status == 'ready').toList(); - final done = filtered.where((t) => t.status == 'done').toList(); - final other = filtered.where((t) => t.status == 'cancelled' || t.status == 'review').toList(); + const sections = [ + ('in_progress', 'IN PROGRESS'), + ('review', 'REVIEW'), + ('ready', 'READY'), + ('backlog', 'BACKLOG'), + ('done', 'DONE'), + ('cancelled', 'CANCELLED'), + ]; + + final byStatus = >{}; + for (final t in filtered) { + (byStatus[t.status ?? 'backlog'] ??= []).add(t); + } final isDark = ClideTheme.of(context).dark; final typeColors = TicketTypeColors.forTheme(dark: isDark); return Column( children: [ - ClideFilterBox(hint: 'Filter tickets…', onChanged: (v) => setState(() => _filter = v)), + Row( + children: [ + Expanded(child: ClideFilterBox(hint: 'Filter tickets…', onChanged: (v) => setState(() => _filter = v))), + Padding( + padding: const EdgeInsets.only(right: 8), + child: ClideTappable( + onTap: _refreshing ? null : _refresh, + tooltip: 'Refresh tickets', + builder: (ctx, hovered, _) => ClideIcon(PhosphorIcons.arrowClockwise, size: 13, color: hovered ? tokens.globalForeground : tokens.globalTextMuted), + ), + ), + ], + ), Expanded( child: SingleChildScrollView( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - if (active.isNotEmpty) _AccordionSection(label: 'ACTIVE', count: active.length, tokens: tokens, expanded: hasFilter || _expanded.contains('active'), onToggle: () => _toggle('active'), children: [for (final t in active) _TicketCard(entry: t, tokens: tokens, typeColors: typeColors, focused: t.id == _focusedId, focusKey: t.id == _focusedId ? _focusedKey : null)]), - if (backlog.isNotEmpty) _AccordionSection(label: 'BACKLOG', count: backlog.length, tokens: tokens, expanded: hasFilter || _expanded.contains('backlog'), onToggle: () => _toggle('backlog'), children: [for (final t in backlog) _TicketCard(entry: t, tokens: tokens, typeColors: typeColors, focused: t.id == _focusedId, focusKey: t.id == _focusedId ? _focusedKey : null)]), - if (done.isNotEmpty) _AccordionSection(label: 'DONE', count: done.length, tokens: tokens, expanded: hasFilter || _expanded.contains('done'), onToggle: () => _toggle('done'), children: [for (final t in done) _TicketCard(entry: t, tokens: tokens, typeColors: typeColors, focused: t.id == _focusedId, focusKey: t.id == _focusedId ? _focusedKey : null)]), - if (other.isNotEmpty) _AccordionSection(label: 'OTHER', count: other.length, tokens: tokens, expanded: hasFilter || _expanded.contains('other'), onToggle: () => _toggle('other'), children: [for (final t in other) _TicketCard(entry: t, tokens: tokens, typeColors: typeColors, focused: t.id == _focusedId, focusKey: t.id == _focusedId ? _focusedKey : null)]), + for (final (status, label) in sections) + if (byStatus[status] case final items? when items.isNotEmpty) + ClideAccordion( + label: label, + count: items.length, + expanded: hasFilter || _isSectionExpanded(status), + onToggle: () => _toggle(status), + children: [ + for (final t in items) + _TicketCard( + entry: t, + tokens: tokens, + typeColors: typeColors, + focused: t.id == _focusedId, + focusKey: t.id == _focusedId ? _focusedKey : null, + ), + ], + ), ], ), ), @@ -150,39 +216,6 @@ class _TicketEntry { ); } -class _AccordionSection extends StatelessWidget { - const _AccordionSection({required this.label, required this.count, required this.tokens, required this.expanded, required this.onToggle, required this.children}); - final String label; - final int count; - final SurfaceTokens tokens; - final bool expanded; - final VoidCallback onToggle; - final List children; - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - ClideTappable( - onTap: onToggle, - builder: (ctx, hovered, _) => Padding( - padding: const EdgeInsets.only(left: 4, top: 10, bottom: 4), - child: Row( - children: [ - ClideIcon(expanded ? PhosphorIcons.caretDown : PhosphorIcons.caretRight, size: 10, color: tokens.globalTextMuted), - const SizedBox(width: 6), - ClideText('$label · $count', fontSize: clideFontSmall, color: hovered ? tokens.globalForeground : tokens.sidebarSectionHeader, fontFamily: clideMonoFamily), - ], - ), - ), - ), - if (expanded) ...children, - ], - ); - } -} - class _TicketCard extends StatelessWidget { const _TicketCard({required this.entry, required this.tokens, required this.typeColors, this.focused = false, this.focusKey}); final _TicketEntry entry;