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