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 <noreply@anthropic.com>
This commit is contained in:
2026-04-24 10:50:25 +02:00
co-authored by Claude
parent 8a0ca92a1f
commit ad1261fdc2
3 changed files with 156 additions and 119 deletions
+60 -63
View File
@@ -19,8 +19,12 @@ class _DecisionsViewState extends State<DecisionsView> {
String _filter = '';
String? _focusedId;
final _focusedKey = GlobalKey();
final Set<String> _expanded = {'confirmed'};
final Set<String> _pinned = {'confirmed'};
StreamSubscription<Message>? _focusSub;
StreamSubscription<DaemonEvent>? _fileSub;
StreamSubscription<SchedulerTick>? _schedulerSub;
bool _refreshing = false;
bool _pendingRefresh = false;
@override
void didChangeDependencies() {
@@ -28,11 +32,28 @@ class _DecisionsViewState extends State<DecisionsView> {
if (_focusSub == null) {
final kernel = ClideKernel.of(context);
_focusSub = kernel.messages.subscribe(publisher: 'builtin.decisions', channel: 'focus').listen(_onFocus);
_fileSub = kernel.events.on<DaemonEvent>().where((e) => e.subsystem == 'files' && e.kind == 'files.changed' && _isDecisionPath(e.data['path'] as String? ?? '')).listen((_) => _refresh());
_schedulerSub = kernel.events.on<SchedulerTick>().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<void> _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<void> _load() async {
final kernel = ClideKernel.of(context);
await kernel.ipc.request('pql.decisions.sync');
@@ -59,18 +80,22 @@ class _DecisionsViewState extends State<DecisionsView> {
@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<DecisionsView> {
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<DecisionsView> {
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<Widget> 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;
+7
View File
@@ -21,6 +21,7 @@ class _PqlPanelViewState extends State<PqlPanelView> {
String? _focusedPath;
final _focusedKey = GlobalKey();
StreamSubscription<Message>? _focusSub;
StreamSubscription<DaemonEvent>? _fileSub;
@override
void didChangeDependencies() {
@@ -42,11 +43,17 @@ class _PqlPanelViewState extends State<PqlPanelView> {
if (ctx != null) Scrollable.ensureVisible(ctx, duration: const Duration(milliseconds: 200), alignment: 0.3);
});
});
_fileSub = kernel.events.on<DaemonEvent>().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();
}
+89 -56
View File
@@ -20,31 +20,40 @@ class _TicketsViewState extends State<TicketsView> {
String _filter = '';
String? _focusedId;
final _focusedKey = GlobalKey();
final Set<String> _expanded = {'active', 'backlog'};
final Set<String> _pinned = {'in_progress', 'ready', 'backlog'};
StreamSubscription<Message>? _focusSub;
StreamSubscription<SchedulerTick>? _schedulerSub;
StreamSubscription<Message>? _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<TicketsView> {
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<SchedulerTick>().where((e) => e.tier == SchedulerTier.oneMinute).listen((_) => _refresh());
}
if (!_loading || _tickets.isNotEmpty) return;
unawaited(_load());
@@ -65,9 +81,24 @@ class _TicketsViewState extends State<TicketsView> {
@override
void dispose() {
_focusSub?.cancel();
_changedSub?.cancel();
_schedulerSub?.cancel();
super.dispose();
}
Future<void> _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<void> _load() async {
final kernel = ClideKernel.of(context);
final resp = await kernel.ipc.request('pql.tickets.list');
@@ -101,27 +132,62 @@ class _TicketsViewState extends State<TicketsView> {
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 = <String, List<_TicketEntry>>{};
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<Widget> 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;