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:
@@ -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] ?? '');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user