restyle activity card as a holder container; background-toggle collapse (T-266)

Extracts a shared ClideHolderCard primitive (consumed next by T-264) that
renders a folded run as one framed container wrapping its sub-cards:

- The whole holder background is the collapse toggle — a gesture target
  behind the children that only fires for hits the children don't consume.
  Each sub-card opaquely absorbs its own bounds, so a card tap (and its
  copy button) interacts with the card, never the holder; selection drags
  pass through. This ends the scroll race: while a run tail-follows, a
  click on whatever background is in view collapses it, no top header to
  reach.
- A focusable caret keeps the control keyboard/AT reachable (D-78); the
  collapsed ticker + step count are preserved.
- _ActivityCard becomes a thin stateless adopter of the primitive.

Tests: ticker/expand, background-toggle, child-tap-not-hijacked, copy
still works, keyboard Activate path; golden for collapsed + expanded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 11:04:38 +02:00
co-authored by Claude Opus 4.8
parent a7a7b04a05
commit 75f7d1ca12
8 changed files with 478 additions and 79 deletions
+22 -79
View File
@@ -15,6 +15,7 @@ import 'dart:io';
import 'package:clide/builtin/claude/src/activity_cluster.dart';
import 'package:clide/builtin/claude/src/conversation_card.dart';
import 'package:clide/builtin/claude/src/conversation_controller.dart';
import 'package:clide/builtin/claude/src/holder_card.dart';
import 'package:clide/builtin/claude/src/prompt_card.dart';
import 'package:clide/builtin/claude/src/transcript_reader.dart';
import 'package:clide/kernel/src/facade.dart';
@@ -541,12 +542,12 @@ class _ConversationTurn extends StatelessWidget {
}
/// A folded run of meta items rendered as one collapsible activity card
/// (T-230). Collapsed (default): a one-line live ticker of the latest step +
/// a step count — re-grouped on every rebuild, so the ticker updates in place
/// as the run grows. Expanded: every folded step in order. Keyboard + screen
/// reader accessible: [ClideTappable] activates on Enter/Space, and the
/// Semantics announces the step count + expanded/collapsed state.
class _ActivityCard extends StatefulWidget {
/// (T-230), now through the shared [ClideHolderCard] container (T-266).
/// Collapsed (default): a one-line live ticker of the latest step + a step
/// count — re-grouped on every rebuild, so the ticker updates in place as the
/// run grows. Expanded: every folded step, wrapped in the holder frame whose
/// background toggles collapse. Stateless — the holder owns the expand state.
class _ActivityCard extends StatelessWidget {
const _ActivityCard({
required this.items,
required this.tokens,
@@ -563,81 +564,23 @@ class _ActivityCard extends StatefulWidget {
final Map<String, ToolResultMessage> resultByToolUseId;
final Map<String, List<UserMessage>> promptsByToolUseId;
@override
State<_ActivityCard> createState() => _ActivityCardState();
}
class _ActivityCardState extends State<_ActivityCard> {
bool _expanded = false;
@override
Widget build(BuildContext context) {
final tokens = widget.tokens;
final count = widget.items.length;
final stepLabel = count == 1 ? '1 step' : '$count steps';
final header = ClideTappable(
onTap: () => setState(() => _expanded = !_expanded),
builder: (context, hovered, focused) => Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: (hovered || focused) ? tokens.listItemHoverBackground : tokens.listItemBackground,
border: Border.all(color: tokens.panelBorder),
borderRadius: BorderRadius.circular(4),
),
child: Row(
children: [
ClideIcon(_expanded ? const ChevronDownIcon() : const ChevronRightIcon(), size: 12, color: tokens.globalTextMuted),
const SizedBox(width: 8),
Expanded(
child: ClideText(
_expanded ? 'Activity' : _summarizeActivity(widget.items.last),
fontSize: clideFontCaption,
fontFamily: clideMonoFamily,
color: tokens.globalTextMuted,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
ClideText(stepLabel, fontSize: clideFontCaption, color: tokens.globalTextMuted),
],
),
),
);
return Semantics(
button: true,
expanded: _expanded,
label: 'Activity, $stepLabel, ${_expanded ? 'expanded' : 'collapsed'}',
excludeSemantics: true,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
header,
if (_expanded)
Padding(
padding: const EdgeInsets.only(left: 12, top: 2),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final item in widget.items)
_ConversationTurn(
item: item,
tokens: tokens,
toolUseOutcomes: widget.toolUseOutcomes,
toolUseById: widget.toolUseById,
resultByToolUseId: widget.resultByToolUseId,
promptsByToolUseId: widget.promptsByToolUseId,
),
],
),
),
],
),
),
final count = items.length;
return ClideHolderCard(
collapsedSummary: _summarizeActivity(items.last),
stepLabel: count == 1 ? '1 step' : '$count steps',
children: [
for (final item in items)
_ConversationTurn(
item: item,
tokens: tokens,
toolUseOutcomes: toolUseOutcomes,
toolUseById: toolUseById,
resultByToolUseId: resultByToolUseId,
promptsByToolUseId: promptsByToolUseId,
),
],
);
}
}
+191
View File
@@ -0,0 +1,191 @@
/// A collapsible container that holds a run of sub-cards as one unit (T-266).
///
/// Collapsed (default): a one-line ticker — the latest step summary + a step
/// count — that toggles to expand. Expanded: a titled, framed container that
/// WRAPS the sub-cards; clicking the holder's own BACKGROUND (its padding, the
/// gaps between sub-cards, its gutter — anywhere a child doesn't cover)
/// collapses it. Taps on a child sub-card interact with that card, never the
/// holder, because each child opaquely consumes its full bounds.
///
/// Why a background toggle (not just a top header): the conversation
/// tail-follows on every write, so a top-anchored collapse control is
/// unreachable while a run streams. A click on whatever background is currently
/// in view collapses the holder, ending that race. An explicit focusable caret
/// keeps the control keyboard/AT reachable (D-78).
///
/// Shared primitive: the activity card (T-230) and the nested sub-agent run
/// (T-264) both render through this, so the container model is settled once.
library;
import 'package:clide/kernel/src/theme/controller.dart';
import 'package:clide/kernel/src/theme/tokens.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
class ClideHolderCard extends StatefulWidget {
const ClideHolderCard({
super.key,
required this.collapsedSummary,
required this.stepLabel,
required this.children,
this.title = 'Activity',
this.initiallyExpanded = false,
});
/// One-line gist of the latest step, shown in the collapsed ticker.
final String collapsedSummary;
/// Count label, e.g. `3 steps` — shown in both the ticker and the header,
/// and announced for AT.
final String stepLabel;
/// The sub-cards, shown wrapped when expanded.
final List<Widget> children;
/// Title shown in the expanded header (default `Activity`).
final String title;
final bool initiallyExpanded;
@override
State<ClideHolderCard> createState() => _ClideHolderCardState();
}
class _ClideHolderCardState extends State<ClideHolderCard> {
late bool _expanded = widget.initiallyExpanded;
final FocusNode _controlFocus = FocusNode(debugLabel: 'holder-control');
@override
void dispose() {
_controlFocus.dispose();
super.dispose();
}
void _toggle() => setState(() => _expanded = !_expanded);
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
return Semantics(
button: true,
expanded: _expanded,
label: '${widget.title}, ${widget.stepLabel}, ${_expanded ? 'expanded' : 'collapsed'}',
excludeSemantics: true,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: _expanded ? _expandedFrame(tokens) : _tickerRow(tokens),
),
);
}
/// Collapsed: the ticker row IS the toggle, focusable for keyboard/AT.
Widget _tickerRow(SurfaceTokens tokens) => ClideTappable(
focusNode: _controlFocus,
onTap: _toggle,
tooltip: 'Expand',
builder: (context, hovered, focused) => Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: (hovered || focused) ? tokens.listItemHoverBackground : tokens.listItemBackground,
border: Border.all(color: tokens.panelBorder),
borderRadius: BorderRadius.circular(4),
),
child: Row(
children: [
ClideIcon(const ChevronRightIcon(), size: 12, color: tokens.globalTextMuted),
const SizedBox(width: 8),
Expanded(
child: ClideText(
widget.collapsedSummary,
fontSize: clideFontCaption,
fontFamily: clideMonoFamily,
color: tokens.globalTextMuted,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
ClideText(widget.stepLabel, fontSize: clideFontCaption, color: tokens.globalTextMuted),
],
),
),
);
/// Expanded: a framed container wrapping the sub-cards. The frame BACKGROUND
/// is a gesture target behind the children that only fires for hits the
/// children don't consume.
Widget _expandedFrame(SurfaceTokens tokens) => DecoratedBox(
decoration: BoxDecoration(
border: Border.all(color: tokens.panelBorder),
borderRadius: BorderRadius.circular(4),
),
child: Stack(
children: [
// Background toggle: behind the children, not a whole-card overlay,
// so child taps are never intercepted. Excluded from focus traversal
// — the header caret is the single keyboard stop.
Positioned.fill(
child: ExcludeFocus(
child: ClideTappable(
onTap: _toggle,
tooltip: 'Collapse',
builder: (_, __, ___) => const SizedBox.expand(),
),
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_headerRow(tokens),
Padding(
padding: const EdgeInsets.fromLTRB(10, 0, 10, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final child in widget.children)
// Each child opaquely consumes its full bounds so a body
// tap interacts with the card (or does nothing), never
// the holder background. Deeper controls (caret/copy)
// still win; only taps are absorbed, so selection drags
// pass through to the SelectionArea.
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {},
child: child,
),
],
),
),
],
),
],
),
);
/// The explicit, focusable collapse control. A background tap is not
/// keyboard/AT reachable on its own, so this keeps the control on the Tab
/// path and Enter/Space-activatable (D-78).
Widget _headerRow(SurfaceTokens tokens) => ClideTappable(
focusNode: _controlFocus,
onTap: _toggle,
tooltip: 'Collapse',
builder: (context, hovered, focused) => Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: (hovered || focused) ? tokens.listItemHoverBackground : tokens.listItemBackground,
border: Border(bottom: BorderSide(color: tokens.panelBorder)),
),
child: Row(
children: [
ClideIcon(const ChevronDownIcon(), size: 12, color: tokens.globalTextMuted),
const SizedBox(width: 8),
Expanded(
child: ClideText(widget.title, fontSize: clideFontCaption, fontFamily: clideMonoFamily, color: tokens.globalTextMuted),
),
const SizedBox(width: 8),
ClideText(widget.stepLabel, fontSize: clideFontCaption, color: tokens.globalTextMuted),
],
),
),
);
}