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:
@@ -18,6 +18,13 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- **Consecutive edits to one file fold into a single card.** A run of edits to
|
||||||
|
the same file now collapses to one `# edits` holder (the latest edit as the
|
||||||
|
ticker; every edit reachable on expand) instead of a stack of cards — a
|
||||||
|
different file or an interleaving step splits the run. The card carries an
|
||||||
|
aggregate live status: a new logo-mark **`ClideSpinner`** while editing,
|
||||||
|
settling to a check (or a cross on failure); the activity card shares it.
|
||||||
|
(T-296)
|
||||||
- **Collapse toggles in the status bar.** A small caret-line button bookends
|
- **Collapse toggles in the status bar.** A small caret-line button bookends
|
||||||
each end of the bottom status bar — left collapses/expands the sidebar, right
|
each end of the bottom status bar — left collapses/expands the sidebar, right
|
||||||
the context pane. The chevron points inward to collapse, outward to expand,
|
the context pane. The chevron points inward to collapse, outward to expand,
|
||||||
|
|||||||
@@ -50,9 +50,58 @@ final class FoldedCluster extends RenderGroup {
|
|||||||
final List<ConversationItem> items;
|
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.
|
/// 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);
|
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
|
/// 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
|
/// originating tool-use (by `toolUseId`) so a result can be classified by its
|
||||||
/// tool name (diffs stay first-class at L1/L2).
|
/// tool name (diffs stay first-class at L1/L2).
|
||||||
|
|||||||
@@ -270,8 +270,9 @@ class _ConversationViewState extends State<ConversationView> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Fold runs of meta items into collapsible activity cards (T-230); sticky
|
// Fold runs of meta items into collapsible activity cards (T-230); sticky
|
||||||
// items (user/prose/surfaced errors) render first-class as before.
|
// items (user/prose/surfaced errors) render first-class as before. Then
|
||||||
final groups = groupConversation(items, widget.foldLevel);
|
// bundle consecutive same-file edits into one "# edits" card (T-296).
|
||||||
|
final groups = coalesceEditRuns(groupConversation(items, widget.foldLevel));
|
||||||
final list = ClideScrollbar(
|
final list = ClideScrollbar(
|
||||||
controller: _scroll,
|
controller: _scroll,
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
@@ -304,6 +305,16 @@ class _ConversationViewState extends State<ConversationView> {
|
|||||||
promptsByToolUseId: fold.promptsByToolUseId,
|
promptsByToolUseId: fold.promptsByToolUseId,
|
||||||
runByToolUseId: fold.runByToolUseId,
|
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(
|
return ClideHolderCard(
|
||||||
collapsedSummary: _summarizeActivity(items.last),
|
collapsedSummary: _summarizeActivity(items.last),
|
||||||
stepLabel: count == 1 ? '1 step' : '$count steps',
|
stepLabel: count == 1 ? '1 step' : '$count steps',
|
||||||
|
status: _runStatus(items, resultByToolUseId),
|
||||||
children: [
|
children: [
|
||||||
for (final item in items)
|
for (final item in items)
|
||||||
_ConversationTurn(
|
_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.
|
/// One-line summary of a folded item for the collapsed ticker.
|
||||||
String _summarizeActivity(ConversationItem item) {
|
String _summarizeActivity(ConversationItem item) {
|
||||||
switch (item) {
|
switch (item) {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ class ClideHolderCard extends StatefulWidget {
|
|||||||
required this.children,
|
required this.children,
|
||||||
this.title = 'Activity',
|
this.title = 'Activity',
|
||||||
this.initiallyExpanded = false,
|
this.initiallyExpanded = false,
|
||||||
|
this.status,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// One-line gist of the latest step, shown in the collapsed ticker.
|
/// One-line gist of the latest step, shown in the collapsed ticker.
|
||||||
@@ -47,6 +48,10 @@ class ClideHolderCard extends StatefulWidget {
|
|||||||
|
|
||||||
final bool initiallyExpanded;
|
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
|
@override
|
||||||
State<ClideHolderCard> createState() => _ClideHolderCardState();
|
State<ClideHolderCard> createState() => _ClideHolderCardState();
|
||||||
}
|
}
|
||||||
@@ -108,6 +113,10 @@ class _ClideHolderCardState extends State<ClideHolderCard> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
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),
|
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),
|
child: ClideText(widget.title, fontSize: clideFontCaption, fontFamily: clideMonoFamily, color: tokens.globalTextMuted),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
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),
|
ClideText(widget.stepLabel, fontSize: clideFontCaption, color: tokens.globalTextMuted),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,8 @@ export 'src/clide_lightbox.dart';
|
|||||||
export 'src/clide_markdown.dart';
|
export 'src/clide_markdown.dart';
|
||||||
export 'src/clide_marquee.dart';
|
export 'src/clide_marquee.dart';
|
||||||
export 'src/clide_menu.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_svg_view.dart';
|
||||||
export 'src/clide_toast.dart';
|
export 'src/clide_toast.dart';
|
||||||
export 'src/clide_icon.dart';
|
export 'src/clide_icon.dart';
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ AssistantTextMessage _prose([String t = 'sure']) => AssistantTextMessage(uuid: '
|
|||||||
AssistantThinkingMessage _think() => AssistantThinkingMessage(uuid: 't${_n++}', timestamp: _ts, isSidechain: false, thinking: '…');
|
AssistantThinkingMessage _think() => AssistantThinkingMessage(uuid: 't${_n++}', timestamp: _ts, isSidechain: false, thinking: '…');
|
||||||
AssistantToolUse _tool(String id, String name) =>
|
AssistantToolUse _tool(String id, String name) =>
|
||||||
AssistantToolUse(uuid: 'tu${_n++}', timestamp: _ts, isSidechain: false, toolUseId: id, name: name, input: const {});
|
AssistantToolUse(uuid: 'tu${_n++}', timestamp: _ts, isSidechain: false, toolUseId: id, name: name, input: const {});
|
||||||
|
AssistantToolUse _edit(String id, String path, {String name = 'Edit'}) =>
|
||||||
|
AssistantToolUse(uuid: 'tu${_n++}', timestamp: _ts, isSidechain: false, toolUseId: id, name: name, input: {'file_path': path});
|
||||||
ToolResultMessage _result(String id, {bool isError = false}) =>
|
ToolResultMessage _result(String id, {bool isError = false}) =>
|
||||||
ToolResultMessage(uuid: 'r${_n++}', timestamp: _ts, isSidechain: false, toolUseId: id, content: '', isError: isError);
|
ToolResultMessage(uuid: 'r${_n++}', timestamp: _ts, isSidechain: false, toolUseId: id, content: '', isError: isError);
|
||||||
|
|
||||||
@@ -87,4 +89,57 @@ void main() {
|
|||||||
expect((groups[1] as StickyItem).item, isA<ImageMessage>());
|
expect((groups[1] as StickyItem).item, isA<ImageMessage>());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('editFilePath', () {
|
||||||
|
test('reads the file of an edit tool-use; null otherwise', () {
|
||||||
|
expect(editFilePath(_edit('1', '/a/b.dart')), '/a/b.dart');
|
||||||
|
expect(editFilePath(_edit('1', '/a/b.dart', name: 'Write')), '/a/b.dart');
|
||||||
|
expect(editFilePath(_tool('1', 'Bash')), isNull); // not a diff tool
|
||||||
|
expect(editFilePath(_tool('1', 'Edit')), isNull); // diff tool, but no file_path
|
||||||
|
expect(editFilePath(_prose()), isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('coalesceEditRuns (T-296)', () {
|
||||||
|
test('consecutive same-file edits bundle into one EditRun', () {
|
||||||
|
final out = coalesceEditRuns([StickyItem(_edit('1', '/a')), StickyItem(_edit('2', '/a')), StickyItem(_edit('3', '/a'))]);
|
||||||
|
expect(out, hasLength(1));
|
||||||
|
expect(out.single, isA<EditRun>());
|
||||||
|
final run = out.single as EditRun;
|
||||||
|
expect(run.edits, hasLength(3));
|
||||||
|
expect(run.filePath, '/a');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a lone edit stays a StickyItem (not a one-item run)', () {
|
||||||
|
final out = coalesceEditRuns([StickyItem(_edit('1', '/a')), StickyItem(_prose())]);
|
||||||
|
expect(out.first, isA<StickyItem>());
|
||||||
|
expect((out.first as StickyItem).item, isA<AssistantToolUse>());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a different file starts a new run', () {
|
||||||
|
final out = coalesceEditRuns([StickyItem(_edit('1', '/a')), StickyItem(_edit('2', '/a')), StickyItem(_edit('3', '/b')), StickyItem(_edit('4', '/b'))]);
|
||||||
|
expect(out.map((g) => g.runtimeType.toString()), ['EditRun', 'EditRun']);
|
||||||
|
expect((out[0] as EditRun).filePath, '/a');
|
||||||
|
expect((out[1] as EditRun).filePath, '/b');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an interleaving non-edit group splits the run (the worked example)', () {
|
||||||
|
// 3 edits to /a, a folded Read cluster, then 7 edits to /a → [3 edits][cluster][7 edits].
|
||||||
|
final groups = <RenderGroup>[
|
||||||
|
for (var i = 0; i < 3; i++) StickyItem(_edit('a$i', '/a')),
|
||||||
|
FoldedCluster([_tool('r', 'Read'), _result('r')]),
|
||||||
|
for (var i = 0; i < 7; i++) StickyItem(_edit('b$i', '/a')),
|
||||||
|
];
|
||||||
|
final out = coalesceEditRuns(groups);
|
||||||
|
expect(out.map((g) => g.runtimeType.toString()), ['EditRun', 'FoldedCluster', 'EditRun']);
|
||||||
|
expect((out[0] as EditRun).edits, hasLength(3));
|
||||||
|
expect((out[2] as EditRun).edits, hasLength(7));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a sticky non-edit between edits breaks the run', () {
|
||||||
|
final out = coalesceEditRuns([StickyItem(_edit('1', '/a')), StickyItem(_prose()), StickyItem(_edit('2', '/a'))]);
|
||||||
|
// edit (lone) → sticky, prose → sticky, edit (lone) → sticky.
|
||||||
|
expect(out.map((g) => g.runtimeType.toString()), ['StickyItem', 'StickyItem', 'StickyItem']);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import 'package:clide/builtin/claude/src/transcript_reader.dart';
|
|||||||
import 'package:clide/kernel/src/events/message_bus.dart';
|
import 'package:clide/kernel/src/events/message_bus.dart';
|
||||||
import 'package:clide/widgets/widgets.dart';
|
import 'package:clide/widgets/widgets.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter/widgets.dart' show Image, FileImage, ValueKey;
|
import 'package:flutter/widgets.dart' show Builder, Image, FileImage, MediaQuery, ValueKey;
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
import '../../helpers/kernel_fixture.dart';
|
import '../../helpers/kernel_fixture.dart';
|
||||||
@@ -159,8 +159,17 @@ void main() {
|
|||||||
final stream = StreamController<ConversationItem>.broadcast();
|
final stream = StreamController<ConversationItem>.broadcast();
|
||||||
final c = ConversationController(stream: stream.stream);
|
final c = ConversationController(stream: stream.stream);
|
||||||
addTearDown(c.dispose);
|
addTearDown(c.dispose);
|
||||||
await tester
|
// Disable animations so an in-flight run's ClideSpinner (a perpetual
|
||||||
.pumpWidget(harness(f, ConversationView(controller: c, hiddenToolUseIds: hiddenToolUseIds, toolUseOutcomes: toolUseOutcomes, foldLevel: foldLevel)));
|
// animation) renders static and pumpAndSettle can settle (T-296).
|
||||||
|
await tester.pumpWidget(harness(
|
||||||
|
f,
|
||||||
|
Builder(
|
||||||
|
builder: (ctx) => MediaQuery(
|
||||||
|
data: MediaQuery.of(ctx).copyWith(disableAnimations: true),
|
||||||
|
child: ConversationView(controller: c, hiddenToolUseIds: hiddenToolUseIds, toolUseOutcomes: toolUseOutcomes, foldLevel: foldLevel),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
));
|
||||||
for (final it in items) {
|
for (final it in items) {
|
||||||
stream.add(it);
|
stream.add(it);
|
||||||
}
|
}
|
||||||
@@ -199,7 +208,15 @@ void main() {
|
|||||||
final stream = StreamController<ConversationItem>.broadcast();
|
final stream = StreamController<ConversationItem>.broadcast();
|
||||||
final c = ConversationController(stream: stream.stream);
|
final c = ConversationController(stream: stream.stream);
|
||||||
addTearDown(c.dispose);
|
addTearDown(c.dispose);
|
||||||
await tester.pumpWidget(harness(f, ConversationView(controller: c, foldLevel: FoldLevel.tools)));
|
await tester.pumpWidget(harness(
|
||||||
|
f,
|
||||||
|
Builder(
|
||||||
|
builder: (ctx) => MediaQuery(
|
||||||
|
data: MediaQuery.of(ctx).copyWith(disableAnimations: true),
|
||||||
|
child: ConversationView(controller: c, foldLevel: FoldLevel.tools),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
));
|
||||||
stream.add(AssistantToolUse(uuid: 'A', timestamp: _t, isSidechain: false, toolUseId: 'A', name: 'Bash', input: const {'command': 'echo a'}));
|
stream.add(AssistantToolUse(uuid: 'A', timestamp: _t, isSidechain: false, toolUseId: 'A', name: 'Bash', input: const {'command': 'echo a'}));
|
||||||
stream.add(AssistantToolUse(uuid: 'B', timestamp: _t, isSidechain: false, toolUseId: 'B', name: 'Read', input: const {'file_path': '/a'}));
|
stream.add(AssistantToolUse(uuid: 'B', timestamp: _t, isSidechain: false, toolUseId: 'B', name: 'Read', input: const {'file_path': '/a'}));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
@@ -247,6 +264,32 @@ void main() {
|
|||||||
expect(find.byType(ImageThumbnail), findsNothing);
|
expect(find.byType(ImageThumbnail), findsNothing);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('consecutive same-file edits collapse into one "# edits" card (T-296)', (tester) async {
|
||||||
|
AssistantToolUse edit(String id, String path) =>
|
||||||
|
AssistantToolUse(uuid: id, timestamp: _t, isSidechain: false, toolUseId: id, name: 'Edit', input: {'file_path': path});
|
||||||
|
ToolResultMessage ok(String id) => ToolResultMessage(uuid: 'r$id', timestamp: _t, isSidechain: false, toolUseId: id, content: 'done', isError: false);
|
||||||
|
await pumpWith(
|
||||||
|
tester,
|
||||||
|
[
|
||||||
|
edit('e1', '/lib/x.dart'),
|
||||||
|
ok('e1'),
|
||||||
|
edit('e2', '/lib/x.dart'),
|
||||||
|
ok('e2'),
|
||||||
|
],
|
||||||
|
foldLevel: FoldLevel.tools);
|
||||||
|
// One bundled card labelled "2 edits" with an aggregate status indicator.
|
||||||
|
expect(find.text('2 edits'), findsOneWidget);
|
||||||
|
expect(find.byType(ClideStatusIndicator), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('an edit to a different file is not bundled with the first (T-296)', (tester) async {
|
||||||
|
AssistantToolUse edit(String id, String path) =>
|
||||||
|
AssistantToolUse(uuid: id, timestamp: _t, isSidechain: false, toolUseId: id, name: 'Edit', input: {'file_path': path});
|
||||||
|
await pumpWith(tester, [edit('e1', '/a.dart'), edit('e2', '/b.dart')], foldLevel: FoldLevel.tools);
|
||||||
|
// Two lone edits, different files → no "edits" bundle.
|
||||||
|
expect(find.textContaining('edits'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('meta items fold into a collapsed activity card; tap expands (T-230)', (tester) async {
|
testWidgets('meta items fold into a collapsed activity card; tap expands (T-230)', (tester) async {
|
||||||
await pumpWith(
|
await pumpWith(
|
||||||
tester,
|
tester,
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
/// Tests for ClideSpinner + ClideStatusIndicator (T-296): the run-status glyph
|
||||||
|
/// (spinner / check / cross) and its reduced-motion behaviour.
|
||||||
|
library;
|
||||||
|
|
||||||
|
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());
|
||||||
|
|
||||||
|
// Disable animations so the spinner is static (no perpetual rotation) and
|
||||||
|
// pumpAndSettle can settle — mirroring how every animated widget behaves
|
||||||
|
// under reduced motion.
|
||||||
|
Future<void> pump(WidgetTester tester, Widget child) => tester.pumpWidget(harness(
|
||||||
|
f,
|
||||||
|
Builder(
|
||||||
|
builder: (ctx) => MediaQuery(
|
||||||
|
data: MediaQuery.of(ctx).copyWith(disableAnimations: true),
|
||||||
|
child: Center(child: child),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
|
||||||
|
Finder iconWith(Object painterType) => find.byWidgetPredicate((w) => w is ClideIcon && w.painter.runtimeType == painterType);
|
||||||
|
|
||||||
|
group('ClideSpinner', () {
|
||||||
|
testWidgets('renders the logo mark and settles under reduced motion', (tester) async {
|
||||||
|
await pump(tester, const ClideSpinner(size: 16));
|
||||||
|
await tester.pumpAndSettle(); // would hang if it kept rotating
|
||||||
|
expect(find.byType(ClideSvgView), findsOneWidget);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('ClideStatusIndicator', () {
|
||||||
|
testWidgets('running shows the spinner', (tester) async {
|
||||||
|
await pump(tester, const ClideStatusIndicator(status: ClideRunStatus.running));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byType(ClideSpinner), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('success shows a check', (tester) async {
|
||||||
|
await pump(tester, const ClideStatusIndicator(status: ClideRunStatus.success));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(iconWith(CheckIcon), findsOneWidget);
|
||||||
|
expect(find.byType(ClideSpinner), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('error shows a cross', (tester) async {
|
||||||
|
await pump(tester, const ClideStatusIndicator(status: ClideRunStatus.error));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(iconWith(CloseIcon), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user