Files
clide/lib/builtin/tickets/src/tickets_view.dart
T
jpmschweitzerandClaude Opus 4.8 6d0ebab721 chore: adopt Dart 3.9 toolchain — honest floor + tall-style reformat (T-353)
Raise the declared minimums in pubspec.yaml to what our deps already
require: Flutter >=3.35.0 / Dart >=3.9.0 (was 3.19.0 / 3.5.0). alchemist
0.12 needs Flutter 3.32; Dart 3.9 first ships in Flutter 3.35, so 3.35 is
the binding floor. Pin the exact build toolchain in .fvmrc (Flutter
3.44.1).

Moving to the Dart 3.9 language level switches `dart format` to the new
"tall" style and enables two new lints. This commit is the resulting
mechanical churn, isolated from any behaviour change:
  - whole-tree `dart format` reformat (tall style)
  - `dart fix` for unnecessary_underscores + use_null_aware_elements

No runtime behaviour change; `make test` green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 12:11:53 +02:00

487 lines
18 KiB
Dart

import 'dart:async';
import 'package:clide/builtin/tickets/src/pick_up_prompt.dart';
import 'package:clide/builtin/tickets/src/ticket_colors.dart';
import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
class TicketsView extends StatefulWidget {
const TicketsView({super.key});
@override
State<TicketsView> createState() => _TicketsViewState();
}
class _TicketsViewState extends State<TicketsView> {
List<_TicketEntry> _tickets = [];
String? _error;
bool _loading = true;
String _filter = '';
String? _focusedId;
final _focusedKey = GlobalKey();
final Set<String> _pinned = {'in_progress', 'ready', 'backlog'};
/// Type-filter chips (T-343), ordered large→small. Each maps 1:1 to a pql
/// ticket type; all on by default. An empty set never persists — toggling off
/// the last one snaps all back on, so the list is never mysteriously blank.
static const _allTypes = {'initiative', 'epic', 'story', 'task', 'bug'};
static const _typeOrder = [('initiative', 'Initiative'), ('epic', 'Epic'), ('story', 'Story'), ('task', 'Task'), ('bug', 'Bug')];
final Set<String> _enabledTypes = {..._allTypes};
StreamSubscription<Message>? _focusSub;
StreamSubscription<SchedulerTick>? _schedulerSub;
StreamSubscription<Message>? _changedSub;
StreamSubscription<ProjectOpened>? _projectSub;
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 (_pinned.contains(key)) {
_pinned.remove(key);
} else {
_pinned.add(key);
}
});
}
/// Single-click a chip: toggle that type in/out. Removing the last enabled
/// type resets all back on (T-343).
void _toggleType(String type) {
setState(() {
if (_enabledTypes.contains(type)) {
_enabledTypes.remove(type);
if (_enabledTypes.isEmpty) _enabledTypes.addAll(_allTypes);
} else {
_enabledTypes.add(type);
}
});
}
/// Double-click a chip: isolate (solo) that type — it on, all others off.
/// Double-clicking the already-soloed chip restores all-on (chart-legend
/// solo pattern, T-343).
void _soloType(String type) {
setState(() {
final soloed = _enabledTypes.length == 1 && _enabledTypes.contains(type);
_enabledTypes
..clear()
..addAll(soloed ? _allTypes : {type});
});
}
static String _sectionForStatus(String? status) => status ?? 'backlog';
void _onFocus(Message msg) {
final id = msg.data['id'] as String?;
if (id == null || id == _focusedId) return;
_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);
});
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
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());
// The first load can fire before the project's workspace is wired into
// the daemon (the boot workDir is the launch CWD, not the repo), so pql
// runs against the wrong/old DB and the list errors. Re-fetch once the
// workspace is actually open — the daemon's pql workDir is correct by
// then (ProjectOpened fires after the IPC server swaps). (T-352)
_projectSub = kernel.events.on<ProjectOpened>().listen((_) => _refresh());
}
if (!_loading || _tickets.isNotEmpty) return;
unawaited(_load());
}
@override
void dispose() {
_focusSub?.cancel();
_changedSub?.cancel();
_schedulerSub?.cancel();
_projectSub?.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');
if (!mounted) return;
if (!resp.ok) {
setState(() {
_error = resp.error?.message ?? 'failed to load tickets';
_loading = false;
});
return;
}
final raw = resp.data['tickets'];
if (raw is List) {
setState(() {
_tickets = [for (final e in raw) _TicketEntry.fromJson((e as Map).cast<String, dynamic>())];
_loading = false;
});
} else {
setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
if (_loading) return const Center(child: ClideText('Loading tickets...', muted: true));
if (_error != null) return Padding(padding: const EdgeInsets.all(12), child: ClideText(_error!, muted: true));
if (_tickets.isEmpty) return const Padding(padding: EdgeInsets.all(12), child: ClideText('No tickets.\nRun `pql ticket new` to create one.', muted: true));
final lf = _filter.toLowerCase();
final hasTextFilter = lf.isNotEmpty;
// All-on = "no type filter" (so a full set never hides null-type tickets).
final allTypesOn = _enabledTypes.length == _allTypes.length;
final filtering = hasTextFilter || !allTypesOn;
bool textMatch(_TicketEntry t) =>
t.id.toLowerCase().contains(lf) || t.title.toLowerCase().contains(lf) || (t.status ?? '').contains(lf) || (t.type ?? '').contains(lf);
bool typeMatch(_TicketEntry t) => allTypesOn || _enabledTypes.contains(t.type);
final filtered = _tickets.where((t) => (!hasTextFilter || textMatch(t)) && typeMatch(t)).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: [
Row(
children: [
Expanded(
child: ClideFilterBox(address: 'tickets.panel', 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.byName('arrow-clockwise'), size: 13, color: hovered ? tokens.globalForeground : tokens.globalTextMuted),
),
),
],
),
// Per-type filter chips (T-343): large→small, all on by default.
Padding(
padding: const EdgeInsets.fromLTRB(8, 2, 8, 6),
child: Wrap(
spacing: 6,
runSpacing: 4,
children: [
for (final (type, label) in _typeOrder)
_TypeChip(
label: label,
color: typeColors.forType(type),
active: _enabledTypes.contains(type),
onToggle: () => _toggleType(type),
onSolo: () => _soloType(type),
tokens: tokens,
),
],
),
),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final (status, label) in sections)
if (byStatus[status] case final items? when items.isNotEmpty)
ClideAccordion(
label: label,
count: items.length,
expanded: filtering || _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,
),
],
),
],
),
),
),
],
);
}
}
/// A type-filter chip (T-343): a type-colored dot + label. Active = filled
/// tint + colored border; inactive = muted, no fill. Single-click toggles the
/// type; double-click isolates it (chart-legend solo). One [GestureDetector]
/// owns both so Flutter disambiguates single vs double.
class _TypeChip extends StatelessWidget {
const _TypeChip({required this.label, required this.color, required this.active, required this.onToggle, required this.onSolo, required this.tokens});
final String label;
final Color color;
final bool active;
final VoidCallback onToggle;
final VoidCallback onSolo;
final SurfaceTokens tokens;
@override
Widget build(BuildContext context) {
final dotColor = active ? color : tokens.globalTextMuted;
return Semantics(
button: true,
toggled: active,
label: '$label type filter',
child: ClideTooltip(
message: 'Click to toggle · double-click to isolate',
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: onToggle,
onDoubleTap: onSolo,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
decoration: BoxDecoration(
color: active ? color.withAlpha(0x22) : null,
border: Border.all(color: active ? color : tokens.buttonBorder),
borderRadius: BorderRadius.circular(4),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(color: dotColor, shape: BoxShape.circle),
),
const SizedBox(width: 6),
ClideText(label, fontSize: clideFontSmall, fontFamily: clideMonoFamily, color: active ? tokens.globalForeground : tokens.globalTextMuted),
],
),
),
),
),
),
);
}
}
class _TicketEntry {
const _TicketEntry({required this.id, required this.title, this.type, this.status, this.priority, this.parentId});
final String id;
final String title;
final String? type;
final String? status;
final String? priority;
final String? parentId;
factory _TicketEntry.fromJson(Map<String, dynamic> json) => _TicketEntry(
id: json['id'] as String? ?? '',
title: json['title'] as String? ?? '',
type: json['type'] as String?,
status: json['status'] as String?,
priority: json['priority'] as String?,
parentId: json['parent_id'] as String?,
);
}
class _TicketCard extends StatelessWidget {
const _TicketCard({required this.entry, required this.tokens, required this.typeColors, this.focused = false, this.focusKey});
final _TicketEntry entry;
final SurfaceTokens tokens;
final TicketTypeColors typeColors;
final bool focused;
final GlobalKey? focusKey;
@override
Widget build(BuildContext context) {
final typeColor = typeColors.forType(entry.type);
final statusLabel = _statusLabel(entry.status);
return Padding(
key: focusKey,
padding: const EdgeInsets.only(bottom: 4),
child: ClideTappable(
onTap: () => ClideKernel.of(context).messages.publish('builtin.tickets', 'selection', {'id': entry.id}),
builder: (ctx, hovered, _) => Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: hovered ? tokens.sidebarItemHover : (focused ? tokens.sidebarItemSelected : tokens.panelBackground),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: focused ? tokens.globalFocus : (hovered ? tokens.panelActiveBorder : tokens.panelBorder), width: 1),
),
child: Stack(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Parent shown as a muted breadcrumb above; the card's own ticket
// sits below it under a tree connector and in bold, so it's clear
// which id is the subject and which is its parent (T-281).
if (entry.parentId != null)
ClideTappable(
onTap: () => ClideKernel.of(context).messages.publish('builtin.tickets', 'selection', {'id': entry.parentId}),
builder: (ctx, hovered, _) => Padding(
padding: const EdgeInsets.only(bottom: 1),
child: ClideText(
entry.parentId!,
fontSize: clideFontSmall,
color: hovered ? tokens.globalForeground : tokens.globalTextMuted,
fontFamily: clideMonoFamily,
),
),
),
Row(
children: [
if (entry.parentId != null) ClideText('└ ', fontSize: clideFontSmall, color: tokens.globalTextMuted),
ClideTooltip(
message: entry.type ?? 'task',
child: Container(
width: 8,
height: 8,
decoration: BoxDecoration(color: typeColor, shape: BoxShape.circle),
),
),
const SizedBox(width: 6),
ClideText(entry.id, fontSize: clideFontSmall, color: tokens.globalForeground, fontFamily: clideMonoFamily, fontWeight: FontWeight.w600),
],
),
const SizedBox(height: 4),
ClideText(entry.title, fontSize: clideFontCaption),
if (statusLabel != null) ...[const SizedBox(height: 6), _StatusBadge(label: statusLabel, tokens: tokens, status: entry.status)],
],
),
// Hover affordance (T-327): hand the full ticket to the focused
// Claude pane via the message bus.
if (hovered)
Positioned(
top: 0,
right: 0,
child: _PickUpAction(id: entry.id, tokens: tokens),
),
],
),
),
),
);
}
static String? _statusLabel(String? status) => switch (status) {
'in_progress' => 'WIP',
'review' => 'REVIEW',
'cancelled' => 'CANCELLED',
_ => null,
};
}
/// The hover "pick up" run-icon (T-327): fetches the full ticket and publishes
/// it on the message bus for the focused Claude pane to inject — the sidebar
/// stays decoupled from the session orchestrator (bus-only).
class _PickUpAction extends StatelessWidget {
const _PickUpAction({required this.id, required this.tokens});
final String id;
final SurfaceTokens tokens;
@override
Widget build(BuildContext context) {
return ClideTappable(
tooltip: 'Pick up — hand this ticket to the Claude pane',
onTap: () {
final kernel = ClideKernel.of(context);
unawaited(() async {
final resp = await kernel.ipc.request('pql.tickets.show', args: {'id': id, 'withContext': true});
if (!resp.ok) return; // a missing ticket / failed fetch is a quiet no-op
// Carry the current status so the receiver can gate the in_progress
// transition without a second fetch (T-339).
kernel.messages.publish('builtin.tickets', 'pick-up', {'id': id, 'prompt': pickUpPrompt(resp.data), 'status': resp.data['status']});
}());
},
builder: (ctx, hovered, _) => Padding(
padding: const EdgeInsets.all(2),
child: ClideIcon(PhosphorIcons.byName('person-simple-run'), size: 14, color: hovered ? tokens.globalFocus : tokens.globalTextMuted),
),
);
}
}
class _StatusBadge extends StatelessWidget {
const _StatusBadge({required this.label, required this.tokens, this.status});
final String label;
final SurfaceTokens tokens;
final String? status;
@override
Widget build(BuildContext context) {
final color = switch (status) {
'in_progress' => tokens.statusInfo,
'review' => tokens.statusWarning,
'cancelled' => tokens.statusError,
_ => tokens.globalTextMuted,
};
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(color: color.withAlpha(0x30), borderRadius: BorderRadius.circular(3)),
child: ClideText(label, fontSize: clideFontBadge, color: color, fontFamily: clideMonoFamily),
);
}
}