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