diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index 994ebdad..32d902f0 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -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 / 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 / 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; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 4bf944b1..247dffd5 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -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 / 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 / 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); diff --git a/CHANGELOG.md b/CHANGELOG.md index c0b189ee..77bf4846 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/lib/builtin/claude/src/activity_cluster.dart b/lib/builtin/claude/src/activity_cluster.dart new file mode 100644 index 00000000..dedf181e --- /dev/null +++ b/lib/builtin/claude/src/activity_cluster.dart @@ -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 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 groupConversation(List items, FoldLevel level) { + // tool_use_id → tool name, so a ToolResultMessage can be classified. + final toolName = { + for (final it in items) + if (it is AssistantToolUse) it.toolUseId: it.name, + }; + + final out = []; + var cluster = []; + + 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 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] ?? ''); + } +} diff --git a/lib/builtin/claude/src/conversation_view.dart b/lib/builtin/claude/src/conversation_view.dart index b01ade8b..6177f3bb 100644 --- a/lib/builtin/claude/src/conversation_view.dart +++ b/lib/builtin/claude/src/conversation_view.dart @@ -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 {}, this.toolUseOutcomes = const {}, + 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 { ); } + // 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 items; + final SurfaceTokens tokens; + final Map toolUseOutcomes; + final Map 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; + } +} diff --git a/test/builtin/claude/activity_cluster_test.dart b/test/builtin/claude/activity_cluster_test.dart new file mode 100644 index 00000000..e4701e4a --- /dev/null +++ b/test/builtin/claude/activity_cluster_test.dart @@ -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()); + expect(groups[1], isA()); + expect((groups[1] as FoldedCluster).items, hasLength(2)); // tool + result, in order + expect(groups[2], isA()); + }); + + 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()); + expect((groups[1] as StickyItem).item, isA()); + 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().single.items, hasLength(2)); + expect(groupConversation(items, FoldLevel.thinking).whereType().single.items, hasLength(3)); + }); + + test('empty input yields no groups', () { + expect(groupConversation(const [], FoldLevel.tools), isEmpty); + }); + }); +} diff --git a/test/builtin/claude/conversation_view_test.dart b/test/builtin/claude/conversation_view_test.dart index fe865776..dad91078 100644 --- a/test/builtin/claude/conversation_view_test.dart +++ b/test/builtin/claude/conversation_view_test.dart @@ -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 pumpWith(WidgetTester tester, List items, - {Set hiddenToolUseIds = const {}, Map toolUseOutcomes = const {}}) async { + {Set hiddenToolUseIds = const {}, Map 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.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.broadcast(); final c = ConversationController(stream: stream.stream);