Files
clide/lib/builtin/claude/src/activity_cluster.dart
T
jpmschweitzerandClaude Opus 4.8 fb29254851 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>
2026-06-03 17:19:10 +02:00

110 lines
3.8 KiB
Dart

/// 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] ?? '');
}
}