tickets reader: adopt retained ReaderNav + static tab

Bring the tickets detail in line with markdown/decisions (D-81). The
controller loads on 'load' (the channel the retained ReaderNav emits),
the extension reveals the static tickets.detail tab on selection instead
of the per-click uncontribute/contribute churn (the T-188 anti-pattern),
and the view grabs nav.current on mount and wraps in ClidePaneChrome
with a ReaderActionBar — pin toggle left, back/forward + jump-to-pin
right, no edit pencil (tickets are pql records, not files). The
controller drops its now-unused panels dependency.

Also adds the tickets builtin's first tests — the sidebar list
(load/sections/filter/select/empty/error/refresh) and the detail reader
(load, nav, pin, parents/decisions/status) — covering a pre-existing gap
exposed by bringing these files under test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-01 14:35:18 +02:00
co-authored by Claude Opus 4.8
parent f83e52b818
commit e2b43b2a86
8 changed files with 382 additions and 54 deletions
+7 -10
View File
@@ -20,17 +20,14 @@ class TicketsExtension extends ClideExtension {
@override
Future<void> activate(ClideExtensionContext ctx) async {
// Ensure the retained nav exists so it records selections + emits
// loads whether or not the detail view is mounted (T-199/D-81).
ctx.readerNav.navFor(id, dataKey: 'id');
// Reveal the static detail tab on selection — no per-click
// uncontribute/contribute churn (the T-188 anti-pattern). The nav
// owns load + history.
_sub = ctx.messages.subscribe(publisher: id, channel: 'selection').listen((msg) {
final selectedId = msg.data['id'] as String?;
if (selectedId == null) return;
ctx.panels.uncontribute('tickets.detail');
ctx.panels.contribute(TabContribution(
id: 'tickets.detail',
slot: Slots.contextPanel,
title: 'Ticket',
icon: PhosphorIcons.ticket,
build: (_) => TicketDetailView(initialId: selectedId),
));
if (msg.data['id'] is! String) return;
ctx.arrangement.setVisible(Slots.contextPanel, true);
ctx.arrangement.setCollapsed(Slots.contextPanel, false);
ctx.panels.activateTab(Slots.contextPanel, 'tickets.detail');
@@ -21,13 +21,14 @@ class TicketDetail {
}
class TicketDetailController extends ChangeNotifier {
TicketDetailController({required this.ipc, required this.messages, required this.panels}) {
_sub = messages.subscribe(publisher: 'builtin.tickets', channel: 'selection').listen(_onSelection);
TicketDetailController({required this.ipc, required this.messages}) {
// Load on 'load' — the single channel the retained ReaderNav emits
// (T-199). The extension reveals the tab; the nav owns history.
_sub = messages.subscribe(publisher: 'builtin.tickets', channel: 'load').listen(_onLoad);
}
final DaemonClient ipc;
final MessageBus messages;
final PanelRegistry panels;
StreamSubscription<Message>? _sub;
TicketDetail? _detail;
@@ -36,12 +37,9 @@ class TicketDetailController extends ChangeNotifier {
bool _loading = false;
bool get loading => _loading;
void _onSelection(Message msg) {
void _onLoad(Message msg) {
final id = msg.data['id'] as String?;
if (id != null) {
panels.activateTab(Slots.contextPanel, 'tickets.detail');
unawaited(load(id));
}
if (id != null) unawaited(load(id));
}
Future<void> load(String id) async {
+63 -33
View File
@@ -1,3 +1,4 @@
import 'package:clide/builtin/shared/reader_chrome.dart';
import 'package:clide/builtin/tickets/src/ticket_colors.dart';
import 'package:clide/builtin/tickets/src/ticket_detail_controller.dart';
import 'package:clide/kernel/kernel.dart';
@@ -14,24 +15,37 @@ class TicketDetailView extends StatefulWidget {
class _TicketDetailViewState extends State<TicketDetailView> {
TicketDetailController? _controller;
ReaderNav? _nav;
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_controller != null) return;
final kernel = ClideKernel.of(context);
_controller = TicketDetailController(ipc: kernel.ipc, messages: kernel.messages, panels: kernel.panels);
if (widget.initialId != null) {
_controller!.load(widget.initialId!);
}
_controller = TicketDetailController(ipc: kernel.ipc, messages: kernel.messages);
_nav = kernel.readerNav.navFor('builtin.tickets', dataKey: 'id')..addListener(_onNavChanged);
// Grab the entry the retained nav already holds (a selection that
// revealed this tab before we mounted), else the initialId.
final current = _nav!.current ?? widget.initialId;
if (current != null) _controller!.load(current);
}
void _onNavChanged() {
if (mounted) setState(() {}); // refresh action-bar button state
}
@override
void dispose() {
_nav?.removeListener(_onNavChanged);
_controller?.dispose();
super.dispose();
}
void _onBack() => _nav?.back();
void _onForward() => _nav?.forward();
void _onPin() => _nav?.togglePin();
void _onJumpToPin() => _nav?.jumpToPin();
void _navigateToRecord(BuildContext context, String id) {
final kernel = ClideKernel.of(context);
if (id.startsWith('T-')) {
@@ -56,37 +70,53 @@ class _TicketDetailViewState extends State<TicketDetailView> {
final isDark = ClideTheme.of(ctx).dark;
final typeColors = TicketTypeColors.forTheme(dark: isDark);
return SingleChildScrollView(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_TicketHeader(detail: d, tokens: tokens, typeColors: typeColors),
const SizedBox(height: 12),
_StatusControls(detail: d, tokens: tokens, controller: c),
if (d.description != null && d.description!.isNotEmpty) ...[
return ClidePaneChrome(
title: d.id,
subtitle: d.title,
trailing: [
ReaderActionBar(
canGoBack: _nav?.canGoBack ?? false,
canGoForward: _nav?.canGoForward ?? false,
hasPinned: _nav?.hasPinned ?? false,
onBack: (_nav?.canGoBack ?? false) ? _onBack : null,
onForward: (_nav?.canGoForward ?? false) ? _onForward : null,
onPin: _onPin,
onJumpToPin: (_nav?.hasPinned ?? false) ? _onJumpToPin : null,
onEdit: null, // tickets are pql records, not files
),
],
child: SingleChildScrollView(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_TicketHeader(detail: d, tokens: tokens, typeColors: typeColors),
const SizedBox(height: 12),
ClideMarkdown(d.description!, onRecordTap: (id) => _navigateToRecord(ctx, id)),
_StatusControls(detail: d, tokens: tokens, controller: c),
if (d.description != null && d.description!.isNotEmpty) ...[
const SizedBox(height: 12),
ClideMarkdown(d.description!, onRecordTap: (id) => _navigateToRecord(ctx, id)),
],
if (d.parents.isNotEmpty) ...[
const SizedBox(height: 16),
_SectionLabel(label: 'PARENT TREE', tokens: tokens),
const SizedBox(height: 6),
for (var i = 0; i < d.parents.length; i++)
_CompactCard(
data: d.parents[i],
tokens: tokens,
typeColors: typeColors,
indent: i,
),
],
if (d.decisions.isNotEmpty) ...[
const SizedBox(height: 16),
_SectionLabel(label: 'REFERENCED DECISIONS', tokens: tokens),
const SizedBox(height: 6),
for (final dec in d.decisions) _DecisionRefCard(data: dec, tokens: tokens),
],
],
if (d.parents.isNotEmpty) ...[
const SizedBox(height: 16),
_SectionLabel(label: 'PARENT TREE', tokens: tokens),
const SizedBox(height: 6),
for (var i = 0; i < d.parents.length; i++)
_CompactCard(
data: d.parents[i],
tokens: tokens,
typeColors: typeColors,
indent: i,
),
],
if (d.decisions.isNotEmpty) ...[
const SizedBox(height: 16),
_SectionLabel(label: 'REFERENCED DECISIONS', tokens: tokens),
const SizedBox(height: 6),
for (final dec in d.decisions) _DecisionRefCard(data: dec, tokens: tokens),
],
],
),
),
);
},