Files
clide/lib/builtin/claude/src/conversation_card.dart
T
jpmschweitzerandClaude Opus 4.8 a2437b0720 unify boxed conversation-card interior padding (T-282)
Bordered cards (tool / Agent calls) used 10px vertical interior padding while
stripe cards (you / claude) used 8, so a collapsed tool/Agent card read
chunkier — taller box and more trailing space — than its neighbours in the
conversation log. Match the bordered variant's vertical padding to the stripe
variant (8) so boxed cards share one rhythm. Box-to-box inter-card margin is
unchanged (a uniform 14). Regenerated the merged-card golden.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 17:42:01 +02:00

374 lines
13 KiB
Dart

/// The base template every conversation turn renders through (T-173).
///
/// One primitive with three [ConversationCardVariant]s (the stripe, bordered,
/// and bare looks the view used to hand-roll), plus chrome wired in once for
/// all message types: a hover/focus-revealed copy button, an always-visible
/// collapse/expand caret for collapsible turns, and an extensible
/// [MessageAction] list. Decoupled from `ConversationItem` — the view maps
/// each item to (variant, accent, label, body, copyText, actions), so future
/// typed cards (T-168) reuse this chrome with a different body.
///
/// T-174: action buttons are always in the widget tree (keyboard/AT reachable);
/// they are revealed visually only while the card is hovered OR any action
/// holds keyboard focus. The caret and each action use [ClideTappable] so Tab
/// traversal + Enter/Space activation work without hovering.
library;
import 'dart:async';
import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
/// An extensible per-message affordance shown in the card's action bar.
/// Copy is provided by the card from `copyText`; callers add more (e.g.
/// fork-from-here, retry) without touching the template.
class MessageAction {
const MessageAction({required this.label, required this.onInvoke});
final String label;
final VoidCallback onInvoke;
}
enum ConversationCardVariant { stripe, bordered, bare }
/// A trailing status mark shown at the right end of the header (T-262): a
/// green check for a succeeded tool call, a red cross for a failure. [none]
/// renders no mark (the default for non-tool cards).
enum ConversationCardStatus { none, success, error }
/// An extra labelled body segment shown below the primary [ConversationCard.body]
/// when the card is expanded (T-262). Lets one card lay out CALL → RESULT (or,
/// for the Agent card, CALL → PROMPT → RESULT — T-263) with a muted sub-label +
/// divider between segments so the reader can tell the parts apart.
class CardSegment {
const CardSegment({required this.label, required this.child});
final String label;
final Widget child;
}
class ConversationCard extends StatefulWidget {
const ConversationCard({
super.key,
this.variant = ConversationCardVariant.stripe,
required this.accent,
required this.label,
required this.body,
this.copyText,
this.actions = const [],
this.collapsible = false,
this.collapsedByDefault = false,
this.collapsedSummary,
this.borderColor,
this.status = ConversationCardStatus.none,
this.extraSegments = const [],
});
final ConversationCardVariant variant;
final Color accent;
final String label;
final Widget body;
/// Trailing header status mark (T-262) — a success check or error cross at
/// the right end of the header. [ConversationCardStatus.none] shows nothing.
final ConversationCardStatus status;
/// Extra labelled segments rendered below [body] when expanded (T-262/T-263);
/// each gets a muted sub-label + divider so CALL/PROMPT/RESULT read apart.
final List<CardSegment> extraSegments;
/// Raw text the copy action yields; no copy button when null.
final String? copyText;
/// Extra actions appended after copy.
final List<MessageAction> actions;
final bool collapsible;
final bool collapsedByDefault;
/// One-line gist shown next to the label while collapsed (e.g. the tool's
/// key arg, or a result's first line), so a collapsed card still says what
/// it holds. Null → just the label.
final String? collapsedSummary;
/// Border colour for the bordered variant (e.g. error red); defaults to the
/// panel border.
final Color? borderColor;
@override
State<ConversationCard> createState() => _ConversationCardState();
}
class _ConversationCardState extends State<ConversationCard> {
bool _hover = false;
late bool _collapsed = widget.collapsible && widget.collapsedByDefault;
// Focus nodes for the action buttons (copy + custom), managed so that
// the Opacity covering the action bar lifts when any action is focused.
// The caret uses its own always-present node (never hidden by Opacity).
final FocusNode _caretFocus = FocusNode(debugLabel: 'card-caret');
List<FocusNode> _actionFocusNodes = [];
int _focusedActionCount = 0;
bool get _anyActionFocused => _focusedActionCount > 0;
/// Rebuilds the action-button focus-node list to match the current set of
/// actions (copyText present or not, plus widget.actions count).
void _syncActionFocusNodes() {
final needed = (widget.copyText != null ? 1 : 0) + widget.actions.length;
if (needed == _actionFocusNodes.length) return;
// Dispose the excess or add new ones.
if (needed < _actionFocusNodes.length) {
for (var i = needed; i < _actionFocusNodes.length; i++) {
_actionFocusNodes[i].removeListener(_onActionFocusChange);
_actionFocusNodes[i].dispose();
}
_actionFocusNodes = _actionFocusNodes.sublist(0, needed);
} else {
for (var i = _actionFocusNodes.length; i < needed; i++) {
final node = FocusNode(debugLabel: 'card-action-$i');
node.addListener(_onActionFocusChange);
_actionFocusNodes.add(node);
}
}
}
void _onActionFocusChange() {
final focused = _actionFocusNodes.where((n) => n.hasFocus).length;
if (focused != _focusedActionCount) {
setState(() => _focusedActionCount = focused);
}
}
@override
void initState() {
super.initState();
_syncActionFocusNodes();
}
@override
void didUpdateWidget(ConversationCard old) {
super.didUpdateWidget(old);
_syncActionFocusNodes();
}
@override
void dispose() {
_caretFocus.dispose();
for (final n in _actionFocusNodes) {
n.removeListener(_onActionFocusChange);
n.dispose();
}
super.dispose();
}
void _copy() {
final text = widget.copyText;
if (text != null) unawaited(ClideKernel.of(context).clipboard.writePlain(text));
}
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
final content = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_header(tokens),
if (!_collapsed) ...[
const SizedBox(height: 4),
widget.body,
for (final seg in widget.extraSegments) ...[
_segmentLabel(tokens, seg.label),
seg.child,
],
],
],
);
return Padding(
padding: const EdgeInsets.only(bottom: 14),
child: MouseRegion(
onEnter: (_) => setState(() => _hover = true),
onExit: (_) => setState(() => _hover = false),
child: _frame(tokens, content),
),
);
}
Widget _frame(SurfaceTokens tokens, Widget content) {
switch (widget.variant) {
case ConversationCardVariant.stripe:
return ClipRRect(
borderRadius: BorderRadius.circular(6),
child: ColoredBox(
color: tokens.globalBackground,
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(width: 3, color: widget.accent),
Expanded(
child: Padding(padding: const EdgeInsets.fromLTRB(12, 8, 12, 8), child: content),
),
],
),
),
),
);
case ConversationCardVariant.bordered:
return Container(
// Vertical interior padding matches the stripe variant (8) so boxed
// cards share one rhythm — a collapsed tool/Agent card no longer reads
// chunkier (taller box + more trailing space) than its neighbours (T-282).
padding: const EdgeInsets.fromLTRB(10, 8, 10, 8),
decoration: BoxDecoration(
color: tokens.globalBackground,
border: Border.all(color: widget.borderColor ?? tokens.panelBorder),
borderRadius: BorderRadius.circular(4),
),
child: content,
);
case ConversationCardVariant.bare:
return content;
}
}
Widget _header(SurfaceTokens tokens) {
final summary = widget.collapsedSummary;
// Action visibility: shown when hovered OR when any action button has
// keyboard focus. Always in the tree so Tab/AT can reach them.
final showActions = _hover || _anyActionFocused;
return Row(
children: [
if (widget.collapsible) _caret(tokens),
ClideText(widget.label, fontSize: clideFontSmall, color: widget.accent, fontFamily: clideMonoFamily),
// While collapsed, show a one-line gist next to the label so the card
// still says what it holds.
if (_collapsed && summary != null) ...[
const SizedBox(width: 10),
Expanded(
child: ClideText(summary, fontSize: clideFontMeta, color: tokens.globalTextMuted, fontFamily: clideMonoFamily, maxLines: 1),
),
] else
const Spacer(),
// Trailing status mark (T-262): success check / error cross, sitting
// between the summary/spacer and the action bar.
if (widget.status != ConversationCardStatus.none) _statusMark(tokens),
// Actions are always in the tree (keyboard/AT always reachable).
// Opacity reveals them on hover or keyboard focus; opacity-0 keeps
// them layout-present but visually hidden so they don't distract.
// alwaysIncludeSemantics keeps them in the semantics tree even at
// opacity 0 (RenderOpacity drops semantics at 0 by default) so AT can
// still discover and activate them without hovering.
Opacity(
opacity: showActions ? 1.0 : 0.0,
alwaysIncludeSemantics: true,
child: Row(children: _actions(tokens)),
),
],
);
}
Widget _caret(SurfaceTokens tokens) {
final label = _collapsed ? 'Expand' : 'Collapse';
return Semantics(
button: true,
label: label,
excludeSemantics: true,
onTap: () => setState(() => _collapsed = !_collapsed),
child: ClideTappable(
focusNode: _caretFocus,
tooltip: label,
onTap: () => setState(() => _collapsed = !_collapsed),
builder: (_, hovered, pressed) => Padding(
padding: const EdgeInsets.only(right: 6),
child: ClideIcon(
_collapsed ? PhosphorIcons.caretRight : PhosphorIcons.caretDown,
size: 12,
color: tokens.globalTextMuted,
),
),
),
);
}
/// The trailing success/error mark (T-262). Carries a Semantics label so the
/// outcome is announced, not just colour-coded.
Widget _statusMark(SurfaceTokens tokens) {
final ClideIconPainter icon;
final Color color;
final String label;
switch (widget.status) {
case ConversationCardStatus.success:
icon = const CheckIcon();
color = tokens.statusSuccess;
label = 'succeeded';
case ConversationCardStatus.error:
icon = const CloseIcon();
color = tokens.statusError;
label = 'failed';
case ConversationCardStatus.none:
return const SizedBox.shrink();
}
return Semantics(
label: label,
container: true,
child: Padding(
padding: const EdgeInsets.only(left: 8),
child: ClideIcon(icon, size: 12, color: color),
),
);
}
/// A muted sub-label + hairline divider introducing an [CardSegment] below
/// the primary body (T-262), so CALL/PROMPT/RESULT read as distinct parts.
Widget _segmentLabel(SurfaceTokens tokens, String label) => Padding(
padding: const EdgeInsets.only(top: 8, bottom: 4),
child: Row(
children: [
ClideText(label, fontSize: clideFontMeta, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
const SizedBox(width: 8),
Expanded(child: Container(height: 1, color: tokens.panelBorder)),
],
),
);
List<Widget> _actions(SurfaceTokens tokens) {
final items = <_ActionItem>[];
if (widget.copyText != null) items.add(_ActionItem('copy', _copy));
for (final a in widget.actions) {
items.add(_ActionItem(a.label, a.onInvoke));
}
return [
for (var i = 0; i < items.length; i++)
Semantics(
button: true,
label: items[i].label,
excludeSemantics: true,
onTap: items[i].onTap,
child: ClideTappable(
focusNode: _actionFocusNodes[i],
tooltip: items[i].label,
onTap: items[i].onTap,
builder: (_, hovered, pressed) => Padding(
padding: const EdgeInsets.only(left: 10),
child: ClideText(
items[i].label,
fontSize: clideFontMeta,
color: tokens.globalTextMuted,
fontFamily: clideMonoFamily,
),
),
),
),
];
}
}
/// Internal pairing of an action label and its callback.
class _ActionItem {
_ActionItem(this.label, this.onTap);
final String label;
final VoidCallback onTap;
}