group consecutive same-file edits into one collapsed card (T-296)

A run of 2+ consecutive edits to the same file now folds into one ClideHolderCard
labelled '# edits' (coalesceEditRuns, run after groupConversation) instead of a
stack of cards; a different file or an interleaving step splits the run. Every
edit stays reachable on expand.

The holder gained an optional aggregate status. New owned primitives: ClideSpinner
(the logo mark, monochrome, 3D Y-axis rotation, reduced-motion-aware) and
ClideStatusIndicator (running→spinner / success→check / error→cross, with an
AnimatedSwitcher seam for a richer transition later — kept self-contained, not
built on ConversationCard's mark). The activity card shares the same indicator.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-09 18:34:27 +02:00
co-authored by Claude Opus 4.8
parent 6efb3b5d2b
commit 407ed25ab0
10 changed files with 447 additions and 6 deletions
@@ -50,9 +50,58 @@ final class FoldedCluster extends RenderGroup {
final List<ConversationItem> items;
}
/// A run of 2+ consecutive edits to the SAME file, bundled into one collapsed
/// "# edits" card (T-296). Holds the edit tool-use items (their success results
/// fold into each child card as usual). Always length >= 2.
final class EditRun extends RenderGroup {
const EditRun(this.edits, this.filePath);
final List<ConversationItem> edits;
final String filePath;
}
/// Tools whose result is a diff the user wants to keep first-class at L1/L2.
bool isDiffTool(String name) => const {'Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Update'}.contains(name);
/// The file an edit tool-use targets, or null if [it] isn't a same-file edit
/// (used to group consecutive edits, T-296).
String? editFilePath(ConversationItem it) {
if (it is! AssistantToolUse || !isDiffTool(it.name)) return null;
final p = it.input['file_path'] ?? it.input['path'] ?? it.input['notebook_path'];
return p is String && p.isNotEmpty ? p : null;
}
/// Coalesce maximal runs of consecutive same-file edit [StickyItem]s into one
/// [EditRun] (T-296). A different file, or any non-edit group, breaks the run;
/// a lone edit stays a [StickyItem]. Run this after [groupConversation], on its
/// output, so it composes with the existing meta-folding (a folded Read cluster
/// between two edit runs splits them — matching the worked example).
List<RenderGroup> coalesceEditRuns(List<RenderGroup> groups) {
final out = <RenderGroup>[];
var run = <ConversationItem>[];
String? runPath;
void flush() {
if (run.isEmpty) return;
out.add(run.length >= 2 ? EditRun(List.unmodifiable(run), runPath!) : StickyItem(run.single));
run = [];
runPath = null;
}
for (final g in groups) {
final path = g is StickyItem ? editFilePath(g.item) : null;
if (path != null) {
if (runPath != null && path != runPath) flush(); // different file → new run
runPath = path;
run.add((g as StickyItem).item);
} else {
flush();
out.add(g);
}
}
flush();
return out;
}
/// Group [items] into render units per [level]. Pairs tool results to their
/// originating tool-use (by `toolUseId`) so a result can be classified by its
/// tool name (diffs stay first-class at L1/L2).
+75 -2
View File
@@ -270,8 +270,9 @@ class _ConversationViewState extends State<ConversationView> {
};
// Fold runs of meta items into collapsible activity cards (T-230); sticky
// items (user/prose/surfaced errors) render first-class as before.
final groups = groupConversation(items, widget.foldLevel);
// items (user/prose/surfaced errors) render first-class as before. Then
// bundle consecutive same-file edits into one "# edits" card (T-296).
final groups = coalesceEditRuns(groupConversation(items, widget.foldLevel));
final list = ClideScrollbar(
controller: _scroll,
child: ListView.builder(
@@ -304,6 +305,16 @@ class _ConversationViewState extends State<ConversationView> {
promptsByToolUseId: fold.promptsByToolUseId,
runByToolUseId: fold.runByToolUseId,
),
EditRun(:final edits) => _EditRunCard(
key: ValueKey('edits.${edits.first.uuid}'),
edits: edits,
tokens: tokens,
toolUseOutcomes: widget.toolUseOutcomes,
toolUseById: widget.controller.toolUseById,
resultByToolUseId: resultByToolUseId,
promptsByToolUseId: fold.promptsByToolUseId,
runByToolUseId: fold.runByToolUseId,
),
};
},
),
@@ -724,6 +735,7 @@ class _ActivityCard extends StatelessWidget {
return ClideHolderCard(
collapsedSummary: _summarizeActivity(items.last),
stepLabel: count == 1 ? '1 step' : '$count steps',
status: _runStatus(items, resultByToolUseId),
children: [
for (final item in items)
_ConversationTurn(
@@ -741,6 +753,67 @@ class _ActivityCard extends StatelessWidget {
}
}
/// Aggregate live status for a run's header tick (T-296): error if any tool in
/// the run failed, else running while its last tool awaits a result, else
/// success. Null (no tools) shows no indicator.
ClideRunStatus? _runStatus(List<ConversationItem> items, Map<String, ToolResultMessage> results) {
final tools = items.whereType<AssistantToolUse>().toList();
if (tools.isEmpty) return null;
for (final t in tools) {
final r = results[t.toolUseId];
if (r != null && r.isError) return ClideRunStatus.error;
}
return results[tools.last.toolUseId] == null ? ClideRunStatus.running : ClideRunStatus.success;
}
/// A run of consecutive same-file edits, bundled into one collapsible "# edits"
/// card (T-296) through the shared [ClideHolderCard]. Each edit keeps its own
/// merged tool card when expanded; the header carries the aggregate live tick.
class _EditRunCard extends StatelessWidget {
const _EditRunCard({
super.key,
required this.edits,
required this.tokens,
required this.toolUseOutcomes,
required this.toolUseById,
required this.resultByToolUseId,
required this.promptsByToolUseId,
required this.runByToolUseId,
});
final List<ConversationItem> edits;
final SurfaceTokens tokens;
final Map<String, bool> toolUseOutcomes;
final Map<String, AssistantToolUse> toolUseById;
final Map<String, ToolResultMessage> resultByToolUseId;
final Map<String, List<UserMessage>> promptsByToolUseId;
final Map<String, List<ConversationItem>> runByToolUseId;
@override
Widget build(BuildContext context) {
final count = edits.length;
return ClideHolderCard(
title: 'Edits',
collapsedSummary: _summarizeActivity(edits.last),
stepLabel: count == 1 ? '1 edit' : '$count edits',
status: _runStatus(edits, resultByToolUseId),
children: [
for (final item in edits)
_ConversationTurn(
key: ValueKey('edit.${item.uuid}'),
item: item,
tokens: tokens,
toolUseOutcomes: toolUseOutcomes,
toolUseById: toolUseById,
resultByToolUseId: resultByToolUseId,
promptsByToolUseId: promptsByToolUseId,
runByToolUseId: runByToolUseId,
),
],
);
}
}
/// One-line summary of a folded item for the collapsed ticker.
String _summarizeActivity(ConversationItem item) {
switch (item) {
+13
View File
@@ -30,6 +30,7 @@ class ClideHolderCard extends StatefulWidget {
required this.children,
this.title = 'Activity',
this.initiallyExpanded = false,
this.status,
});
/// One-line gist of the latest step, shown in the collapsed ticker.
@@ -47,6 +48,10 @@ class ClideHolderCard extends StatefulWidget {
final bool initiallyExpanded;
/// Optional aggregate run status (spinner / check / cross) shown at the head
/// of the ticker + header — the run's live state (T-296). Null shows nothing.
final ClideRunStatus? status;
@override
State<ClideHolderCard> createState() => _ClideHolderCardState();
}
@@ -108,6 +113,10 @@ class _ClideHolderCardState extends State<ClideHolderCard> {
),
),
const SizedBox(width: 8),
if (widget.status != null) ...[
ClideStatusIndicator(status: widget.status!, size: 12),
const SizedBox(width: 8),
],
ClideText(widget.stepLabel, fontSize: clideFontCaption, color: tokens.globalTextMuted),
],
),
@@ -186,6 +195,10 @@ class _ClideHolderCardState extends State<ClideHolderCard> {
child: ClideText(widget.title, fontSize: clideFontCaption, fontFamily: clideMonoFamily, color: tokens.globalTextMuted),
),
const SizedBox(width: 8),
if (widget.status != null) ...[
ClideStatusIndicator(status: widget.status!, size: 12),
const SizedBox(width: 8),
],
ClideText(widget.stepLabel, fontSize: clideFontCaption, color: tokens.globalTextMuted),
],
),
+95
View File
@@ -0,0 +1,95 @@
/// A compact in-progress spinner: the clide logo mark, monochrome, rotating in
/// 3D about its vertical axis (T-296).
///
/// Reuses `assets/logo/logo.svg` as the single source of truth for the mark
/// (tinted to one colour via a srcIn [ColorFilter]) rather than re-coding the
/// geometry, and spins it with a perspective Y-rotation. Honours
/// reduced-motion: when animations are disabled it shows the static, front-on
/// mark. Animation is one [AnimationController] (no timers) so tests advance it
/// with bounded pumps.
library;
import 'dart:math' as math;
import 'package:clide/kernel/src/theme/controller.dart';
import 'package:clide/widgets/src/clide_svg_view.dart';
import 'package:flutter/widgets.dart';
class ClideSpinner extends StatefulWidget {
const ClideSpinner({
super.key,
this.size = 14,
this.color,
this.period = const Duration(milliseconds: 1500),
this.semanticLabel,
});
final double size;
/// Mark colour; defaults to the theme's foreground (monochrome on the chrome).
final Color? color;
/// Time for one full rotation.
final Duration period;
/// Optional AT label (e.g. 'running'); omit when a parent announces status.
final String? semanticLabel;
@override
State<ClideSpinner> createState() => _ClideSpinnerState();
}
class _ClideSpinnerState extends State<ClideSpinner> with SingleTickerProviderStateMixin {
late final AnimationController _ctrl = AnimationController(vsync: this, duration: widget.period);
bool _reducedMotion = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_reducedMotion = MediaQuery.maybeOf(context)?.disableAnimations ?? false;
_sync();
}
void _sync() {
if (_reducedMotion) {
_ctrl.stop();
_ctrl.value = 0;
} else if (!_ctrl.isAnimating) {
_ctrl.repeat();
}
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final color = widget.color ?? ClideTheme.of(context).surface.globalForeground;
// Tint every stroke of the multi-colour logo to one colour, keeping alpha.
final mark = ColorFiltered(
colorFilter: ColorFilter.mode(color, BlendMode.srcIn),
child: ClideSvgView.asset('assets/logo/logo.svg', width: widget.size, height: widget.size),
);
final child = _reducedMotion
? mark
: AnimatedBuilder(
animation: _ctrl,
child: mark,
builder: (_, child) => Transform(
alignment: Alignment.center,
transform: Matrix4.identity()
..setEntry(3, 2, 0.0015) // perspective
..rotateY(_ctrl.value * 2 * math.pi),
child: child,
),
);
return Semantics(
label: widget.semanticLabel,
excludeSemantics: widget.semanticLabel == null,
child: SizedBox(width: widget.size, height: widget.size, child: child),
);
}
}
@@ -0,0 +1,43 @@
/// A self-contained run-status glyph: spinner while running, check on success,
/// cross on failure (T-296).
///
/// Deliberately NOT built on ConversationCard's success/error mark — it owns its
/// own states and rendering so the spinner→check / spinner→cross transition can
/// grow richer (a morph/cross-fade) without being constrained by that card. A
/// light [AnimatedSwitcher] cross-fade between states is wired now; the keyed
/// children leave the seam for a fuller transition later.
library;
import 'package:clide/kernel/src/theme/controller.dart';
import 'package:clide/widgets/src/clide_icon.dart';
import 'package:clide/widgets/src/clide_spinner.dart';
import 'package:clide/widgets/src/icons/check.dart';
import 'package:clide/widgets/src/icons/x.dart';
import 'package:flutter/widgets.dart';
enum ClideRunStatus { running, success, error }
class ClideStatusIndicator extends StatelessWidget {
const ClideStatusIndicator({super.key, required this.status, this.size = 14});
final ClideRunStatus status;
final double size;
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
final (Widget glyph, String label) = switch (status) {
ClideRunStatus.running => (ClideSpinner(size: size, color: tokens.globalTextMuted, key: const ValueKey('running')), 'running'),
ClideRunStatus.success => (ClideIcon(const CheckIcon(), size: size, color: tokens.statusSuccess, key: const ValueKey('success')), 'succeeded'),
ClideRunStatus.error => (ClideIcon(const CloseIcon(), size: size, color: tokens.statusError, key: const ValueKey('error')), 'failed'),
};
return Semantics(
label: label,
container: true,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: glyph,
),
);
}
}
+2
View File
@@ -16,6 +16,8 @@ export 'src/clide_lightbox.dart';
export 'src/clide_markdown.dart';
export 'src/clide_marquee.dart';
export 'src/clide_menu.dart';
export 'src/clide_spinner.dart';
export 'src/clide_status_indicator.dart';
export 'src/clide_svg_view.dart';
export 'src/clide_toast.dart';
export 'src/clide_icon.dart';