merge successful tool call + result into one card (T-262)

A successful tool call now renders as a single merged card instead of a
separate call card + result card:

- ConversationCard gains a trailing header status slot (success check /
  error cross, semantics-labelled) and optional labelled extra body
  segments (P1 + P2 — reused by T-263's Agent card).
- conversation_view builds a reverse toolUseId→result lookup, folds a
  successful result into its call card as a colorized ClideCodeBlock
  (Read → file grammar, Bash → shell, else text), and suppresses the
  standalone success result in _visibleItems. Errors stay a separate
  prominent red card, now with a matching red header mark (note C).
- Suppression happens before grouping, so a merged success result is no
  longer a separate activity-cluster step (note D).

Tests: card status-mark + segment coverage, merged-fold + language
inference + in-flight/orphan/error cases, cluster non-double-count, and a
golden for the merged card (collapsed + expanded + error).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-07 19:41:28 +02:00
co-authored by Claude Opus 4.8
parent af23c7809e
commit 9a8f8641f7
9 changed files with 412 additions and 16 deletions
+77 -1
View File
@@ -31,6 +31,21 @@ class MessageAction {
enum ConversationCardVariant { stripe, bordered, bare }
/// A trailing status mark shown at the right end of the header (T-262): a
/// green check for a succeeded tool call, a red cross for a failure. [none]
/// renders no mark (the default for non-tool cards).
enum ConversationCardStatus { none, success, error }
/// An extra labelled body segment shown below the primary [ConversationCard.body]
/// when the card is expanded (T-262). Lets one card lay out CALL → RESULT (or,
/// for the Agent card, CALL → PROMPT → RESULT — T-263) with a muted sub-label +
/// divider between segments so the reader can tell the parts apart.
class CardSegment {
const CardSegment({required this.label, required this.child});
final String label;
final Widget child;
}
class ConversationCard extends StatefulWidget {
const ConversationCard({
super.key,
@@ -44,6 +59,8 @@ class ConversationCard extends StatefulWidget {
this.collapsedByDefault = false,
this.collapsedSummary,
this.borderColor,
this.status = ConversationCardStatus.none,
this.extraSegments = const [],
});
final ConversationCardVariant variant;
@@ -51,6 +68,14 @@ class ConversationCard extends StatefulWidget {
final String label;
final Widget body;
/// Trailing header status mark (T-262) — a success check or error cross at
/// the right end of the header. [ConversationCardStatus.none] shows nothing.
final ConversationCardStatus status;
/// Extra labelled segments rendered below [body] when expanded (T-262/T-263);
/// each gets a muted sub-label + divider so CALL/PROMPT/RESULT read apart.
final List<CardSegment> extraSegments;
/// Raw text the copy action yields; no copy button when null.
final String? copyText;
@@ -149,7 +174,14 @@ class _ConversationCardState extends State<ConversationCard> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_header(tokens),
if (!_collapsed) ...[const SizedBox(height: 4), widget.body],
if (!_collapsed) ...[
const SizedBox(height: 4),
widget.body,
for (final seg in widget.extraSegments) ...[
_segmentLabel(tokens, seg.label),
seg.child,
],
],
],
);
return Padding(
@@ -215,6 +247,9 @@ class _ConversationCardState extends State<ConversationCard> {
),
] else
const Spacer(),
// Trailing status mark (T-262): success check / error cross, sitting
// between the summary/spacer and the action bar.
if (widget.status != ConversationCardStatus.none) _statusMark(tokens),
// Actions are always in the tree (keyboard/AT always reachable).
// Opacity reveals them on hover or keyboard focus; opacity-0 keeps
// them layout-present but visually hidden so they don't distract.
@@ -253,6 +288,47 @@ class _ConversationCardState extends State<ConversationCard> {
);
}
/// The trailing success/error mark (T-262). Carries a Semantics label so the
/// outcome is announced, not just colour-coded.
Widget _statusMark(SurfaceTokens tokens) {
final ClideIconPainter icon;
final Color color;
final String label;
switch (widget.status) {
case ConversationCardStatus.success:
icon = const CheckIcon();
color = tokens.statusSuccess;
label = 'succeeded';
case ConversationCardStatus.error:
icon = const CloseIcon();
color = tokens.statusError;
label = 'failed';
case ConversationCardStatus.none:
return const SizedBox.shrink();
}
return Semantics(
label: label,
container: true,
child: Padding(
padding: const EdgeInsets.only(left: 8),
child: ClideIcon(icon, size: 12, color: color),
),
);
}
/// A muted sub-label + hairline divider introducing an [CardSegment] below
/// the primary body (T-262), so CALL/PROMPT/RESULT read as distinct parts.
Widget _segmentLabel(SurfaceTokens tokens, String label) => Padding(
padding: const EdgeInsets.only(top: 8, bottom: 4),
child: Row(
children: [
ClideText(label, fontSize: clideFontMeta, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
const SizedBox(width: 8),
Expanded(child: Container(height: 1, color: tokens.panelBorder)),
],
),
);
List<Widget> _actions(SurfaceTokens tokens) {
final items = <_ActionItem>[];
if (widget.copyText != null) items.add(_ActionItem('copy', _copy));
+74 -7
View File
@@ -18,6 +18,7 @@ import 'package:clide/builtin/claude/src/conversation_controller.dart';
import 'package:clide/builtin/claude/src/prompt_card.dart';
import 'package:clide/builtin/claude/src/transcript_reader.dart';
import 'package:clide/kernel/src/facade.dart';
import 'package:clide/kernel/src/syntax/language_map.dart';
import 'package:clide/kernel/src/theme/controller.dart';
import 'package:clide/kernel/src/theme/tokens.dart';
import 'package:clide/widgets/widgets.dart';
@@ -99,14 +100,31 @@ class _ConversationViewState extends State<ConversationView> {
if (it is AssistantToolUse && it.name == 'AskUserQuestion') it.toolUseId,
};
final outcomes = widget.toolUseOutcomes;
final toolUseById = {
for (final it in items)
if (it is AssistantToolUse) it.toolUseId: it,
};
// A tool-use is dropped (not rendered as a card) when it's an
// AskUserQuestion or a still-pending permission prompt.
bool toolUseDropped(AssistantToolUse t) {
if (t.name == 'AskUserQuestion') return true;
return hidden.contains(t.toolUseId) && !outcomes.containsKey(t.toolUseId);
}
bool drop(ConversationItem it) {
if (it is AssistantToolUse) {
if (it.name == 'AskUserQuestion') return true;
// Permission-prompted: hide only while pending; once resolved it shows
// collapsed with a green/red border.
return hidden.contains(it.toolUseId) && !outcomes.containsKey(it.toolUseId);
if (it is AssistantToolUse) return toolUseDropped(it);
if (it is ToolResultMessage) {
if (auqIds.contains(it.toolUseId)) return true; // AUQ result echo — noise
// T-262: a successful result whose paired tool-use is going to render
// folds INTO that card — suppress the standalone result here so it
// doesn't double-render. Errors stay standalone (prominent red card);
// orphan results (no paired tool-use) stay standalone too.
if (it.isError) return false;
final tu = toolUseById[it.toolUseId];
if (tu == null) return false;
return !toolUseDropped(tu);
}
if (it is ToolResultMessage) return auqIds.contains(it.toolUseId); // AUQ result only; keep permission results
return false;
}
@@ -139,6 +157,14 @@ class _ConversationViewState extends State<ConversationView> {
);
}
// Reverse pairing (T-262): toolUseId → its result, so a tool-use card can
// fold a successful result in. Built from the full item list (not the
// visible one — the success result is suppressed from `items`).
final resultByToolUseId = <String, ToolResultMessage>{
for (final it in widget.controller.items)
if (it is ToolResultMessage) it.toolUseId: it,
};
// 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);
@@ -156,12 +182,14 @@ class _ConversationViewState extends State<ConversationView> {
tokens: tokens,
toolUseOutcomes: widget.toolUseOutcomes,
toolUseById: widget.controller.toolUseById,
resultByToolUseId: resultByToolUseId,
),
FoldedCluster(:final items) => _ActivityCard(
items: items,
tokens: tokens,
toolUseOutcomes: widget.toolUseOutcomes,
toolUseById: widget.controller.toolUseById,
resultByToolUseId: resultByToolUseId,
),
};
},
@@ -185,6 +213,7 @@ class _ConversationTurn extends StatelessWidget {
required this.tokens,
this.toolUseOutcomes = const <String, bool>{},
this.toolUseById = const <String, AssistantToolUse>{},
this.resultByToolUseId = const <String, ToolResultMessage>{},
});
final ConversationItem item;
@@ -194,6 +223,10 @@ class _ConversationTurn extends StatelessWidget {
/// Index from toolUseId → AssistantToolUse, for result-card pairing (T-168).
final Map<String, AssistantToolUse> toolUseById;
/// Index from toolUseId → its result, so a tool-use card can fold a
/// successful result into one merged card (T-262).
final Map<String, ToolResultMessage> resultByToolUseId;
@override
Widget build(BuildContext context) {
final i = item;
@@ -315,8 +348,20 @@ class _ConversationTurn extends StatelessWidget {
);
Widget _toolUse(AssistantToolUse t) {
// T-262: fold the paired result into this card. A successful result becomes
// a "result" segment below the call + a green header check; a failed result
// stamps a red header cross but stays a separate prominent error card (the
// result is not suppressed — see _visibleItems). No result yet (in-flight) →
// no mark, no segment.
final result = resultByToolUseId[t.toolUseId];
final succeeded = result != null && !result.isError;
final status = result == null ? ConversationCardStatus.none : (result.isError ? ConversationCardStatus.error : ConversationCardStatus.success);
final segments =
succeeded ? [CardSegment(label: 'result', child: ClideCodeBlock(source: result.content, language: _resultLanguage(t)))] : const <CardSegment>[];
// A resolved permission-prompted call: collapsed, green if approved / red
// if denied — a quiet record of what was permitted (D-78).
// if denied — a quiet record of what was permitted (D-78). It still folds
// its result + outcome check like any other merged card (T-262).
final outcome = toolUseOutcomes[t.toolUseId];
if (outcome != null) {
final color = outcome ? tokens.statusSuccess : tokens.statusError;
@@ -329,7 +374,9 @@ class _ConversationTurn extends StatelessWidget {
collapsible: true,
collapsedByDefault: true,
collapsedSummary: _toolUseSummary(t),
status: status,
body: toolInputBody(tokens, t.name, t.input),
extraSegments: segments,
);
}
// Per-tool body rendering (T-168): Bash → command block, Edit/Write → diff,
@@ -345,10 +392,27 @@ class _ConversationTurn extends StatelessWidget {
collapsible: true,
collapsedByDefault: true,
collapsedSummary: summary,
status: status,
body: body,
extraSegments: segments,
);
}
/// Per-tool language for the folded result code block (T-262): Read shows the
/// file's content, so colorize by the file's grammar; Bash output is shell;
/// everything else (Grep/LS/Write/Edit confirmations/…) falls back to plain.
String _resultLanguage(AssistantToolUse t) {
switch (t.name) {
case 'Read':
final path = (t.input['file_path'] ?? t.input['path']) as String?;
return (path != null && path.isNotEmpty ? grammarForPath(path) : null) ?? 'text';
case 'Bash':
return 'bash';
default:
return 'text';
}
}
Widget _toolResult(ToolResultMessage t) {
final paired = toolUseById[t.toolUseId];
final accent = t.isError ? tokens.statusError : tokens.globalTextMuted;
@@ -430,12 +494,14 @@ class _ActivityCard extends StatefulWidget {
required this.tokens,
required this.toolUseOutcomes,
required this.toolUseById,
required this.resultByToolUseId,
});
final List<ConversationItem> items;
final SurfaceTokens tokens;
final Map<String, bool> toolUseOutcomes;
final Map<String, AssistantToolUse> toolUseById;
final Map<String, ToolResultMessage> resultByToolUseId;
@override
State<_ActivityCard> createState() => _ActivityCardState();
@@ -503,6 +569,7 @@ class _ActivityCardState extends State<_ActivityCard> {
tokens: tokens,
toolUseOutcomes: widget.toolUseOutcomes,
toolUseById: widget.toolUseById,
resultByToolUseId: widget.resultByToolUseId,
),
],
),