add ClidePane primitive + focus-driven status-bar slot
Replace the MessageBus-based pane-context slot with a focus-driven one. Panes keep their status widget locally; the FocusTracker holds the focused pane's widget (activeStatusWidget) and ClidePane conveys it to the shared slot only while its contribution is focused, re-conveying on change and clearing on blur. The status-bar item just renders focus.activeStatusWidget, height-clamped and marquee-scrolled when it overflows. Removes the publish/subscribe race the bus version had. T-150. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2198,3 +2198,5 @@ INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by,
|
||||
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-149', 'status', 'backlog', 'in_progress', NULL, '2026-05-23 07:14:36', '2026-05-23 07:14:36', '2026-05-23 07:14:36', NULL, '75bb1fa2d964f284910201b1c15995b9', 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-149', 'status', 'in_progress', 'done', NULL, '2026-05-23 07:27:32', '2026-05-23 07:27:32', '2026-05-23 07:27:32', NULL, 'd020ddfea99355676800af1e39249c85', 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-145', 'status', 'backlog', 'in_progress', NULL, '2026-05-23 08:03:56', '2026-05-23 08:03:56', '2026-05-23 08:03:56', NULL, 'e50db235944f48a5f581ae64a3589385', 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-145', 'status', 'in_progress', 'done', NULL, '2026-05-23 08:43:19', '2026-05-23 08:43:19', '2026-05-23 08:43:19', NULL, 'c9cf511becf7e542d465701e51dba4e2', 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-150', 'status', 'backlog', 'in_progress', NULL, '2026-05-23 09:04:54', '2026-05-23 09:04:54', '2026-05-23 09:04:54', NULL, 'fcc43ef1e1ae3c1dfa0e9c94ffa35bed', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
|
||||
@@ -2568,3 +2568,17 @@ Applies to the lead session and every teammate. Re-validate field names against
|
||||
|
||||
Acceptance: lead pane header and each teammate tile header show current model + an accept-edits/plan-mode badge + a context-usage indicator, updating live from the transcript. Blocked by T-137 (done), T-140 (done).
|
||||
', 'in_progress', 'medium', NULL, NULL, NULL, '2026-05-23 06:07:59', '2026-05-23 08:03:56', NULL, '917515bd4833dddf3299994c1d6739d2', 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-145', 'task', 'T-132', 'Per-session status strip: model / permission-mode / context%', 'A compact per-session status strip in the lead Claude pane header AND each teammate tile header (T-140), showing: current model, permission mode (accept-edits / plan / default / bypass), and context-window usage %.
|
||||
|
||||
Shared status source (this ticket owns it): extract per-session status in the drift-contained reader/observer (D-75) — do NOT parse CC internals in the widgets.
|
||||
- Model: team config member.model, overridden by the latest assistant message.model in the transcript when present.
|
||||
- Permission mode: the transcript''s `permission-mode` records (TranscriptReader currently SKIPS these — surface the latest instead). Also honor config member.planModeRequired for teammates.
|
||||
- Context %: latest assistant message.usage (input_tokens + cache_read_input_tokens + cache_creation_input_tokens + output_tokens) over the model''s context limit.
|
||||
|
||||
Delivery: emit a per-session status to the UI via the bus/events (e.g. a TeamMemberStatus event or an extra MessageBus channel) so BOTH the header strip and the T-141 sidebar render from one source without re-tailing.
|
||||
|
||||
Applies to the lead session and every teammate. Re-validate field names against a live team run / CC version bump (undocumented, per T-134).
|
||||
|
||||
Acceptance: lead pane header and each teammate tile header show current model + an accept-edits/plan-mode badge + a context-usage indicator, updating live from the transcript. Blocked by T-137 (done), T-140 (done).
|
||||
', 'done', 'medium', NULL, NULL, NULL, '2026-05-23 06:07:59', '2026-05-23 08:43:19', NULL, 'e7823e9d4aab062c19296bcbe5f5fc59', 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-150', 'task', 'T-132', 'ClidePane primitive + focus-driven status-bar slot', 'A behavior-only pane wrapper (no chrome) every pane uses, plus the plumbing to surface a pane status widget to the bottom status bar. FocusTracker gains: activeStatusWidget (the focused pane status widget), setStatusWidget(contributionId, widget) applied ONLY when that contribution is focused (background panes ignored; they keep content locally and re-convey on focus), and clears activeStatusWidget on focus change. ClidePane(contributionId, statusWidget, active, child): when it is the shown pane (active sub-tab AND its contribution focused) it conveys statusWidget on focus-gain and on content change. New status-bar item renders focus.activeStatusWidget in a fixed-height Container that marquee-scrolls on overflow (new ClideMarquee primitive, own-the-stack, no package). Claude adopts ClidePane (statusWidget = the model/mode/context line), replacing the bus-text slot from 06cf9f8. Disconnects slot plumbing from what a pane renders: text, icon, dropdown, animation, anything.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-05-23 09:04:40', '2026-05-23 09:04:54', NULL, '14e4ec869de090b66ff3003f39924361', 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);
|
||||
|
||||
+5
-4
@@ -18,10 +18,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
||||
|
||||
### Added
|
||||
|
||||
- Per-session status in the bottom status bar (T-145) — the active
|
||||
Claude pane publishes its model · permission mode (accept-edits /
|
||||
plan / …) · context-token count to a status-bar context slot, swapping
|
||||
to the focused pane on tab switch. Context is a token count, not a %
|
||||
- Per-session status in the bottom status bar (T-145, T-150) — the
|
||||
active Claude pane shows its model · permission mode (accept-edits /
|
||||
plan / …) · context-token count in a status-bar slot, swapping to the
|
||||
focused pane on tab switch and clearing when focus leaves. Long text
|
||||
marquee-scrolls within the slot. Context is a token count, not a %
|
||||
(the transcript doesn't carry the model's window limit).
|
||||
- tmux agent teams surface as native teammate tiles (T-139, T-140) —
|
||||
when a Claude team is running, each teammate shows as a live
|
||||
|
||||
@@ -15,7 +15,6 @@ import 'conversation_controller.dart';
|
||||
import 'conversation_view.dart';
|
||||
import 'session_naming.dart';
|
||||
import 'tmux_session.dart' as tmux;
|
||||
import 'pane_context_status.dart';
|
||||
import 'transcript_publisher.dart';
|
||||
import 'transcript_reader.dart';
|
||||
|
||||
@@ -26,16 +25,22 @@ class ClaudePane extends StatefulWidget {
|
||||
this.secondaryIndex,
|
||||
this.showChrome = true,
|
||||
this.active = true,
|
||||
this.contributionId = 'claude.primary',
|
||||
}) : assert(isPrimary || secondaryIndex != null, 'secondary panes need an index');
|
||||
|
||||
final bool isPrimary;
|
||||
final bool showChrome;
|
||||
final int? secondaryIndex;
|
||||
|
||||
/// Whether this pane is the visible/focused tab. Only the active pane
|
||||
/// publishes its status to the status-bar context slot (T-145).
|
||||
/// Whether this pane is the visible/focused sub-tab. Only the active
|
||||
/// pane publishes its status to the status-bar context slot (T-145).
|
||||
final bool active;
|
||||
|
||||
/// Workspace contribution id this pane lives under (the Claude tab —
|
||||
/// keep in sync with the extension's TabContribution id). The status
|
||||
/// slot shows our message only while this contribution is focused.
|
||||
final String contributionId;
|
||||
|
||||
@override
|
||||
State<ClaudePane> createState() => _ClaudePaneState();
|
||||
}
|
||||
@@ -63,22 +68,18 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
bool _spawned = false;
|
||||
bool _usingTmux = false;
|
||||
|
||||
// Publish this pane's status line to the status-bar context slot, but
|
||||
// only when it's the active tab — the active pane owns the slot; an
|
||||
// inactive pane staying quiet lets the active one win without a race
|
||||
// (T-145). Switching tabs re-publishes from the newly-active pane.
|
||||
void _publishContext() {
|
||||
if (!widget.active || _status.isEmpty) return;
|
||||
final messages = _kernel()?.messages;
|
||||
if (messages == null) return;
|
||||
publishPaneContext(messages, 'builtin.claude', formatStatusLine(_status));
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(ClaudePane old) {
|
||||
super.didUpdateWidget(old);
|
||||
// Became the active tab → push our status into the slot.
|
||||
if (widget.active && !old.active) _publishContext();
|
||||
// The status line surfaced to the bottom status bar via ClidePane —
|
||||
// null until Claude reports a model/mode/context. ClidePane conveys it
|
||||
// while this pane is the focused one (T-150).
|
||||
Widget? _statusWidget(SurfaceTokens tokens) {
|
||||
if (_status.isEmpty) return null;
|
||||
return ClideText(
|
||||
formatStatusLine(_status),
|
||||
fontSize: clideFontSmall,
|
||||
fontFamily: clideMonoFamily,
|
||||
color: tokens.statusBarForeground,
|
||||
maxLines: 1,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -279,10 +280,11 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
channel: channel,
|
||||
);
|
||||
_conversation = ConversationController.fromBus(messages: messages, channel: channel);
|
||||
// On status change, rebuild — ClidePane re-conveys the new statusWidget
|
||||
// to the bar while this pane is focused (T-150).
|
||||
_statusSub = _feed!.statusStream.listen((s) {
|
||||
if (!mounted) return;
|
||||
setState(() => _status = s);
|
||||
_publishContext();
|
||||
});
|
||||
_subscribe();
|
||||
setState(() {});
|
||||
@@ -345,6 +347,7 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final title = widget.isPrimary ? 'claude — primary' : 'claude — secondary ${widget.secondaryIndex}';
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
|
||||
final Widget body;
|
||||
if (_error != null) {
|
||||
@@ -376,20 +379,29 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
body = const Center(child: ClideText('attaching…', muted: true));
|
||||
}
|
||||
|
||||
if (!widget.showChrome) return body;
|
||||
final content = widget.showChrome
|
||||
? ClidePaneChrome(
|
||||
title: title,
|
||||
subtitle: _error ?? _statusLine,
|
||||
onClose: widget.isPrimary
|
||||
? null
|
||||
: () {
|
||||
final id = _paneId;
|
||||
if (id != null) {
|
||||
unawaited(_ipc()?.request('pane.close', args: {'id': id}));
|
||||
}
|
||||
},
|
||||
child: body,
|
||||
)
|
||||
: body;
|
||||
|
||||
return ClidePaneChrome(
|
||||
title: title,
|
||||
subtitle: _error ?? _statusLine,
|
||||
onClose: widget.isPrimary
|
||||
? null
|
||||
: () {
|
||||
final id = _paneId;
|
||||
if (id != null) {
|
||||
unawaited(_ipc()?.request('pane.close', args: {'id': id}));
|
||||
}
|
||||
},
|
||||
child: body,
|
||||
// Surface this pane's status to the bottom status-bar slot while it's
|
||||
// the focused pane (T-150).
|
||||
return ClidePane(
|
||||
contributionId: widget.contributionId,
|
||||
active: widget.active,
|
||||
statusWidget: _statusWidget(tokens),
|
||||
child: content,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,74 +1,44 @@
|
||||
/// The status-bar "in-pane context" slot (T-145).
|
||||
///
|
||||
/// A generic, publisher-agnostic slot: a pane publishes a short status
|
||||
/// string to [paneContextChannel] on the MessageBus, and the bottom
|
||||
/// status bar shows the latest one. The active pane publishes (an
|
||||
/// inactive pane stays quiet), so switching tabs swaps the slot to the
|
||||
/// newly-active pane's message. The Claude pane is the first publisher
|
||||
/// (model · permission-mode · context); other panes can use the same
|
||||
/// channel.
|
||||
/// Status-bar item that shows the *focused* pane's status widget
|
||||
/// (T-150). The content comes from whichever pane is focused — each pane
|
||||
/// surfaces its own [ClidePane.statusWidget] via [FocusTracker], so this
|
||||
/// item is generic: it just renders `focus.activeStatusWidget`, clamped
|
||||
/// to a fixed height and marquee-scrolled when it overflows. Nothing
|
||||
/// (and no space) when no focused pane has a status — so it clears on
|
||||
/// focus change.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// MessageBus channel for the status-bar context slot. Data: `{'text': String}`.
|
||||
const paneContextChannel = 'statusbar.context';
|
||||
/// Max width the slot occupies in the status bar before marquee kicks in.
|
||||
const double _slotMaxWidth = 360;
|
||||
|
||||
/// Publish [text] to the context slot (empty string clears it).
|
||||
void publishPaneContext(MessageBus messages, String publisher, String text) {
|
||||
messages.publish(publisher, paneContextChannel, {'text': text});
|
||||
}
|
||||
/// Fixed slot height — panes can render anything, but not blow up the bar.
|
||||
const double _slotHeight = 16;
|
||||
|
||||
/// Status-bar item that shows the latest pane-context message (nothing
|
||||
/// when empty). Subscribes to the bus itself via the ambient kernel.
|
||||
class PaneContextStatusItem extends StatefulWidget {
|
||||
class PaneContextStatusItem extends StatelessWidget {
|
||||
const PaneContextStatusItem({super.key});
|
||||
|
||||
@override
|
||||
State<PaneContextStatusItem> createState() => _PaneContextStatusItemState();
|
||||
}
|
||||
|
||||
class _PaneContextStatusItemState extends State<PaneContextStatusItem> {
|
||||
StreamSubscription<Message>? _sub;
|
||||
String _text = '';
|
||||
bool _subscribed = false;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_subscribed) return;
|
||||
_subscribed = true;
|
||||
final messages = ClideKernel.of(context).messages;
|
||||
_sub = messages.subscribe(channel: paneContextChannel).listen((m) {
|
||||
final t = (m.data['text'] as String?) ?? '';
|
||||
if (t == _text || !mounted) return;
|
||||
setState(() => _text = t);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_sub?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_text.isEmpty) return const SizedBox.shrink();
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: ClideText(
|
||||
_text,
|
||||
fontSize: clideFontSmall,
|
||||
fontFamily: clideMonoFamily,
|
||||
color: tokens.statusBarForeground,
|
||||
maxLines: 1,
|
||||
),
|
||||
final focus = ClideKernel.of(context).focus;
|
||||
return ListenableBuilder(
|
||||
listenable: focus,
|
||||
builder: (ctx, _) {
|
||||
final widget = focus.activeStatusWidget;
|
||||
if (widget == null) return const SizedBox.shrink();
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: SizedBox(
|
||||
height: _slotHeight,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: _slotMaxWidth),
|
||||
child: ClideMarquee(child: widget),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import 'package:flutter/widgets.dart';
|
||||
class FocusTracker extends ChangeNotifier {
|
||||
SlotId? _slot;
|
||||
String? _contributionId;
|
||||
Widget? _activeStatusWidget;
|
||||
final Map<SlotId, FocusScopeNode> _scopes = {};
|
||||
|
||||
/// Static order panels cycle through. Matches the visual left-to-right
|
||||
@@ -26,17 +27,37 @@ class FocusTracker extends ChangeNotifier {
|
||||
SlotId? get activeSlot => _slot;
|
||||
String? get activeContributionId => _contributionId;
|
||||
|
||||
/// The focused pane's status-bar widget (T-150). The status bar renders
|
||||
/// this; it's null when the focused contribution has no status, so the
|
||||
/// bar clears automatically on focus change.
|
||||
Widget? get activeStatusWidget => _activeStatusWidget;
|
||||
|
||||
void setActive({required SlotId slot, required String contributionId}) {
|
||||
if (_slot == slot && _contributionId == contributionId) return;
|
||||
_slot = slot;
|
||||
_contributionId = contributionId;
|
||||
// Focus moved — the previous pane's status no longer applies. The
|
||||
// newly-focused pane re-conveys its own via [setStatusWidget].
|
||||
_activeStatusWidget = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Convey [widget] (or null) as the status-bar content for
|
||||
/// [contributionId]. Applied only while that contribution is focused —
|
||||
/// a background pane's update is ignored, so it keeps its content
|
||||
/// locally and re-conveys when it regains focus (T-150).
|
||||
void setStatusWidget(String contributionId, Widget? widget) {
|
||||
if (contributionId != _contributionId) return;
|
||||
if (identical(_activeStatusWidget, widget)) return;
|
||||
_activeStatusWidget = widget;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void clear() {
|
||||
if (_slot == null && _contributionId == null) return;
|
||||
if (_slot == null && _contributionId == null && _activeStatusWidget == null) return;
|
||||
_slot = null;
|
||||
_contributionId = null;
|
||||
_activeStatusWidget = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/// Horizontal marquee (T-150). Shows [child] statically when it fits the
|
||||
/// available width; when it's wider, scrolls it leftward in a seamless
|
||||
/// loop (a second copy follows after [gap]). Clips to its box. Used by
|
||||
/// the status-bar slot so a long pane status doesn't get truncated.
|
||||
///
|
||||
/// Own-the-stack: a `Ticker`-driven `SingleChildScrollView`, no package.
|
||||
library;
|
||||
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class ClideMarquee extends StatefulWidget {
|
||||
const ClideMarquee({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.pxPerSecond = 28,
|
||||
this.gap = 48,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
|
||||
/// Scroll speed when overflowing.
|
||||
final double pxPerSecond;
|
||||
|
||||
/// Space between the looped copies.
|
||||
final double gap;
|
||||
|
||||
@override
|
||||
State<ClideMarquee> createState() => _ClideMarqueeState();
|
||||
}
|
||||
|
||||
class _ClideMarqueeState extends State<ClideMarquee> with SingleTickerProviderStateMixin {
|
||||
final GlobalKey _childKey = GlobalKey();
|
||||
final ScrollController _scroll = ScrollController();
|
||||
late final Ticker _ticker = createTicker(_tick);
|
||||
|
||||
double _contentWidth = 0;
|
||||
double _viewportWidth = 0;
|
||||
double _offset = 0;
|
||||
Duration _last = Duration.zero;
|
||||
|
||||
bool get _overflow => _contentWidth > _viewportWidth + 0.5;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _measure());
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(ClideMarquee old) {
|
||||
super.didUpdateWidget(old);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _measure());
|
||||
}
|
||||
|
||||
void _measure() {
|
||||
if (!mounted) return;
|
||||
final w = _childKey.currentContext?.size?.width ?? 0;
|
||||
if ((w - _contentWidth).abs() > 0.5) {
|
||||
setState(() => _contentWidth = w);
|
||||
}
|
||||
if (_overflow && !_ticker.isActive) {
|
||||
_last = Duration.zero;
|
||||
_ticker.start();
|
||||
} else if (!_overflow && _ticker.isActive) {
|
||||
_ticker.stop();
|
||||
_offset = 0;
|
||||
if (_scroll.hasClients) _scroll.jumpTo(0);
|
||||
}
|
||||
}
|
||||
|
||||
void _tick(Duration elapsed) {
|
||||
if (!_overflow || !_scroll.hasClients) return;
|
||||
final dt = _last == Duration.zero ? 0.0 : (elapsed - _last).inMicroseconds / 1e6;
|
||||
_last = elapsed;
|
||||
final span = _contentWidth + widget.gap;
|
||||
if (span <= 0) return;
|
||||
_offset = (_offset + widget.pxPerSecond * dt) % span;
|
||||
final max = _scroll.position.maxScrollExtent;
|
||||
_scroll.jumpTo(_offset.clamp(0, max));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ticker.dispose();
|
||||
_scroll.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ClipRect(
|
||||
child: LayoutBuilder(
|
||||
builder: (ctx, constraints) {
|
||||
_viewportWidth = constraints.maxWidth;
|
||||
return SingleChildScrollView(
|
||||
controller: _scroll,
|
||||
scrollDirection: Axis.horizontal,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
KeyedSubtree(key: _childKey, child: widget.child),
|
||||
if (_overflow) ...[SizedBox(width: widget.gap), widget.child],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/// Behaviour-only wrapper every pane uses (T-150). No chrome — that's
|
||||
/// [ClidePaneChrome]'s job. ClidePane handles cross-pane uniformity:
|
||||
/// surfacing the pane's [statusWidget] to the bottom status bar while the
|
||||
/// pane is focused, via [FocusTracker.setStatusWidget].
|
||||
///
|
||||
/// The widget lives with the pane; ClidePane only conveys it to the
|
||||
/// shared slot while this pane is the shown one (its contribution is
|
||||
/// focused and, for multi-pane contributions, it's the [active] sub-tab),
|
||||
/// and re-conveys whenever [statusWidget] changes. A backgrounded pane
|
||||
/// keeps its content locally and re-conveys on regaining focus.
|
||||
library;
|
||||
|
||||
import 'package:clide/kernel/src/facade.dart';
|
||||
import 'package:clide/kernel/src/focus.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class ClidePane extends StatefulWidget {
|
||||
const ClidePane({
|
||||
super.key,
|
||||
required this.contributionId,
|
||||
required this.child,
|
||||
this.statusWidget,
|
||||
this.active = true,
|
||||
});
|
||||
|
||||
/// The workspace contribution id this pane belongs to (what
|
||||
/// [FocusTracker.activeContributionId] reports when it's focused).
|
||||
final String contributionId;
|
||||
|
||||
/// Status-bar content to surface while focused, or null for none.
|
||||
final Widget? statusWidget;
|
||||
|
||||
/// For contributions hosting multiple panes (e.g. Claude's sub-tabs),
|
||||
/// whether this is the visible one. Only the active pane conveys.
|
||||
final bool active;
|
||||
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
State<ClidePane> createState() => _ClidePaneState();
|
||||
}
|
||||
|
||||
class _ClidePaneState extends State<ClidePane> {
|
||||
FocusTracker? _focus;
|
||||
bool _wired = false;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (!_wired) {
|
||||
_wired = true;
|
||||
_focus = ClideKernel.of(context).focus..addListener(_sync);
|
||||
}
|
||||
_sync();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(ClidePane old) {
|
||||
super.didUpdateWidget(old);
|
||||
if (old.statusWidget != widget.statusWidget || old.active != widget.active || old.contributionId != widget.contributionId) {
|
||||
_sync();
|
||||
}
|
||||
}
|
||||
|
||||
bool get _shown => widget.active && _focus?.activeContributionId == widget.contributionId;
|
||||
|
||||
// Convey our status while shown. Re-entrancy is bounded: setStatusWidget
|
||||
// notifies (→ _sync again), but the identical-widget guard there makes
|
||||
// the second pass a no-op.
|
||||
void _sync() {
|
||||
if (_shown) _focus?.setStatusWidget(widget.contributionId, widget.statusWidget);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (_shown) _focus?.setStatusWidget(widget.contributionId, null);
|
||||
_focus?.removeListener(_sync);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => widget.child;
|
||||
}
|
||||
@@ -12,10 +12,12 @@ export 'src/clide_code_block.dart';
|
||||
export 'src/clide_divider.dart';
|
||||
export 'src/clide_filter_box.dart';
|
||||
export 'src/clide_markdown.dart';
|
||||
export 'src/clide_marquee.dart';
|
||||
export 'src/clide_svg_view.dart';
|
||||
export 'src/clide_icon.dart';
|
||||
export 'src/clide_icon_rail.dart';
|
||||
export 'src/clide_palette.dart';
|
||||
export 'src/clide_pane.dart';
|
||||
export 'src/clide_pane_chrome.dart';
|
||||
export 'src/clide_resize_border.dart';
|
||||
export 'src/clide_pty_view.dart';
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
/// Tests for the status-bar in-pane context slot (T-145): it shows the
|
||||
/// latest text published on the bus channel and clears on empty.
|
||||
/// Tests for the focus-driven status-bar slot item (T-150): it renders
|
||||
/// the focused pane's status widget and clears when focus moves to a
|
||||
/// pane with no status.
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/claude/src/pane_context_status.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:clide/kernel/src/panels/slot_id.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../helpers/kernel_fixture.dart';
|
||||
@@ -14,27 +16,20 @@ void main() {
|
||||
setUp(() async => f = await KernelFixture.create());
|
||||
tearDown(() => f.dispose());
|
||||
|
||||
testWidgets('renders the latest published context, clears on empty', (tester) async {
|
||||
testWidgets('renders the focused pane status, clears on focus change', (tester) async {
|
||||
await tester.pumpWidget(harness(f, const PaneContextStatusItem()));
|
||||
await tester.pump();
|
||||
expect(find.byType(ClideText), findsNothing); // nothing published yet
|
||||
expect(find.text('S'), findsNothing); // nothing focused
|
||||
|
||||
publishPaneContext(f.services.messages, 'builtin.claude', 'opus 4.7 · default · 21k ctx');
|
||||
final focus = f.services.focus;
|
||||
focus.setActive(slot: Slots.workspace, contributionId: 'pane.x');
|
||||
focus.setStatusWidget('pane.x', const Text('S', textDirection: TextDirection.ltr));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
expect(find.textContaining('opus 4.7'), findsOneWidget);
|
||||
expect(find.text('S'), findsOneWidget);
|
||||
|
||||
// A newer publisher overwrites the slot.
|
||||
publishPaneContext(f.services.messages, 'builtin.editor', 'lib/app.dart · modified');
|
||||
// Focus a pane with no status → the slot clears.
|
||||
focus.setActive(slot: Slots.workspace, contributionId: 'pane.y');
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
expect(find.textContaining('opus 4.7'), findsNothing);
|
||||
expect(find.textContaining('lib/app.dart'), findsOneWidget);
|
||||
|
||||
// Empty clears it.
|
||||
publishPaneContext(f.services.messages, 'builtin.editor', '');
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
expect(find.byType(ClideText), findsNothing);
|
||||
expect(find.text('S'), findsNothing);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/// Tests for FocusTracker's status-widget slot (T-150).
|
||||
library;
|
||||
|
||||
import 'package:clide/kernel/src/focus.dart';
|
||||
import 'package:clide/kernel/src/panels/slot_id.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('FocusTracker status widget', () {
|
||||
test('setStatusWidget applies only for the focused contribution', () {
|
||||
final f = FocusTracker();
|
||||
f.setActive(slot: Slots.workspace, contributionId: 'a');
|
||||
const wa = SizedBox(key: ValueKey('a'));
|
||||
f.setStatusWidget('a', wa);
|
||||
expect(f.activeStatusWidget, wa);
|
||||
|
||||
// A background contribution's update is ignored.
|
||||
f.setStatusWidget('b', const SizedBox(key: ValueKey('b')));
|
||||
expect(f.activeStatusWidget, wa);
|
||||
});
|
||||
|
||||
test('a focus change clears the previous status widget', () {
|
||||
final f = FocusTracker();
|
||||
f.setActive(slot: Slots.workspace, contributionId: 'a');
|
||||
f.setStatusWidget('a', const SizedBox());
|
||||
expect(f.activeStatusWidget, isNotNull);
|
||||
|
||||
f.setActive(slot: Slots.workspace, contributionId: 'b');
|
||||
expect(f.activeStatusWidget, isNull);
|
||||
});
|
||||
|
||||
test('clear() resets focus and status', () {
|
||||
final f = FocusTracker();
|
||||
f.setActive(slot: Slots.workspace, contributionId: 'a');
|
||||
f.setStatusWidget('a', const SizedBox());
|
||||
f.clear();
|
||||
expect(f.activeContributionId, isNull);
|
||||
expect(f.activeStatusWidget, isNull);
|
||||
});
|
||||
|
||||
test('setStatusWidget notifies on change, not on a repeat', () {
|
||||
final f = FocusTracker();
|
||||
f.setActive(slot: Slots.workspace, contributionId: 'a');
|
||||
var n = 0;
|
||||
f.addListener(() => n++);
|
||||
const w = SizedBox();
|
||||
f.setStatusWidget('a', w);
|
||||
expect(n, 1);
|
||||
f.setStatusWidget('a', w); // identical → no notify
|
||||
expect(n, 1);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/// Tests for ClideMarquee (T-150): static when the child fits, scrolls
|
||||
/// (looped copy) when it overflows; clips its box.
|
||||
library;
|
||||
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
Widget _boxed(double width, Widget child) => Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: Center(
|
||||
child: SizedBox(width: width, height: 16, child: child),
|
||||
),
|
||||
);
|
||||
|
||||
void main() {
|
||||
testWidgets('shows the child statically when it fits', (tester) async {
|
||||
await tester.pumpWidget(_boxed(300, const ClideMarquee(child: Text('short'))));
|
||||
await tester.pump();
|
||||
expect(find.text('short'), findsOneWidget);
|
||||
await tester.pumpWidget(const SizedBox()); // tear down the ticker
|
||||
});
|
||||
|
||||
testWidgets('renders a looped copy and runs without overflow when wider than the slot', (tester) async {
|
||||
await tester.pumpWidget(_boxed(40, const ClideMarquee(child: Text('a long status line that overflows the slot'))));
|
||||
await tester.pump(); // measure
|
||||
await tester.pump(const Duration(milliseconds: 100)); // advance the ticker
|
||||
expect(find.text('a long status line that overflows the slot'), findsWidgets);
|
||||
expect(tester.takeException(), isNull);
|
||||
await tester.pumpWidget(const SizedBox()); // dispose → stop ticker
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/// Tests for ClidePane (T-150): it surfaces its statusWidget to the
|
||||
/// FocusTracker slot only while it is the shown (focused + active) pane.
|
||||
library;
|
||||
|
||||
import 'package:clide/kernel/src/panels/slot_id.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../helpers/kernel_fixture.dart';
|
||||
import '../../helpers/widget_harness.dart';
|
||||
|
||||
void main() {
|
||||
late KernelFixture f;
|
||||
setUp(() async => f = await KernelFixture.create());
|
||||
tearDown(() => f.dispose());
|
||||
|
||||
testWidgets('conveys its status while its contribution is focused, clears on blur', (tester) async {
|
||||
await tester.pumpWidget(harness(
|
||||
f,
|
||||
const ClidePane(
|
||||
contributionId: 'pane.x',
|
||||
statusWidget: Text('S', textDirection: TextDirection.ltr),
|
||||
child: SizedBox(),
|
||||
),
|
||||
));
|
||||
final focus = f.services.focus;
|
||||
expect(focus.activeStatusWidget, isNull); // not focused yet
|
||||
|
||||
focus.setActive(slot: Slots.workspace, contributionId: 'pane.x');
|
||||
await tester.pump();
|
||||
expect(focus.activeStatusWidget, isA<Text>());
|
||||
|
||||
focus.setActive(slot: Slots.workspace, contributionId: 'other');
|
||||
await tester.pump();
|
||||
expect(focus.activeStatusWidget, isNull); // focus moved away → cleared
|
||||
});
|
||||
|
||||
testWidgets('an inactive pane never conveys', (tester) async {
|
||||
await tester.pumpWidget(harness(
|
||||
f,
|
||||
const ClidePane(
|
||||
contributionId: 'pane.x',
|
||||
active: false,
|
||||
statusWidget: Text('S', textDirection: TextDirection.ltr),
|
||||
child: SizedBox(),
|
||||
),
|
||||
));
|
||||
f.services.focus.setActive(slot: Slots.workspace, contributionId: 'pane.x');
|
||||
await tester.pump();
|
||||
expect(f.services.focus.activeStatusWidget, isNull);
|
||||
});
|
||||
|
||||
testWidgets('re-conveys when statusWidget changes while focused', (tester) async {
|
||||
f.services.focus.setActive(slot: Slots.workspace, contributionId: 'pane.x');
|
||||
var label = 'A';
|
||||
late StateSetter setLabel;
|
||||
await tester.pumpWidget(harness(
|
||||
f,
|
||||
StatefulBuilder(
|
||||
builder: (ctx, setState) {
|
||||
setLabel = setState;
|
||||
return ClidePane(
|
||||
contributionId: 'pane.x',
|
||||
statusWidget: Text(label, textDirection: TextDirection.ltr),
|
||||
child: const SizedBox(),
|
||||
);
|
||||
},
|
||||
),
|
||||
));
|
||||
await tester.pump();
|
||||
expect((f.services.focus.activeStatusWidget! as Text).data, 'A');
|
||||
|
||||
setLabel(() => label = 'B');
|
||||
await tester.pump();
|
||||
expect((f.services.focus.activeStatusWidget! as Text).data, 'B');
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user