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
+58
View File
@@ -118,3 +118,61 @@ INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by,
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-230', 'status', 'ready', 'in_progress', NULL, '2026-06-03 14:27:26', '2026-06-03 14:27:26', '2026-06-03 14:27:26', NULL, 'ec0e54053e51dea1fecb48eebc69e387', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-82', 'status', 'in_progress', 'done', NULL, '2026-06-03 14:56:07', '2026-06-03 14:56:07', '2026-06-03 14:56:07', NULL, '9b160e380defb6a1ac78f200585745c8', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-234', 'status', 'in_progress', 'done', NULL, '2026-06-03 15:04:07', '2026-06-03 15:04:07', '2026-06-03 15:04:07', NULL, 'a498a47a4c52685fb7a33cbf004b47f9', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-230', 'description', 'The Claude pane renders every transcript item as its own row, so a heavy agent turn becomes a wall of tool-call/result rows (Bash <cmd> / Bash · result {…} / ''completed with no output'') that buries the messages that matter (user + Claude prose). Wireframe: docs/design/wireframes/claude/meta-activity-card.png.
Fold runs of consecutive ''meta'' items into one live, collapsible activity card:
- The card shows the MOST RECENT meta line as a live ticker and updates in place as new ones stream in — a running ''what''s happening now''. A step count (''14 steps'') sits on the right.
- Collapsed by default; click the chevron to expand the full list of folded steps, click to re-collapse.
- ''Sticky'' items never fold — they render first-class. A sticky item SEALS the current card and a new cluster begins after it. A cluster = one unbroken run of foldable items between two sticky items.
Taxonomy over the sealed ConversationItem set (transcript_reader.dart): UserMessage, AssistantTextMessage, AssistantThinkingMessage, AssistantToolUse, ToolResultMessage.
- Always sticky: UserMessage, AssistantTextMessage. Permission/AskUserQuestion prompts already live in the interaction zone (D-78) and are inherently sticky / cluster-breaking — no change, but note they break the run too.
- Foldable: AssistantToolUse + its paired ToolResultMessage (pairing already exists via toolUseById, T-168).
Refinement decisions (2026-06-03, user):
1. WHAT FOLDS is a 3-level setting (conservative → aggressive); DEFAULT = level 1. Build the foldable predicate so all three are switchable:
- L1 (default): fold tool calls + results only; diffs (Edit/Write results) AND AssistantThinkingMessage stay first-class/sticky.
- L2: also fold thinking; diffs stay sticky.
- L3: fold everything except UserMessage + AssistantTextMessage (incl. diffs + thinking).
2. ERRORS: a failed/error ToolResultMessage SURFACES — treated as sticky, stays visible and breaks the cluster (see wireframe state B). Start here; the user noted some non-fatal errors are themselves clutter, so expect to tune which errors surface vs. fold.
3. DEFAULT/RUN STATE: always collapsed, showing the last line + step count, with the ticker updating live. NO setting. (Rejected auto-expanding the in-flight cluster: it scrolls the last useful sticky message out of view during long runs and only collapses when done — worst timing. Collapsed-with-live-ticker keeps the last sticky message anchored while preserving the motion/dynamism.)
Implementation home: a ''collapse adjacent foldable items'' grouping pass over ConversationController.items, consumed by ConversationView; the card is a new conversation_card variant. Keep it accessible (keyboard expand/collapse + semantics: announce step count and collapsed/expanded state).
Acceptance:
1. Consecutive foldable items collapse into one card; the collapsed card shows the latest line + a live-updating step count.
2. A sticky message (user / Claude prose / surfaced error / interaction-zone prompt) seals the card and starts a new cluster after it.
3. Expanding shows every folded step (tool call + result pairs) in order; collapsing returns to the one-line view.
4. The foldable level is a setting (default L1: diffs + thinking stay first-class); switching levels re-groups the transcript.
5. Failed tool results surface as their own (sticky) row and break the cluster.
6. Keyboard + screen-reader accessible (expand/collapse, step count announced).', 'The Claude pane renders every transcript item as its own row, so a heavy agent turn becomes a wall of tool-call/result rows (Bash <cmd> / Bash · result {…} / ''completed with no output'') that buries the messages that matter (user + Claude prose). Wireframe: docs/design/wireframes/claude/meta-activity-card.png.
Fold runs of consecutive ''meta'' items into one live, collapsible activity card:
- The card shows the MOST RECENT meta line as a live ticker and updates in place as new ones stream in — a running ''what''s happening now''. A step count (''14 steps'') sits on the right.
- Collapsed by default; click the chevron to expand the full list of folded steps, click to re-collapse.
- ''Sticky'' items never fold — they render first-class. A sticky item SEALS the current card and a new cluster begins after it. A cluster = one unbroken run of foldable items between two sticky items.
Taxonomy over the sealed ConversationItem set (transcript_reader.dart): UserMessage, AssistantTextMessage, AssistantThinkingMessage, AssistantToolUse, ToolResultMessage.
- Always sticky: UserMessage, AssistantTextMessage. Permission/AskUserQuestion prompts already live in the interaction zone (D-78) and are inherently sticky / cluster-breaking — no change, but note they break the run too.
- Foldable: AssistantToolUse + its paired ToolResultMessage (pairing already exists via toolUseById, T-168).
Refinement decisions (2026-06-03, user):
1. WHAT FOLDS is a 3-level setting (conservative → aggressive); DEFAULT = level 1. Build the foldable predicate so all three are switchable:
- L1 (default): fold tool calls + results only; diffs (Edit/Write results) AND AssistantThinkingMessage stay first-class/sticky.
- L2: also fold thinking; diffs stay sticky.
- L3: fold everything except UserMessage + AssistantTextMessage (incl. diffs + thinking).
2. ERRORS: a failed/error ToolResultMessage SURFACES — treated as sticky, stays visible and breaks the cluster (see wireframe state B). Start here; the user noted some non-fatal errors are themselves clutter, so expect to tune which errors surface vs. fold.
3. DEFAULT/RUN STATE: always collapsed, showing the last line + step count, with the ticker updating live. NO setting. (Rejected auto-expanding the in-flight cluster: it scrolls the last useful sticky message out of view during long runs and only collapses when done — worst timing. Collapsed-with-live-ticker keeps the last sticky message anchored while preserving the motion/dynamism.)
Implementation home: a ''collapse adjacent foldable items'' grouping pass over ConversationController.items, consumed by ConversationView; the card is a new conversation_card variant. Keep it accessible (keyboard expand/collapse + semantics: announce step count and collapsed/expanded state).
Acceptance:
1. Consecutive foldable items collapse into one card; the collapsed card shows the latest line + a live-updating step count.
2. A sticky message (user / Claude prose / surfaced error / interaction-zone prompt) seals the card and starts a new cluster after it.
3. Expanding shows every folded step (tool call + result pairs) in order; collapsing returns to the one-line view.
4. The foldable level is a setting (default L1: diffs + thinking stay first-class); switching levels re-groups the transcript.
5. Failed tool results surface as their own (sticky) row and break the cluster.
6. Keyboard + screen-reader accessible (expand/collapse, step count announced).
Implemented (2026-06-03): pure grouping in activity_cluster.dart (groupConversation + FoldLevel none/tools/thinking/everything + RenderGroup StickyItem/FoldedCluster), fully unit-tested. Activity card + grouping wired into conversation_view.dart (collapsed live ticker of the latest step + step count; click/Enter expands to the folded steps in order; Semantics announces count + expanded/collapsed). Default level = L1 (tools): tool calls+results fold; user/Claude prose, FAILED results (sticky, surfaced), diffs (Edit/Write), and thinking stay first-class. Added FoldLevel.none (no folding) used by the existing item-level renderer tests. DEFERRED (acceptance 4''s persistence): the fold level is a switchable ConversationView parameter (proven by tests; none/L1/L2/L3 re-group) but is NOT yet wired to a persisted user setting + a UI control — the live pane uses the L1 default. Follow-up: read it from settings (ctx.settings) + a control to change it. Did NOT match the wireframe pixel-for-pixel; functional shape per the refinement decisions.', NULL, '2026-06-03 15:17:10', '2026-06-03 15:17:10', '2026-06-03 15:17:10', NULL, 'be73ea1e861930fc12fd9d6050cc6841', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-230', 'status', 'in_progress', 'done', NULL, '2026-06-03 15:17:33', '2026-06-03 15:17:33', '2026-06-03 15:17:33', NULL, '069a613ac7439a5d19da505712da8341', 1) ON CONFLICT(hash) DO NOTHING;
+61
View File
@@ -385,3 +385,64 @@ Acceptance:
3. Selecting a theme applies it live (ThemeController.select) and closes the popout.
4. Esc / outside-click dismisses without changing the theme; the control is keyboard-operable.
5. theme.pick (palette/command) still works unchanged.', 'done', 'medium', NULL, NULL, NULL, '2026-06-03 13:49:44', '2026-06-03 15:04:07', NULL, '88cfe20315c172dfdb9ba3ed1c25564d', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-230', 'story', 'T-132', 'Cluster meta messages into a collapsible activity card', 'The Claude pane renders every transcript item as its own row, so a heavy agent turn becomes a wall of tool-call/result rows (Bash <cmd> / Bash · result {…} / ''completed with no output'') that buries the messages that matter (user + Claude prose). Wireframe: docs/design/wireframes/claude/meta-activity-card.png.
Fold runs of consecutive ''meta'' items into one live, collapsible activity card:
- The card shows the MOST RECENT meta line as a live ticker and updates in place as new ones stream in — a running ''what''s happening now''. A step count (''14 steps'') sits on the right.
- Collapsed by default; click the chevron to expand the full list of folded steps, click to re-collapse.
- ''Sticky'' items never fold — they render first-class. A sticky item SEALS the current card and a new cluster begins after it. A cluster = one unbroken run of foldable items between two sticky items.
Taxonomy over the sealed ConversationItem set (transcript_reader.dart): UserMessage, AssistantTextMessage, AssistantThinkingMessage, AssistantToolUse, ToolResultMessage.
- Always sticky: UserMessage, AssistantTextMessage. Permission/AskUserQuestion prompts already live in the interaction zone (D-78) and are inherently sticky / cluster-breaking — no change, but note they break the run too.
- Foldable: AssistantToolUse + its paired ToolResultMessage (pairing already exists via toolUseById, T-168).
Refinement decisions (2026-06-03, user):
1. WHAT FOLDS is a 3-level setting (conservative → aggressive); DEFAULT = level 1. Build the foldable predicate so all three are switchable:
- L1 (default): fold tool calls + results only; diffs (Edit/Write results) AND AssistantThinkingMessage stay first-class/sticky.
- L2: also fold thinking; diffs stay sticky.
- L3: fold everything except UserMessage + AssistantTextMessage (incl. diffs + thinking).
2. ERRORS: a failed/error ToolResultMessage SURFACES — treated as sticky, stays visible and breaks the cluster (see wireframe state B). Start here; the user noted some non-fatal errors are themselves clutter, so expect to tune which errors surface vs. fold.
3. DEFAULT/RUN STATE: always collapsed, showing the last line + step count, with the ticker updating live. NO setting. (Rejected auto-expanding the in-flight cluster: it scrolls the last useful sticky message out of view during long runs and only collapses when done — worst timing. Collapsed-with-live-ticker keeps the last sticky message anchored while preserving the motion/dynamism.)
Implementation home: a ''collapse adjacent foldable items'' grouping pass over ConversationController.items, consumed by ConversationView; the card is a new conversation_card variant. Keep it accessible (keyboard expand/collapse + semantics: announce step count and collapsed/expanded state).
Acceptance:
1. Consecutive foldable items collapse into one card; the collapsed card shows the latest line + a live-updating step count.
2. A sticky message (user / Claude prose / surfaced error / interaction-zone prompt) seals the card and starts a new cluster after it.
3. Expanding shows every folded step (tool call + result pairs) in order; collapsing returns to the one-line view.
4. The foldable level is a setting (default L1: diffs + thinking stay first-class); switching levels re-groups the transcript.
5. Failed tool results surface as their own (sticky) row and break the cluster.
6. Keyboard + screen-reader accessible (expand/collapse, step count announced).
Implemented (2026-06-03): pure grouping in activity_cluster.dart (groupConversation + FoldLevel none/tools/thinking/everything + RenderGroup StickyItem/FoldedCluster), fully unit-tested. Activity card + grouping wired into conversation_view.dart (collapsed live ticker of the latest step + step count; click/Enter expands to the folded steps in order; Semantics announces count + expanded/collapsed). Default level = L1 (tools): tool calls+results fold; user/Claude prose, FAILED results (sticky, surfaced), diffs (Edit/Write), and thinking stay first-class. Added FoldLevel.none (no folding) used by the existing item-level renderer tests. DEFERRED (acceptance 4''s persistence): the fold level is a switchable ConversationView parameter (proven by tests; none/L1/L2/L3 re-group) but is NOT yet wired to a persisted user setting + a UI control — the live pane uses the L1 default. Follow-up: read it from settings (ctx.settings) + a control to change it. Did NOT match the wireframe pixel-for-pixel; functional shape per the refinement decisions.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-03 11:15:39', '2026-06-03 15:17:10', NULL, '448154a6e3692d943eaa3b03bd914693', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-235', 'task', 'T-132', 'Persist + expose the activity-card fold level (T-230 follow-up)', 'Follow-up to T-230. The activity-card fold level is implemented as a switchable ConversationView.foldLevel parameter (FoldLevel none/tools/thinking/everything; groupConversation re-groups per level, unit-tested), but the live Claude pane currently uses the L1 default with no way for the user to change it. Finish acceptance #4: (1) read the fold level from a persisted user setting (via ctx.settings / the kernel settings service — see how other builtins read settings), defaulting to L1; (2) add a control to change it (e.g. a command ''claude.activity.fold-level'' cycling none->L1->L2->L3, and/or an entry in the Claude/settings UI); (3) thread it into the ConversationView built in claude_pane.dart (and team_panel_host.dart). The grouping + card + a11y are already done in activity_cluster.dart + conversation_view.dart.', 'backlog', 'low', NULL, NULL, NULL, '2026-06-03 15:17:29', '2026-06-03 15:17:29', NULL, '8154cda9c468dd17716f381f4e10e0c1', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-230', 'story', 'T-132', 'Cluster meta messages into a collapsible activity card', 'The Claude pane renders every transcript item as its own row, so a heavy agent turn becomes a wall of tool-call/result rows (Bash <cmd> / Bash · result {…} / ''completed with no output'') that buries the messages that matter (user + Claude prose). Wireframe: docs/design/wireframes/claude/meta-activity-card.png.
Fold runs of consecutive ''meta'' items into one live, collapsible activity card:
- The card shows the MOST RECENT meta line as a live ticker and updates in place as new ones stream in — a running ''what''s happening now''. A step count (''14 steps'') sits on the right.
- Collapsed by default; click the chevron to expand the full list of folded steps, click to re-collapse.
- ''Sticky'' items never fold — they render first-class. A sticky item SEALS the current card and a new cluster begins after it. A cluster = one unbroken run of foldable items between two sticky items.
Taxonomy over the sealed ConversationItem set (transcript_reader.dart): UserMessage, AssistantTextMessage, AssistantThinkingMessage, AssistantToolUse, ToolResultMessage.
- Always sticky: UserMessage, AssistantTextMessage. Permission/AskUserQuestion prompts already live in the interaction zone (D-78) and are inherently sticky / cluster-breaking — no change, but note they break the run too.
- Foldable: AssistantToolUse + its paired ToolResultMessage (pairing already exists via toolUseById, T-168).
Refinement decisions (2026-06-03, user):
1. WHAT FOLDS is a 3-level setting (conservative → aggressive); DEFAULT = level 1. Build the foldable predicate so all three are switchable:
- L1 (default): fold tool calls + results only; diffs (Edit/Write results) AND AssistantThinkingMessage stay first-class/sticky.
- L2: also fold thinking; diffs stay sticky.
- L3: fold everything except UserMessage + AssistantTextMessage (incl. diffs + thinking).
2. ERRORS: a failed/error ToolResultMessage SURFACES — treated as sticky, stays visible and breaks the cluster (see wireframe state B). Start here; the user noted some non-fatal errors are themselves clutter, so expect to tune which errors surface vs. fold.
3. DEFAULT/RUN STATE: always collapsed, showing the last line + step count, with the ticker updating live. NO setting. (Rejected auto-expanding the in-flight cluster: it scrolls the last useful sticky message out of view during long runs and only collapses when done — worst timing. Collapsed-with-live-ticker keeps the last sticky message anchored while preserving the motion/dynamism.)
Implementation home: a ''collapse adjacent foldable items'' grouping pass over ConversationController.items, consumed by ConversationView; the card is a new conversation_card variant. Keep it accessible (keyboard expand/collapse + semantics: announce step count and collapsed/expanded state).
Acceptance:
1. Consecutive foldable items collapse into one card; the collapsed card shows the latest line + a live-updating step count.
2. A sticky message (user / Claude prose / surfaced error / interaction-zone prompt) seals the card and starts a new cluster after it.
3. Expanding shows every folded step (tool call + result pairs) in order; collapsing returns to the one-line view.
4. The foldable level is a setting (default L1: diffs + thinking stay first-class); switching levels re-groups the transcript.
5. Failed tool results surface as their own (sticky) row and break the cluster.
6. Keyboard + screen-reader accessible (expand/collapse, step count announced).
Implemented (2026-06-03): pure grouping in activity_cluster.dart (groupConversation + FoldLevel none/tools/thinking/everything + RenderGroup StickyItem/FoldedCluster), fully unit-tested. Activity card + grouping wired into conversation_view.dart (collapsed live ticker of the latest step + step count; click/Enter expands to the folded steps in order; Semantics announces count + expanded/collapsed). Default level = L1 (tools): tool calls+results fold; user/Claude prose, FAILED results (sticky, surfaced), diffs (Edit/Write), and thinking stay first-class. Added FoldLevel.none (no folding) used by the existing item-level renderer tests. DEFERRED (acceptance 4''s persistence): the fold level is a switchable ConversationView parameter (proven by tests; none/L1/L2/L3 re-group) but is NOT yet wired to a persisted user setting + a UI control — the live pane uses the L1 default. Follow-up: read it from settings (ctx.settings) + a control to change it. Did NOT match the wireframe pixel-for-pixel; functional shape per the refinement decisions.', 'done', 'medium', NULL, NULL, NULL, '2026-06-03 11:15:39', '2026-06-03 15:17:33', NULL, '2f9702465f4d0079bca7f4f4e7b60095', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
+5
View File
@@ -18,6 +18,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
### Added
- Claude pane folds runs of tool calls/results into a collapsible "activity
card" so prose isn't buried: collapsed by default with a live one-line ticker
+ step count, click/Enter to expand. Claude prose, user messages, and failed
results stay first-class; diffs and thinking stay visible at the default
level. (T-230)
- Theme switcher in the status bar: a far-right control showing the current
theme that opens a popover to switch live — click or keyboard (arrows/Enter,
Esc to dismiss). The `theme.pick` palette command is unchanged. (T-234)
@@ -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;
}
}
@@ -0,0 +1,82 @@
/// Unit tests for the activity-card grouping pass (T-230).
library;
import 'package:clide/builtin/claude/src/activity_cluster.dart';
import 'package:clide/builtin/claude/src/transcript_reader.dart';
import 'package:test/test.dart';
var _n = 0;
final _ts = DateTime.utc(2026, 1, 1);
UserMessage _user([String t = 'hi']) => UserMessage(uuid: 'u${_n++}', timestamp: _ts, isSidechain: false, text: t);
AssistantTextMessage _prose([String t = 'sure']) => AssistantTextMessage(uuid: 'a${_n++}', timestamp: _ts, isSidechain: false, text: t);
AssistantThinkingMessage _think() => AssistantThinkingMessage(uuid: 't${_n++}', timestamp: _ts, isSidechain: false, thinking: '');
AssistantToolUse _tool(String id, String name) =>
AssistantToolUse(uuid: 'tu${_n++}', timestamp: _ts, isSidechain: false, toolUseId: id, name: name, input: const {});
ToolResultMessage _result(String id, {bool isError = false}) =>
ToolResultMessage(uuid: 'r${_n++}', timestamp: _ts, isSidechain: false, toolUseId: id, content: '', isError: isError);
void main() {
group('groupConversation', () {
test('L1: a tool call + result fold into one cluster between sticky prose', () {
final groups = groupConversation([_user(), _tool('1', 'Bash'), _result('1'), _prose()], FoldLevel.tools);
expect(groups, hasLength(3));
expect(groups[0], isA<StickyItem>());
expect(groups[1], isA<FoldedCluster>());
expect((groups[1] as FoldedCluster).items, hasLength(2)); // tool + result, in order
expect(groups[2], isA<StickyItem>());
});
test('a sticky message seals the cluster and starts a new one after it', () {
final groups = groupConversation([_tool('1', 'Bash'), _result('1'), _prose(), _tool('2', 'Bash'), _result('2')], FoldLevel.tools);
expect(groups.map((g) => g.runtimeType.toString()), ['FoldedCluster', 'StickyItem', 'FoldedCluster']);
});
test('L1: diffs (Edit/Write) and thinking stay first-class', () {
final groups = groupConversation([_think(), _tool('1', 'Edit'), _result('1')], FoldLevel.tools);
// none fold at L1 → three sticky items, no cluster
expect(groups.every((g) => g is StickyItem), isTrue);
expect(groups, hasLength(3));
});
test('L2: thinking folds, diffs still first-class', () {
final folded = groupConversation([_think(), _tool('1', 'Bash'), _result('1')], FoldLevel.thinking);
expect(folded, hasLength(1));
expect((folded.single as FoldedCluster).items, hasLength(3)); // think + tool + result
final diff = groupConversation([_tool('1', 'Write'), _result('1')], FoldLevel.thinking);
expect(diff.every((g) => g is StickyItem), isTrue);
});
test('L3: everything except user/prose folds, including diffs and thinking', () {
final groups = groupConversation([_think(), _tool('1', 'Edit'), _result('1'), _tool('2', 'Bash'), _result('2')], FoldLevel.everything);
expect(groups, hasLength(1));
expect((groups.single as FoldedCluster).items, hasLength(5));
});
test('a failed result surfaces (sticky) and breaks the cluster', () {
final groups = groupConversation([
_tool('1', 'Bash'),
_result('1', isError: true),
_tool('2', 'Bash'),
_result('2'),
], FoldLevel.tools);
// [Folded([tool1]), Sticky(errorResult), Folded([tool2,result2])]
expect(groups, hasLength(3));
expect((groups[0] as FoldedCluster).items, hasLength(1));
expect(groups[1], isA<StickyItem>());
expect((groups[1] as StickyItem).item, isA<ToolResultMessage>());
expect((groups[2] as FoldedCluster).items, hasLength(2));
});
test('switching level re-groups the same transcript', () {
final items = [_tool('1', 'Bash'), _result('1'), _think()];
expect(groupConversation(items, FoldLevel.tools).whereType<FoldedCluster>().single.items, hasLength(2));
expect(groupConversation(items, FoldLevel.thinking).whereType<FoldedCluster>().single.items, hasLength(3));
});
test('empty input yields no groups', () {
expect(groupConversation(const [], FoldLevel.tools), isEmpty);
});
});
}
@@ -6,6 +6,7 @@ library;
import 'dart:async';
import 'package:clide/builtin/claude/src/activity_cluster.dart';
import 'package:clide/builtin/claude/src/claude_banner.dart';
import 'package:clide/builtin/claude/src/conversation_controller.dart';
import 'package:clide/builtin/claude/src/conversation_view.dart';
@@ -145,7 +146,7 @@ void main() {
tearDown(() => f.dispose());
Future<ConversationController> pumpWith(WidgetTester tester, List<ConversationItem> items,
{Set<String> hiddenToolUseIds = const {}, Map<String, bool> toolUseOutcomes = const {}}) async {
{Set<String> hiddenToolUseIds = const {}, Map<String, bool> toolUseOutcomes = const {}, FoldLevel foldLevel = FoldLevel.none}) async {
tester.view.physicalSize = const Size(900, 700);
tester.view.devicePixelRatio = 1.0;
addTearDown(() {
@@ -155,7 +156,8 @@ void main() {
final stream = StreamController<ConversationItem>.broadcast();
final c = ConversationController(stream: stream.stream);
addTearDown(c.dispose);
await tester.pumpWidget(harness(f, ConversationView(controller: c, hiddenToolUseIds: hiddenToolUseIds, toolUseOutcomes: toolUseOutcomes)));
await tester
.pumpWidget(harness(f, ConversationView(controller: c, hiddenToolUseIds: hiddenToolUseIds, toolUseOutcomes: toolUseOutcomes, foldLevel: foldLevel)));
for (final it in items) {
stream.add(it);
}
@@ -168,6 +170,36 @@ void main() {
expect(find.text('Waiting for Claude…'), findsOneWidget);
});
testWidgets('meta items fold into a collapsed activity card; tap expands (T-230)', (tester) async {
await pumpWith(
tester,
[
_tool('Bash', const {'command': 'echo hi'}),
_result('hi'),
],
foldLevel: FoldLevel.tools);
// Collapsed by default: one card with a step count, not the raw rows.
expect(find.text('2 steps'), findsOneWidget);
expect(find.bySemanticsLabel('Activity, 2 steps, collapsed'), findsOneWidget);
// Activating it expands to reveal the folded steps.
await tester.tap(find.bySemanticsLabel('Activity, 2 steps, collapsed'));
await tester.pumpAndSettle();
expect(find.bySemanticsLabel('Activity, 2 steps, expanded'), findsOneWidget);
});
testWidgets('a failed result surfaces first-class, not folded (T-230)', (tester) async {
await pumpWith(
tester,
[
_tool('Bash', const {'command': 'boom'}),
_result('error output', isError: true),
],
foldLevel: FoldLevel.tools);
// The tool call folds (1 step); the error result is sticky → no 2-step card.
expect(find.text('1 step'), findsOneWidget);
expect(find.textContaining('error output'), findsWidgets);
});
testWidgets('empty controller shows the provided emptyState instead', (tester) async {
final stream = StreamController<ConversationItem>.broadcast();
final c = ConversationController(stream: stream.stream);