render every conversation turn through one card primitive
test / unit + widget + golden + a11y (push) Failing after 24s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
test / dart doc (lib API) (push) Failing after 25s

The Claude pane's conversation view hand-rolled a separate card layout
per message kind (user/assistant/thinking/tool-use/tool-result), so any
shared chrome had to be added five times. ConversationCard is one
template with three variants (stripe/bordered/bare) that wires the
chrome once: a copy button revealed on hover (yielding the turn's raw
text), an always-visible collapse/expand caret for collapsible turns,
and an extensible MessageAction list. It's decoupled from
ConversationItem — the view maps each item to (variant, accent, label,
body, copyText, actions) — so the typed event cards coming with the
stream-json work reuse the same chrome with a different body.

T-173.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-24 19:10:02 +02:00
co-authored by Claude Opus 4.7
parent 1e77d72361
commit e672f1a77b
6 changed files with 346 additions and 102 deletions
@@ -0,0 +1,182 @@
/// The base template every conversation turn renders through (T-173).
///
/// One primitive with three [ConversationCardVariant]s (the stripe, bordered,
/// and bare looks the view used to hand-roll), plus chrome wired in once for
/// all message types: a hover-revealed copy button, an always-visible
/// collapse/expand caret for collapsible turns, and an extensible
/// [MessageAction] list. Decoupled from `ConversationItem` — the view maps
/// each item to (variant, accent, label, body, copyText, actions), so future
/// typed cards (T-168) reuse this chrome with a different body.
library;
import 'dart:async';
import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
/// An extensible per-message affordance shown in the card's action bar.
/// Copy is provided by the card from `copyText`; callers add more (e.g.
/// fork-from-here, retry) without touching the template.
class MessageAction {
const MessageAction({required this.label, required this.onInvoke});
final String label;
final VoidCallback onInvoke;
}
enum ConversationCardVariant { stripe, bordered, bare }
class ConversationCard extends StatefulWidget {
const ConversationCard({
super.key,
this.variant = ConversationCardVariant.stripe,
required this.accent,
required this.label,
required this.body,
this.copyText,
this.actions = const [],
this.collapsible = false,
this.collapsedByDefault = false,
this.borderColor,
});
final ConversationCardVariant variant;
final Color accent;
final String label;
final Widget body;
/// Raw text the copy action yields; no copy button when null.
final String? copyText;
/// Extra actions appended after copy.
final List<MessageAction> actions;
final bool collapsible;
final bool collapsedByDefault;
/// Border colour for the bordered variant (e.g. error red); defaults to the
/// panel border.
final Color? borderColor;
@override
State<ConversationCard> createState() => _ConversationCardState();
}
class _ConversationCardState extends State<ConversationCard> {
bool _hover = false;
late bool _collapsed = widget.collapsible && widget.collapsedByDefault;
void _copy() {
final text = widget.copyText;
if (text != null) unawaited(ClideKernel.of(context).clipboard.writePlain(text));
}
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
final content = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_header(tokens),
if (!_collapsed) ...[const SizedBox(height: 4), widget.body],
],
);
return Padding(
padding: const EdgeInsets.only(bottom: 14),
child: MouseRegion(
onEnter: (_) => setState(() => _hover = true),
onExit: (_) => setState(() => _hover = false),
child: _frame(tokens, content),
),
);
}
Widget _frame(SurfaceTokens tokens, Widget content) {
switch (widget.variant) {
case ConversationCardVariant.stripe:
return ClipRRect(
borderRadius: BorderRadius.circular(6),
child: ColoredBox(
color: tokens.globalBackground,
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(width: 3, color: widget.accent),
Expanded(
child: Padding(padding: const EdgeInsets.fromLTRB(12, 8, 12, 8), child: content),
),
],
),
),
),
);
case ConversationCardVariant.bordered:
return Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: tokens.globalBackground,
border: Border.all(color: widget.borderColor ?? tokens.panelBorder),
borderRadius: BorderRadius.circular(4),
),
child: content,
);
case ConversationCardVariant.bare:
return content;
}
}
Widget _header(SurfaceTokens tokens) {
return Row(
children: [
if (widget.collapsible) _caret(tokens),
ClideText(widget.label, fontSize: clideFontSmall, color: widget.accent, fontFamily: clideMonoFamily),
const Spacer(),
// Hover-revealed actions. (Always-reachable keyboard a11y for these is
// a follow-up detail; the collapse caret above is always visible.)
if (_hover) ..._actions(tokens),
],
);
}
Widget _caret(SurfaceTokens tokens) {
return _tap(
label: _collapsed ? 'Expand' : 'Collapse',
onTap: () => setState(() => _collapsed = !_collapsed),
child: Padding(
padding: const EdgeInsets.only(right: 6),
child: ClideIcon(
_collapsed ? PhosphorIcons.caretRight : PhosphorIcons.caretDown,
size: 12,
color: tokens.globalTextMuted,
),
),
);
}
List<Widget> _actions(SurfaceTokens tokens) {
Widget btn(String label, VoidCallback onTap) => _tap(
label: label,
onTap: onTap,
child: Padding(
padding: const EdgeInsets.only(left: 10),
child: ClideText(label, fontSize: clideFontMeta, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
),
);
return [
if (widget.copyText != null) btn('copy', _copy),
for (final a in widget.actions) btn(a.label, a.onInvoke),
];
}
Widget _tap({required String label, required VoidCallback onTap, required Widget child}) {
return Semantics(
button: true,
label: label,
child: GestureDetector(
onTap: onTap,
child: MouseRegion(cursor: SystemMouseCursors.click, child: child),
),
);
}
}
+35 -102
View File
@@ -11,6 +11,7 @@ library;
import 'dart:convert';
import 'package:clide/builtin/claude/src/conversation_card.dart';
import 'package:clide/builtin/claude/src/conversation_controller.dart';
import 'package:clide/builtin/claude/src/transcript_reader.dart';
import 'package:clide/kernel/src/theme/controller.dart';
@@ -120,104 +121,54 @@ class _ConversationTurn extends StatelessWidget {
Widget build(BuildContext context) {
final i = item;
return switch (i) {
UserMessage() => _messageCard('you', tokens.globalFocus, ClideMarkdown(i.text)),
AssistantTextMessage() => _messageCard('claude', claudeAccent, ClideMarkdown(i.text)),
AssistantThinkingMessage() => _labelled(
'thinking',
tokens.globalTextMuted,
ClideText(i.thinking, muted: true, fontSize: clideFontMeta),
UserMessage() => ConversationCard(
accent: tokens.globalFocus,
label: 'you',
copyText: i.text,
body: ClideMarkdown(i.text),
),
AssistantTextMessage() => ConversationCard(
accent: claudeAccent,
label: 'claude',
copyText: i.text,
body: ClideMarkdown(i.text),
),
AssistantThinkingMessage() => ConversationCard(
variant: ConversationCardVariant.bare,
accent: tokens.globalTextMuted,
label: 'thinking',
copyText: i.thinking,
collapsible: true,
collapsedByDefault: true,
body: ClideText(i.thinking, muted: true, fontSize: clideFontMeta),
),
AssistantToolUse() => _toolUse(i),
ToolResultMessage() => _toolResult(i),
};
}
/// A turn rendered as a distinct card: an [accent]-coloured left stripe
/// and label over a filled background, so user and Claude turns read
/// apart from each other (and from the panel canvas) by accent.
Widget _messageCard(String label, Color accent, Widget body) {
return Padding(
padding: const EdgeInsets.only(bottom: 14),
child: ClipRRect(
borderRadius: BorderRadius.circular(6),
child: ColoredBox(
color: tokens.globalBackground,
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(width: 3, color: accent),
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClideText(
label,
fontSize: clideFontSmall,
color: accent,
fontFamily: clideMonoFamily,
),
const SizedBox(height: 4),
body,
],
),
),
),
],
),
),
),
),
);
}
/// A labelled turn: a small role tag above the body.
Widget _labelled(String label, Color labelColor, Widget body) {
return Padding(
padding: const EdgeInsets.only(bottom: 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClideText(
label,
fontSize: clideFontSmall,
color: labelColor,
fontFamily: clideMonoFamily,
),
const SizedBox(height: 4),
body,
],
),
);
}
Widget _toolUse(AssistantToolUse t) {
final pretty = const JsonEncoder.withIndent(' ').convert(t.input);
return _card(
borderColor: tokens.panelBorder,
header: Row(
children: [
ClideText('', color: tokens.globalFocus, fontFamily: clideMonoFamily),
const SizedBox(width: 6),
ClideText(t.name, fontWeight: FontWeight.w500, fontFamily: clideMonoFamily),
],
),
return ConversationCard(
variant: ConversationCardVariant.bordered,
accent: tokens.globalFocus,
label: t.name,
copyText: pretty,
collapsible: true,
body: ClideCodeBlock(source: pretty, language: 'json'),
);
}
Widget _toolResult(ToolResultMessage t) {
final color = t.isError ? tokens.statusError : tokens.globalTextMuted;
return _card(
final accent = t.isError ? tokens.statusError : tokens.globalTextMuted;
return ConversationCard(
variant: ConversationCardVariant.bordered,
accent: accent,
borderColor: t.isError ? tokens.statusError : tokens.panelBorder,
header: ClideText(
t.isError ? 'error' : 'result',
fontSize: clideFontSmall,
color: color,
fontFamily: clideMonoFamily,
),
label: t.isError ? 'error' : 'result',
copyText: t.content,
collapsible: true,
collapsedByDefault: true,
body: ClideText(
t.content,
fontSize: clideFontMeta,
@@ -226,22 +177,4 @@ class _ConversationTurn extends StatelessWidget {
),
);
}
Widget _card({required Color borderColor, required Widget header, required Widget body}) {
return Padding(
padding: const EdgeInsets.only(bottom: 14),
child: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: tokens.globalBackground,
border: Border.all(color: borderColor),
borderRadius: BorderRadius.circular(4),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [header, const SizedBox(height: 6), body],
),
),
);
}
}