fold Claude tool activity into a collapsible card (T-230)

A heavy agent turn buried user/Claude prose under a wall of tool-call/
result rows. A pure grouping pass (activity_cluster.dart) folds runs of
consecutive meta items into clusters; the conversation view renders each
cluster as one collapsible activity card — collapsed by default with a
live one-line ticker of the latest step + a step count, click/Enter to
expand the steps in order. Sticky items (user messages, Claude prose,
and FAILED results) render first-class and seal the cluster.

Fold level is switchable (FoldLevel none/tools/thinking/everything);
default L1 folds tool calls+results while keeping diffs and thinking
first-class. The grouping logic is fully unit-tested; the card is
keyboard + screen-reader accessible. Persisting the level via a user
setting + control is the tracked follow-up T-235.

Closes T-230 (under T-132).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-03 17:19:10 +02:00
co-authored by Claude Opus 4.8
parent b4bbbc8a62
commit fb29254851
7 changed files with 491 additions and 9 deletions
@@ -0,0 +1,109 @@
/// Pure grouping pass for the Claude pane's "activity card" (T-230).
///
/// Folds runs of consecutive "meta" items (tool calls + their results, and —
/// at higher levels — thinking) into one collapsible cluster, so a heavy
/// agent turn doesn't bury the messages that matter (user + Claude prose).
///
/// This file is pure (no Flutter): it turns a flat [ConversationItem] list
/// into a list of [RenderGroup]s — each either a first-class [StickyItem] or
/// a foldable [FoldedCluster]. The widget layer renders sticky items as
/// before and clusters as one [activity card]. Kept separate + unit-tested
/// because the fold rules are the load-bearing part.
library;
import 'package:clide/builtin/claude/src/transcript_reader.dart';
/// How aggressively meta items fold. Default is [tools] (L1).
enum FoldLevel {
/// L0 — never fold; every item renders first-class (the pre-T-230 layout).
none,
/// L1 — fold tool calls + their (non-error, non-diff) results only. Diffs
/// (Edit/Write results) and thinking stay first-class.
tools,
/// L2 — also fold thinking. Diffs still stay first-class.
thinking,
/// L3 — fold everything except user messages and Claude prose (incl. diffs
/// and thinking).
everything,
}
/// A unit the conversation view renders: either a single first-class item or
/// a folded run of meta items.
sealed class RenderGroup {
const RenderGroup();
}
/// A first-class item — rendered exactly as before, and it seals the current
/// cluster (a sticky item breaks the run).
final class StickyItem extends RenderGroup {
const StickyItem(this.item);
final ConversationItem item;
}
/// A folded run of consecutive foldable items, rendered as one activity card.
/// Never empty.
final class FoldedCluster extends RenderGroup {
const FoldedCluster(this.items);
final List<ConversationItem> items;
}
/// 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);
/// 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).
List<RenderGroup> groupConversation(List<ConversationItem> items, FoldLevel level) {
// tool_use_id → tool name, so a ToolResultMessage can be classified.
final toolName = <String, String>{
for (final it in items)
if (it is AssistantToolUse) it.toolUseId: it.name,
};
final out = <RenderGroup>[];
var cluster = <ConversationItem>[];
void flush() {
if (cluster.isNotEmpty) {
out.add(FoldedCluster(List.unmodifiable(cluster)));
cluster = [];
}
}
for (final item in items) {
if (_isFoldable(item, level, toolName)) {
cluster.add(item);
} else {
flush();
out.add(StickyItem(item));
}
}
flush();
return out;
}
bool _isFoldable(ConversationItem item, FoldLevel level, Map<String, String> toolName) {
if (level == FoldLevel.none) return false;
switch (item) {
// User prose and Claude prose are always first-class.
case UserMessage():
case AssistantTextMessage():
return false;
// Thinking folds at L2+, first-class at L1.
case AssistantThinkingMessage():
return level != FoldLevel.tools;
case AssistantToolUse(:final name):
// The Edit/Write call stays first-class with its diff at L1/L2.
if (level == FoldLevel.everything) return true;
return !isDiffTool(name);
case ToolResultMessage(:final isError, :final toolUseId):
// A failed result surfaces — it's first-class and breaks the cluster.
if (isError) return false;
if (level == FoldLevel.everything) return true;
// A diff result (paired with an Edit/Write call) stays first-class.
return !isDiffTool(toolName[toolUseId] ?? '');
}
}
+142 -7
View File
@@ -11,6 +11,7 @@ library;
import 'dart:convert';
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/prompt_card.dart';
@@ -28,10 +29,15 @@ class ConversationView extends StatefulWidget {
this.emptyState,
this.hiddenToolUseIds = const <String>{},
this.toolUseOutcomes = const <String, bool>{},
this.foldLevel = FoldLevel.tools,
});
final ConversationController controller;
/// How aggressively consecutive meta items (tool calls/results, thinking)
/// fold into collapsible activity cards (T-230). Default L1 ([FoldLevel.tools]).
final FoldLevel foldLevel;
/// tool_use_ids that surfaced as a prompt (permission / AskUserQuestion) —
/// D-78. While pending (not in [toolUseOutcomes]) the raw tool-use card is
/// hidden (it shows as a prompt). The result is always kept.
@@ -131,18 +137,32 @@ 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);
final list = ClideScrollbar(
controller: _scroll,
child: ListView.builder(
controller: _scroll,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
itemCount: items.length,
itemBuilder: (context, i) => _ConversationTurn(
item: items[i],
tokens: tokens,
toolUseOutcomes: widget.toolUseOutcomes,
toolUseById: widget.controller.toolUseById,
),
itemCount: groups.length,
itemBuilder: (context, i) {
final g = groups[i];
return switch (g) {
StickyItem(:final item) => _ConversationTurn(
item: item,
tokens: tokens,
toolUseOutcomes: widget.toolUseOutcomes,
toolUseById: widget.controller.toolUseById,
),
FoldedCluster(:final items) => _ActivityCard(
items: items,
tokens: tokens,
toolUseOutcomes: widget.toolUseOutcomes,
toolUseById: widget.controller.toolUseById,
),
};
},
),
);
return ColoredBox(
@@ -319,3 +339,118 @@ class _ConversationTurn extends StatelessWidget {
return line.length > 80 ? '${line.substring(0, 80)}' : line;
}
}
/// 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 {
const _ActivityCard({
required this.items,
required this.tokens,
required this.toolUseOutcomes,
required this.toolUseById,
});
final List<ConversationItem> items;
final SurfaceTokens tokens;
final Map<String, bool> toolUseOutcomes;
final Map<String, AssistantToolUse> toolUseById;
@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,
),
],
),
),
],
),
),
);
}
}
/// One-line summary of a folded item for the collapsed ticker.
String _summarizeActivity(ConversationItem item) {
switch (item) {
case AssistantToolUse(:final name, :final input):
final raw = input['command'] ?? input['file_path'] ?? input['path'] ?? input['pattern'] ?? input['url'];
final detail = raw is String ? raw.split('\n').first.trim() : '';
final clipped = detail.length > 72 ? '${detail.substring(0, 72)}' : detail;
return clipped.isEmpty ? name : '$name $clipped';
case ToolResultMessage(:final isError):
return isError ? '↳ result · error' : '↳ result';
case AssistantThinkingMessage():
return 'thinking…';
case UserMessage(:final text):
return text;
case AssistantTextMessage(:final text):
return text;
}
}