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