1use dioxus::prelude::*;
29use dioxus_bulma::prelude::*;
30#[cfg(feature = "genai")]
31use genai::chat::{ChatMessage as GenAiChatMessage, ChatRequest, ChatRole as GenAiChatRole, ToolResponse};
32use serde::{Deserialize, Serialize};
33use serde_json::Value;
34
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36pub enum ChatRole {
37 System,
38 User,
39 Assistant,
40 Tool,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44pub enum ToolCallStatus {
45 Pending,
46 Running,
47 Completed,
48 Failed,
49}
50
51impl ToolCallStatus {
52 pub fn as_label(&self) -> &'static str {
53 match self {
54 Self::Pending => "pending",
55 Self::Running => "running",
56 Self::Completed => "completed",
57 Self::Failed => "failed",
58 }
59 }
60}
61
62#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
63pub struct ToolCall {
64 pub name: String,
65 pub arguments: Value,
66 pub status: ToolCallStatus,
67}
68
69#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
70pub struct ProgressState {
71 pub label: String,
72 pub percent: f32,
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
77pub enum StepStatus {
78 Pending,
80 Active,
82 Done,
84 Failed,
86}
87
88#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
90pub struct ReasoningStep {
91 pub title: String,
92 #[serde(default)]
94 pub detail: Option<String>,
95 pub status: StepStatus,
96}
97
98impl ReasoningStep {
99 pub fn new(title: impl Into<String>, status: StepStatus) -> Self {
100 Self { title: title.into(), detail: None, status }
101 }
102
103 pub fn with_detail(mut self, detail: impl Into<String>) -> Self {
104 self.detail = Some(detail.into());
105 self
106 }
107}
108
109#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
111pub struct Reasoning {
112 pub summary: String,
114 pub steps: Vec<ReasoningStep>,
115 #[serde(default)]
117 pub collapsed: bool,
118}
119
120#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
122pub enum ControlStyle {
123 Primary,
124 #[default]
125 Neutral,
126 Danger,
127 Ghost,
128}
129
130#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
132pub struct SelectOption {
133 pub value: String,
134 pub label: String,
135}
136
137impl SelectOption {
138 pub fn new(value: impl Into<String>, label: impl Into<String>) -> Self {
139 Self { value: value.into(), label: label.into() }
140 }
141}
142
143#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
148pub enum InlineControl {
149 Button {
150 id: String,
151 label: String,
152 #[serde(default)]
153 style: ControlStyle,
154 #[serde(default)]
155 disabled: bool,
156 },
157 Select {
158 id: String,
159 #[serde(default)]
160 label: Option<String>,
161 options: Vec<SelectOption>,
162 #[serde(default)]
163 selected: Option<String>,
164 },
165 Toggle {
166 id: String,
167 label: String,
168 #[serde(default)]
169 value: bool,
170 },
171}
172
173#[derive(Debug, Clone, PartialEq)]
175pub enum ControlValue {
176 Clicked,
178 Selected(String),
180 Toggled(bool),
182}
183
184#[derive(Debug, Clone, PartialEq)]
186pub struct ControlEvent {
187 pub id: String,
189 pub value: ControlValue,
190}
191
192#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
194pub enum ContextKind {
195 File,
196 Directory,
197}
198
199#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
206pub struct ContextItem {
207 pub id: String,
209 pub label: String,
211 pub kind: ContextKind,
212}
213
214impl ContextItem {
215 pub fn file(id: impl Into<String>, label: impl Into<String>) -> Self {
216 Self { id: id.into(), label: label.into(), kind: ContextKind::File }
217 }
218
219 pub fn directory(id: impl Into<String>, label: impl Into<String>) -> Self {
220 Self { id: id.into(), label: label.into(), kind: ContextKind::Directory }
221 }
222
223 pub fn from_document(doc: &Document) -> Self {
225 Self::file(doc.id.clone(), doc.name.clone())
226 }
227}
228
229#[derive(Debug, Clone, PartialEq)]
234pub enum ContextEvent {
235 AddFilesRequested,
237 AddDirectoryRequested,
239 Remove(String),
241}
242
243#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
245pub enum DiffKind {
246 Added,
248 Removed,
250 Context,
252}
253
254#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
256pub struct DiffLine {
257 pub kind: DiffKind,
258 pub content: String,
259}
260
261impl DiffLine {
262 pub fn added(content: impl Into<String>) -> Self {
263 Self { kind: DiffKind::Added, content: content.into() }
264 }
265
266 pub fn removed(content: impl Into<String>) -> Self {
267 Self { kind: DiffKind::Removed, content: content.into() }
268 }
269
270 pub fn context(content: impl Into<String>) -> Self {
271 Self { kind: DiffKind::Context, content: content.into() }
272 }
273}
274
275#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
277pub struct FileDiff {
278 pub path: String,
280 pub lines: Vec<DiffLine>,
281 #[serde(default = "default_true")]
284 pub animate: bool,
285}
286
287fn default_true() -> bool {
288 true
289}
290
291#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
293pub enum DocumentKind {
294 Image,
295 Pdf,
296 Text,
297 Other,
298 Custom,
301}
302
303#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
310pub struct Document {
311 pub id: String,
312 pub name: String,
314 pub kind: DocumentKind,
315 #[serde(default)]
318 pub image: Option<String>,
319 #[serde(default)]
321 pub url: Option<String>,
322 #[serde(default)]
324 pub text: Option<String>,
325 #[serde(default)]
327 pub data: Option<Value>,
328}
329
330impl Document {
331 fn bare(id: impl Into<String>, name: impl Into<String>, kind: DocumentKind) -> Self {
332 Self {
333 id: id.into(),
334 name: name.into(),
335 kind,
336 image: None,
337 url: None,
338 text: None,
339 data: None,
340 }
341 }
342
343 pub fn image(id: impl Into<String>, name: impl Into<String>, src: impl Into<String>) -> Self {
345 Self { image: Some(src.into()), ..Self::bare(id, name, DocumentKind::Image) }
346 }
347
348 pub fn text(id: impl Into<String>, name: impl Into<String>, content: impl Into<String>) -> Self {
350 Self { text: Some(content.into()), ..Self::bare(id, name, DocumentKind::Text) }
351 }
352
353 pub fn pdf(id: impl Into<String>, name: impl Into<String>, url: impl Into<String>) -> Self {
356 Self { url: Some(url.into()), ..Self::bare(id, name, DocumentKind::Pdf) }
357 }
358
359 pub fn custom(id: impl Into<String>, name: impl Into<String>, data: Value) -> Self {
362 Self { data: Some(data), ..Self::bare(id, name, DocumentKind::Custom) }
363 }
364
365 pub fn with_image(mut self, src: impl Into<String>) -> Self {
367 self.image = Some(src.into());
368 self
369 }
370}
371
372#[derive(Debug, Clone, PartialEq)]
378pub enum DocumentEvent {
379 AddToContext(Vec<Document>),
383}
384
385#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
386pub enum ChatMessagePayload {
387 Text(String),
388 Markdown(String),
389 Reasoning(Reasoning),
391 ToolCall(ToolCall),
392 ToolResult { name: String, content: String },
393 Progress(ProgressState),
394 Status(String),
396 Controls(Vec<InlineControl>),
398 Diff(FileDiff),
400 Documents(Vec<Document>),
402 Typing,
403 Error(String),
404}
405
406#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
407pub struct ChatMessage {
408 pub role: ChatRole,
409 pub payload: ChatMessagePayload,
410}
411
412#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
413pub struct ChatTranscript {
414 pub messages: Vec<ChatMessage>,
415}
416
417impl ChatTranscript {
418 pub fn push(&mut self, role: ChatRole, payload: ChatMessagePayload) {
419 self.messages.push(ChatMessage { role, payload });
420 }
421
422 #[cfg(feature = "genai")]
423 pub fn to_genai_request(&self) -> ChatRequest {
424 let mut system: Option<String> = None;
425 let mut messages = Vec::new();
426
427 for message in &self.messages {
428 match (&message.role, &message.payload) {
429 (ChatRole::System, ChatMessagePayload::Text(content))
430 | (ChatRole::System, ChatMessagePayload::Markdown(content)) => {
431 if system.is_none() {
432 system = Some(content.clone());
433 } else {
434 messages.push(GenAiChatMessage::system(content.clone()));
435 }
436 }
437 (ChatRole::User, ChatMessagePayload::Text(content))
438 | (ChatRole::User, ChatMessagePayload::Markdown(content)) => {
439 messages.push(GenAiChatMessage::user(content.clone()));
440 }
441 (ChatRole::Assistant, ChatMessagePayload::Text(content))
442 | (ChatRole::Assistant, ChatMessagePayload::Markdown(content)) => {
443 messages.push(GenAiChatMessage::assistant(content.clone()));
444 }
445 (ChatRole::Tool, ChatMessagePayload::Text(content))
446 | (ChatRole::Tool, ChatMessagePayload::Markdown(content)) => {
447 messages.push(GenAiChatMessage {
448 role: GenAiChatRole::Tool,
449 content: format!("Tool output: {content}").into(),
450 options: None,
451 });
452 }
453 (_, ChatMessagePayload::ToolResult { name, content }) => {
454 messages.push(GenAiChatMessage::from(
455 ToolResponse::new(name.clone(), content.clone()),
456 ));
457 }
458 (_, ChatMessagePayload::ToolCall(call)) => {
459 messages.push(GenAiChatMessage::assistant(format!(
460 "Tool call requested: {} with {}",
461 call.name, call.arguments
462 )));
463 }
464 (_, ChatMessagePayload::Error(content)) => {
465 messages.push(GenAiChatMessage::assistant(content.clone()));
466 }
467 (_, ChatMessagePayload::Progress(_))
468 | (_, ChatMessagePayload::Reasoning(_))
469 | (_, ChatMessagePayload::Status(_))
470 | (_, ChatMessagePayload::Controls(_))
471 | (_, ChatMessagePayload::Diff(_))
472 | (_, ChatMessagePayload::Documents(_))
473 | (_, ChatMessagePayload::Typing) => {}
474 }
475 }
476
477 let mut request = ChatRequest::new(messages);
478 request.system = system;
479 request
480 }
481}
482
483#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
484pub struct ChatControls {
485 pub show_input: bool,
486 pub show_send_button: bool,
487 pub show_stop_button: bool,
488 pub show_retry_button: bool,
489 pub show_clear_button: bool,
490 pub input_enabled: bool,
491 pub placeholder: String,
492 pub allow_file_attachments: bool,
495 pub allow_directory_context: bool,
497 pub attach_files_label: String,
499 pub add_directory_label: String,
501 pub allow_document_selection: bool,
504}
505
506impl Default for ChatControls {
507 fn default() -> Self {
508 Self {
509 show_input: true,
510 show_send_button: true,
511 show_stop_button: true,
512 show_retry_button: true,
513 show_clear_button: true,
514 input_enabled: true,
515 placeholder: "Ask anything, or invoke a tool...".to_string(),
516 allow_file_attachments: false,
517 allow_directory_context: false,
518 attach_files_label: "📎 Add files".to_string(),
519 add_directory_label: "📁 Add folder".to_string(),
520 allow_document_selection: false,
521 }
522 }
523}
524
525#[derive(Props, Clone, PartialEq)]
526pub struct ChatSurfaceProps {
527 pub transcript: ChatTranscript,
528 #[props(default)]
529 pub controls: ChatControls,
530 #[props(default)]
531 pub title: Option<String>,
532 #[props(default)]
533 pub theme: Option<BulmaTheme>,
534 #[props(default)]
541 pub embedded: bool,
542 #[props(default)]
546 pub input: String,
547 #[props(default)]
549 pub on_input: EventHandler<String>,
550 #[props(default)]
553 pub on_send: EventHandler<String>,
554 #[props(default)]
556 pub on_stop: EventHandler<()>,
557 #[props(default)]
559 pub on_retry: EventHandler<()>,
560 #[props(default)]
562 pub on_clear: EventHandler<()>,
563 #[props(default)]
567 pub input_accessory: Option<Element>,
568 #[props(default)]
570 pub on_action: EventHandler<ControlEvent>,
571 #[props(default)]
574 pub attachments: Vec<ContextItem>,
575 #[props(default)]
577 pub on_context: EventHandler<ContextEvent>,
578 #[props(default)]
582 pub render_document: Option<Callback<Document, Element>>,
583 #[props(default)]
586 pub on_document: EventHandler<DocumentEvent>,
587}
588
589const CHAT_SURFACE_CSS: &str = r#"
592.gc-msg { margin-bottom: 1.1rem; }
593.gc-caption { display: flex; align-items: center; gap: 0.4rem; margin-bottom: 0.25rem; }
594.gc-dot { width: 0.5rem; height: 0.5rem; border-radius: 50%; display: inline-block; flex: none; }
595.gc-msg-system .gc-dot { background: #b5b5b5; }
596.gc-msg-user .gc-dot { background: var(--bulma-primary, #00d1b2); }
597.gc-msg-assistant .gc-dot { background: var(--bulma-link, #485fc7); }
598.gc-msg-tool .gc-dot { background: var(--bulma-info, #3e8ed0); }
599.gc-role { font-size: 0.7rem; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; color: var(--bulma-text-weak, #7a7a7a); }
600.gc-body { padding-left: 0.9rem; border-left: 2px solid var(--bulma-border-weak, #ededed); margin-left: 0.24rem; }
601.gc-body > p { margin: 0; }
602.gc-muted { color: var(--bulma-text-weak, #7a7a7a); }
603
604.gc-code { padding: 0.6rem 0.75rem; border-radius: 0.5rem; background: var(--bulma-scheme-main-bis, #f5f7fa); font-size: 0.8rem; overflow-x: auto; margin-top: 0.4rem; white-space: pre; }
605.gc-tool-line { display: flex; align-items: center; gap: 0.5rem; }
606.gc-tool-name { font-family: monospace; font-weight: 600; font-size: 0.85rem; color: var(--bulma-text, #363636); }
607.gc-chip { font-size: 0.65rem; text-transform: uppercase; letter-spacing: 0.03em; padding: 0.1rem 0.45rem; border-radius: 999px; font-weight: 600; }
608.gc-chip-pending { background: rgba(255,183,15,.18); color: #b87503; }
609.gc-chip-active { background: rgba(62,142,208,.18); color: #2b6cb0; }
610.gc-chip-done { background: rgba(72,199,142,.18); color: #257953; }
611.gc-chip-failed { background: rgba(241,70,104,.18); color: #c81e4b; }
612
613.gc-reasoning { border: 1px solid var(--bulma-border-weak, #ededed); border-radius: 0.6rem; background: var(--bulma-scheme-main-bis, #f8f9fb); padding: 0.5rem 0.75rem; }
614.gc-reasoning-summary { cursor: pointer; display: flex; align-items: center; gap: 0.5rem; list-style: none; font-size: 0.78rem; color: var(--bulma-text-weak, #7a7a7a); font-weight: 600; }
615.gc-reasoning-summary::-webkit-details-marker { display: none; }
616.gc-chevron { width: 0; height: 0; border-left: 5px solid currentColor; border-top: 4px solid transparent; border-bottom: 4px solid transparent; transition: transform .15s ease; flex: none; }
617.gc-reasoning[open] .gc-chevron { transform: rotate(90deg); }
618
619.gc-timeline { list-style: none; margin: 0.6rem 0 0.15rem; padding: 0; }
620.gc-step { position: relative; padding: 0 0 0.7rem 1.5rem; }
621.gc-step:last-child { padding-bottom: 0; }
622.gc-step::before { content: ""; position: absolute; left: 0.4rem; top: 1.05rem; bottom: -0.1rem; width: 2px; background: var(--bulma-border, #dbdbdb); }
623.gc-step:last-child::before { display: none; }
624.gc-step-marker { position: absolute; left: 0; top: 0.05rem; width: 0.85rem; height: 0.85rem; display: inline-flex; align-items: center; justify-content: center; font-size: 0.72rem; line-height: 1; }
625.gc-step-pending .gc-step-marker { color: var(--bulma-text-weak, #b5b5b5); }
626.gc-step-active .gc-step-marker { color: var(--bulma-info, #3e8ed0); animation: gc-pulse 1.2s ease-in-out infinite; }
627.gc-step-done .gc-step-marker { color: var(--bulma-success, #48c78e); }
628.gc-step-failed .gc-step-marker { color: var(--bulma-danger, #f14668); }
629.gc-step-content { display: flex; flex-direction: column; }
630.gc-step-title { font-size: 0.85rem; color: var(--bulma-text, #363636); }
631.gc-step-pending .gc-step-title { color: var(--bulma-text-weak, #7a7a7a); }
632.gc-step-detail { font-size: 0.75rem; color: var(--bulma-text-weak, #7a7a7a); }
633
634.gc-status { display: flex; align-items: center; gap: 0.5rem; color: var(--bulma-text-weak, #7a7a7a); font-size: 0.85rem; }
635.gc-spinner { width: 0.95rem; height: 0.95rem; border: 2px solid currentColor; border-top-color: transparent; border-radius: 50%; display: inline-block; flex: none; animation: gc-spin 0.7s linear infinite; }
636.gc-spinner-sm { width: 0.75rem; height: 0.75rem; border-width: 2px; }
637.gc-step-active .gc-step-marker .gc-spinner { color: var(--bulma-info, #3e8ed0); }
638
639.gc-controls { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }
640.gc-control-group { display: inline-flex; align-items: center; gap: 0.35rem; }
641.gc-control-label { font-size: 0.78rem; color: var(--bulma-text-weak, #7a7a7a); }
642.gc-toggle { display: inline-flex; align-items: center; gap: 0.35rem; font-size: 0.85rem; }
643
644.gc-input-box { border: 1px solid var(--bulma-border, #dbdbdb); border-radius: 0.7rem; background: var(--bulma-scheme-main, #fff); padding: 0.5rem 0.6rem; transition: border-color 0.15s ease, box-shadow 0.15s ease; }
645.gc-input-box:focus-within { border-color: var(--bulma-link, #485fc7); box-shadow: 0 0 0 2px rgba(72, 95, 199, 0.12); }
646.gc-input-box textarea, .gc-input-box .textarea { border: none !important; box-shadow: none !important; background: transparent; padding: 0.1rem; min-height: 3.5rem; resize: vertical; }
647
648.gc-attachments { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-bottom: 0.4rem; }
649.gc-attachment { display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.1rem 0.25rem 0.1rem 0.5rem; border-radius: 999px; background: var(--bulma-scheme-main-bis, #f5f7fa); border: 1px solid var(--bulma-border-weak, #ededed); font-size: 0.76rem; }
650.gc-attachment-kind { font-size: 0.8rem; line-height: 1; }
651.gc-attachment-label { color: var(--bulma-text, #363636); }
652.gc-attachment-remove { cursor: pointer; border: none; background: none; color: var(--bulma-text-weak, #7a7a7a); font-size: 1rem; line-height: 1; padding: 0 0.15rem; }
653.gc-attachment-remove:hover { color: var(--bulma-danger, #f14668); }
654.gc-input-accessory { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem; margin-top: 0.4rem; }
655.gc-context-actions { display: flex; flex-wrap: wrap; gap: 0.15rem; margin-top: 0.3rem; }
656.gc-markdown > :first-child { margin-top: 0; }
657.gc-markdown > :last-child { margin-bottom: 0; }
658.gc-markdown pre { background: var(--bulma-scheme-main-bis, #f5f7fa); border-radius: 0.5rem; padding: 0.6rem 0.75rem; overflow-x: auto; font-size: 0.82rem; }
659.gc-markdown code { font-size: 0.85em; }
660.gc-context-btn { display: inline-flex; align-items: center; gap: 0.3rem; cursor: pointer; border: none; background: transparent; color: var(--bulma-text-weak, #7a7a7a); font-size: 0.76rem; padding: 0.2rem 0.4rem; border-radius: 0.4rem; line-height: 1; transition: background 0.12s ease, color 0.12s ease; }
661.gc-context-btn:hover:not(:disabled) { background: var(--bulma-scheme-main-bis, #f0f1f4); color: var(--bulma-text, #363636); }
662.gc-context-btn:disabled { opacity: 0.5; cursor: default; }
663.gc-diff { border: 1px solid var(--bulma-border-weak, #ededed); border-radius: 0.6rem; overflow: hidden; font-size: 0.8rem; }
664.gc-diff-header { display: flex; align-items: center; gap: 0.6rem; padding: 0.4rem 0.7rem; background: var(--bulma-scheme-main-bis, #f5f7fa); border-bottom: 1px solid var(--bulma-border-weak, #ededed); }
665.gc-diff-file { font-family: monospace; font-weight: 600; color: var(--bulma-text, #363636); }
666.gc-diff-stat-add { color: #257953; font-weight: 600; }
667.gc-diff-stat-del { color: #c81e4b; font-weight: 600; }
668.gc-diff-body { font-family: monospace; padding: 0.3rem 0; overflow-x: auto; }
669.gc-diff-line { display: flex; gap: 0.5rem; padding: 0 0.7rem; white-space: pre; line-height: 1.5; }
670.gc-diff-gutter { width: 0.8rem; text-align: center; flex: none; color: var(--bulma-text-weak, #b5b5b5); user-select: none; }
671.gc-diff-code { flex: 1 1 auto; }
672.gc-diff-added { background: rgba(72, 199, 142, 0.14); box-shadow: inset 2px 0 0 #48c78e; }
673.gc-diff-added .gc-diff-gutter { color: #257953; }
674.gc-diff-removed { background: rgba(241, 70, 104, 0.14); box-shadow: inset 2px 0 0 #f14668; }
675.gc-diff-removed .gc-diff-gutter { color: #c81e4b; }
676.gc-diff-context { color: var(--bulma-text-weak, #7a7a7a); }
677.gc-diff-animate { animation: gc-diff-reveal 0.28s ease both; }
678.gc-diff-animate.gc-diff-added { animation: gc-diff-reveal 0.28s ease both, gc-flash-add 1.1s ease; }
679.gc-diff-animate.gc-diff-removed { animation: gc-diff-reveal 0.28s ease both, gc-flash-remove 1.1s ease; }
680
681.gc-doc-bar { display: flex; align-items: center; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 0.5rem; font-size: 0.8rem; color: var(--bulma-text-weak, #7a7a7a); }
682.gc-docs { display: flex; flex-wrap: wrap; gap: 0.5rem; }
683.gc-doc { position: relative; border: 1px solid var(--bulma-border-weak, #ededed); border-radius: 0.5rem; overflow: hidden; transition: border-color 0.12s ease, box-shadow 0.12s ease; }
684.gc-doc.gc-selected { border-color: var(--bulma-link, #485fc7); box-shadow: 0 0 0 2px rgba(72, 95, 199, 0.25); }
685.gc-doc-select { position: absolute; top: 0.35rem; left: 0.35rem; z-index: 2; width: 1rem; height: 1rem; cursor: pointer; }
686.gc-doc-thumb { cursor: pointer; border: none; background: var(--bulma-scheme-main, #fff); padding: 0; width: 7rem; overflow: hidden; display: flex; flex-direction: column; text-align: left; }
687.gc-doc:hover { border-color: var(--bulma-link, #485fc7); box-shadow: 0 2px 8px rgba(10, 10, 10, 0.08); }
688.gc-doc-preview { height: 4.6rem; display: flex; align-items: center; justify-content: center; background: var(--bulma-scheme-main-bis, #f5f7fa); overflow: hidden; }
689.gc-doc-preview img { width: 100%; height: 100%; object-fit: cover; }
690.gc-doc-icon { font-size: 1.9rem; }
691.gc-doc-name { font-size: 0.72rem; padding: 0.3rem 0.4rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--bulma-text, #363636); }
692
693.gc-lightbox { position: fixed; inset: 0; background: rgba(10, 10, 10, 0.7); display: flex; align-items: center; justify-content: center; padding: 2rem; z-index: 1000; animation: gc-fade-in 0.15s ease; }
694.gc-lightbox-card { background: var(--bulma-scheme-main, #fff); border-radius: 0.6rem; max-width: 90vw; max-height: 90vh; display: flex; flex-direction: column; overflow: hidden; box-shadow: 0 20px 60px rgba(10, 10, 10, 0.4); }
695.gc-lightbox-head { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 0.6rem 0.9rem; border-bottom: 1px solid var(--bulma-border-weak, #ededed); }
696.gc-lightbox-title { font-weight: 600; font-size: 0.9rem; color: var(--bulma-text, #363636); }
697.gc-lightbox-actions { display: flex; align-items: center; gap: 0.75rem; flex: none; }
698.gc-lightbox-download { font-size: 0.82rem; color: var(--bulma-link, #485fc7); white-space: nowrap; }
699.gc-lightbox-close { cursor: pointer; border: none; background: none; font-size: 1.4rem; line-height: 1; color: var(--bulma-text-weak, #7a7a7a); }
700.gc-lightbox-close:hover { color: var(--bulma-text, #363636); }
701.gc-lightbox-body { padding: 0.9rem; overflow: auto; }
702.gc-lightbox-body img { max-width: 100%; max-height: 75vh; display: block; margin: 0 auto; }
703.gc-lightbox-text { font-family: monospace; font-size: 0.8rem; white-space: pre-wrap; margin: 0; }
704.gc-pdf { width: 80vw; max-width: 900px; height: 75vh; border: none; }
705.gc-doc-link { display: inline-block; margin-top: 0.6rem; font-size: 0.85rem; color: var(--bulma-link, #485fc7); }
706
707@keyframes gc-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
708@keyframes gc-fade-in { from { opacity: 0; } to { opacity: 1; } }
709@keyframes gc-spin { to { transform: rotate(360deg); } }
710@keyframes gc-diff-reveal { from { opacity: 0; transform: translateY(-3px); } to { opacity: 1; transform: none; } }
711@keyframes gc-flash-add { 0% { background: rgba(72, 199, 142, 0.55); } 100% { background: rgba(72, 199, 142, 0.14); } }
712@keyframes gc-flash-remove { 0% { background: rgba(241, 70, 104, 0.55); } 100% { background: rgba(241, 70, 104, 0.14); } }
713"#;
714
715#[component]
716pub fn ChatSurface(props: ChatSurfaceProps) -> Element {
717 let title = props
718 .title
719 .clone()
720 .unwrap_or_else(|| "Dioxus GenAI Chat".to_string());
721 let theme = props.theme;
722 let embedded = props.embedded;
723
724 if embedded {
728 return rsx! {
729 style { dangerous_inner_html: CHAT_SURFACE_CSS }
730 div { class: "gc-surface gc-embedded",
731 ChatBody { ..props }
732 }
733 };
734 }
735
736 rsx! {
737 BulmaProvider {
738 theme,
739 load_bulma_css: true,
740 style { dangerous_inner_html: CHAT_SURFACE_CSS }
741 Section {
742 class: "py-5",
743 Container {
744 class: "is-max-desktop",
745 BulmaBox {
746 style: "border-radius: 16px; box-shadow: 0 12px 40px rgba(10, 10, 10, 0.1);",
747 BulmaTitle {
748 size: TitleSize::Is4,
749 "{title}"
750 }
751 ChatBody { ..props }
752 }
753 }
754 }
755 }
756 }
757}
758
759#[cfg(feature = "markdown")]
766fn render_markdown(src: &str) -> String {
767 use pulldown_cmark::{Options, Parser, html};
768 let mut options = Options::empty();
769 options.insert(Options::ENABLE_TABLES);
770 options.insert(Options::ENABLE_STRIKETHROUGH);
771 let parser = Parser::new_ext(src, options);
772 let mut out = String::new();
773 html::push_html(&mut out, parser);
774 out
775}
776
777#[cfg(not(feature = "markdown"))]
778fn render_markdown(src: &str) -> String {
779 let escaped = src
780 .replace('&', "&")
781 .replace('<', "<")
782 .replace('>', ">");
783 format!("<p>{escaped}</p>")
784}
785
786#[component]
789fn ChatBody(props: ChatSurfaceProps) -> Element {
790 let on_context = props.on_context;
791 let on_input = props.on_input;
792 let on_send = props.on_send;
793 let on_stop = props.on_stop;
794 let on_retry = props.on_retry;
795 let on_clear = props.on_clear;
796 let show_context_actions =
797 props.controls.allow_file_attachments || props.controls.allow_directory_context;
798 let input_enabled = props.controls.input_enabled;
799 let send_disabled = !input_enabled || props.input.trim().is_empty();
800
801 rsx! {
802 for (idx, message) in props.transcript.messages.iter().enumerate() {
803 ChatBubble {
804 key: "{idx}",
805 message: message.clone(),
806 on_action: props.on_action,
807 render_document: props.render_document,
808 on_document: props.on_document,
809 document_selectable: props.controls.allow_document_selection,
810 }
811 }
812
813 if props.controls.show_input {
814 div {
815 class: "gc-input-box",
816 if !props.attachments.is_empty() {
817 div {
818 class: "gc-attachments",
819 for item in props.attachments.iter() {
820 {
821 let id = item.id.clone();
822 rsx! {
823 span {
824 key: "{item.id}",
825 class: "gc-attachment",
826 span { class: "gc-attachment-kind", "{context_kind_icon(item.kind)}" }
827 span { class: "gc-attachment-label", "{item.label}" }
828 button {
829 class: "gc-attachment-remove",
830 title: "Remove",
831 onclick: move |_| on_context.call(ContextEvent::Remove(id.clone())),
832 "×"
833 }
834 }
835 }
836 }
837 }
838 }
839 }
840 textarea {
845 class: "textarea",
846 value: "{props.input}",
847 placeholder: "{props.controls.placeholder}",
848 rows: 3,
849 disabled: !input_enabled,
850 oninput: move |evt| on_input.call(evt.value()),
851 onkeydown: {
852 let input = props.input.clone();
853 move |evt: KeyboardEvent| {
854 if input_enabled
855 && evt.key() == Key::Enter
856 && !evt.modifiers().shift()
857 {
858 evt.prevent_default();
859 let text = input.trim();
860 if !text.is_empty() {
861 on_send.call(text.to_string());
862 }
863 }
864 }
865 },
866 }
867 if let Some(accessory) = props.input_accessory.clone() {
868 div { class: "gc-input-accessory", {accessory} }
869 }
870 if show_context_actions {
871 div {
872 class: "gc-context-actions",
873 if props.controls.allow_file_attachments {
874 button {
875 class: "gc-context-btn",
876 disabled: !input_enabled,
877 onclick: move |_| on_context.call(ContextEvent::AddFilesRequested),
878 "{props.controls.attach_files_label}"
879 }
880 }
881 if props.controls.allow_directory_context {
882 button {
883 class: "gc-context-btn",
884 disabled: !input_enabled,
885 onclick: move |_| on_context.call(ContextEvent::AddDirectoryRequested),
886 "{props.controls.add_directory_label}"
887 }
888 }
889 }
890 }
891 }
892 }
893
894 Buttons {
895 if props.controls.show_send_button {
896 Button {
897 color: BulmaColor::Primary,
898 disabled: send_disabled,
899 onclick: {
900 let input = props.input.clone();
901 move |_| {
902 let text = input.trim();
903 if !text.is_empty() {
904 on_send.call(text.to_string());
905 }
906 }
907 },
908 "Send"
909 }
910 }
911 if props.controls.show_stop_button {
912 Button {
913 color: BulmaColor::Warning,
914 outlined: true,
915 onclick: move |_| on_stop.call(()),
916 "Stop"
917 }
918 }
919 if props.controls.show_retry_button {
920 Button {
921 color: BulmaColor::Info,
922 outlined: true,
923 onclick: move |_| on_retry.call(()),
924 "Retry"
925 }
926 }
927 if props.controls.show_clear_button {
928 Button {
929 color: BulmaColor::Danger,
930 outlined: true,
931 onclick: move |_| on_clear.call(()),
932 "Clear"
933 }
934 }
935 }
936 }
937}
938
939#[derive(Props, Clone, PartialEq)]
940struct ChatBubbleProps {
941 message: ChatMessage,
942 on_action: EventHandler<ControlEvent>,
943 #[props(default)]
944 render_document: Option<Callback<Document, Element>>,
945 #[props(default)]
946 on_document: EventHandler<DocumentEvent>,
947 #[props(default)]
948 document_selectable: bool,
949}
950
951#[component]
952fn ChatBubble(props: ChatBubbleProps) -> Element {
953 let role = &props.message.role;
954
955 rsx! {
956 div {
957 class: "gc-msg gc-msg-{role_slug(role)}",
958 div {
959 class: "gc-caption",
960 span { class: "gc-dot" }
961 span { class: "gc-role", "{role_label(role)}" }
962 }
963 div {
964 class: "gc-body",
965 {match &props.message.payload {
966 ChatMessagePayload::Text(content) => rsx! {
967 p { "{content}" }
968 },
969 ChatMessagePayload::Markdown(content) => rsx! {
970 div {
971 class: "gc-markdown content",
972 dangerous_inner_html: render_markdown(content),
973 }
974 },
975 ChatMessagePayload::Reasoning(reasoning) => rsx! {
976 ReasoningPanel { reasoning: reasoning.clone() }
977 },
978 ChatMessagePayload::Status(label) => rsx! {
979 div {
980 class: "gc-status",
981 span { class: "gc-spinner" }
982 span { "{label}" }
983 }
984 },
985 ChatMessagePayload::Controls(controls) => rsx! {
986 ControlBar { controls: controls.clone(), on_action: props.on_action }
987 },
988 ChatMessagePayload::Diff(diff) => rsx! {
989 DiffView { diff: diff.clone() }
990 },
991 ChatMessagePayload::Documents(documents) => rsx! {
992 DocumentGallery {
993 documents: documents.clone(),
994 render_document: props.render_document,
995 on_document: props.on_document,
996 selectable: props.document_selectable,
997 }
998 },
999 ChatMessagePayload::Typing => rsx! {
1000 div {
1001 class: "gc-status",
1002 span { class: "gc-spinner" }
1003 span { "Thinking…" }
1004 }
1005 },
1006 ChatMessagePayload::Error(content) => rsx! {
1007 p { class: "has-text-danger has-text-weight-semibold", "{content}" }
1008 },
1009 ChatMessagePayload::Progress(progress) => rsx! {
1010 div {
1011 p { class: "gc-muted mb-1", "{progress.label}" }
1012 Progress {
1013 color: BulmaColor::Info,
1014 value: progress.percent.clamp(0.0, 100.0),
1015 max: 100.0,
1016 "{progress.percent.clamp(0.0, 100.0).round()}%"
1017 }
1018 }
1019 },
1020 ChatMessagePayload::ToolCall(call) => {
1021 let args = serde_json::to_string_pretty(&call.arguments)
1022 .unwrap_or_else(|_| call.arguments.to_string());
1023 let running = matches!(call.status, ToolCallStatus::Running);
1024 rsx! {
1025 div {
1026 div {
1027 class: "gc-tool-line",
1028 span { class: "gc-tool-name", "{call.name}" }
1029 if running {
1030 span { class: "gc-spinner gc-spinner-sm" }
1031 }
1032 span {
1033 class: "gc-chip gc-chip-{step_slug(tool_step_status(&call.status))}",
1034 "{call.status.as_label()}"
1035 }
1036 }
1037 pre { class: "gc-code", "{args}" }
1038 }
1039 }
1040 }
1041 ChatMessagePayload::ToolResult { name, content } => rsx! {
1042 div {
1043 div {
1044 class: "gc-tool-line",
1045 span { class: "gc-tool-name", "{name}" }
1046 span { class: "gc-chip gc-chip-done", "result" }
1047 }
1048 pre { class: "gc-code", "{content}" }
1049 }
1050 },
1051 }}
1052 }
1053 }
1054 }
1055}
1056
1057#[derive(Props, Clone, PartialEq)]
1058struct ControlBarProps {
1059 controls: Vec<InlineControl>,
1060 on_action: EventHandler<ControlEvent>,
1061}
1062
1063#[component]
1065fn ControlBar(props: ControlBarProps) -> Element {
1066 let on_action = props.on_action;
1067
1068 rsx! {
1069 div {
1070 class: "gc-controls",
1071 for (idx, control) in props.controls.iter().enumerate() {
1072 {match control {
1073 InlineControl::Button { id, label, style, disabled } => {
1074 let id = id.clone();
1075 rsx! {
1076 button {
1077 key: "{idx}",
1078 class: "button is-small {control_style_class(*style)}",
1079 disabled: *disabled,
1080 onclick: move |_| on_action.call(ControlEvent {
1081 id: id.clone(),
1082 value: ControlValue::Clicked,
1083 }),
1084 "{label}"
1085 }
1086 }
1087 }
1088 InlineControl::Select { id, label, options, selected } => {
1089 let id = id.clone();
1090 rsx! {
1091 div {
1092 key: "{idx}",
1093 class: "gc-control-group",
1094 if let Some(label) = label {
1095 span { class: "gc-control-label", "{label}" }
1096 }
1097 div {
1098 class: "select is-small",
1099 select {
1100 onchange: move |evt| on_action.call(ControlEvent {
1101 id: id.clone(),
1102 value: ControlValue::Selected(evt.value()),
1103 }),
1104 for opt in options.iter() {
1105 option {
1106 key: "{opt.value}",
1107 value: "{opt.value}",
1108 selected: selected.as_deref() == Some(opt.value.as_str()),
1109 "{opt.label}"
1110 }
1111 }
1112 }
1113 }
1114 }
1115 }
1116 }
1117 InlineControl::Toggle { id, label, value } => {
1118 let id = id.clone();
1119 rsx! {
1120 label {
1121 key: "{idx}",
1122 class: "checkbox gc-toggle",
1123 input {
1124 r#type: "checkbox",
1125 checked: *value,
1126 onchange: move |evt| on_action.call(ControlEvent {
1127 id: id.clone(),
1128 value: ControlValue::Toggled(evt.checked()),
1129 }),
1130 }
1131 span { "{label}" }
1132 }
1133 }
1134 }
1135 }}
1136 }
1137 }
1138 }
1139}
1140
1141fn control_style_class(style: ControlStyle) -> &'static str {
1142 match style {
1143 ControlStyle::Primary => "is-primary",
1144 ControlStyle::Neutral => "",
1145 ControlStyle::Danger => "is-danger is-light",
1146 ControlStyle::Ghost => "is-ghost",
1147 }
1148}
1149
1150#[derive(Props, Clone, PartialEq)]
1151struct DiffViewProps {
1152 diff: FileDiff,
1153}
1154
1155#[component]
1158fn DiffView(props: DiffViewProps) -> Element {
1159 let diff = &props.diff;
1160 let animate = diff.animate;
1161 let added = diff.lines.iter().filter(|l| l.kind == DiffKind::Added).count();
1162 let removed = diff.lines.iter().filter(|l| l.kind == DiffKind::Removed).count();
1163
1164 rsx! {
1165 div {
1166 class: "gc-diff",
1167 div {
1168 class: "gc-diff-header",
1169 span { class: "gc-diff-file", "{diff.path}" }
1170 span { class: "gc-diff-stat-add", "+{added}" }
1171 span { class: "gc-diff-stat-del", "-{removed}" }
1172 }
1173 div {
1174 class: "gc-diff-body",
1175 for (idx, line) in diff.lines.iter().enumerate() {
1176 {
1177 let kind = line.kind;
1178 let class = if animate {
1179 format!("gc-diff-line gc-diff-{} gc-diff-animate", diff_kind_slug(kind))
1180 } else {
1181 format!("gc-diff-line gc-diff-{}", diff_kind_slug(kind))
1182 };
1183 let style = if animate {
1185 format!("animation-delay: {}ms", idx * 45)
1186 } else {
1187 String::new()
1188 };
1189 rsx! {
1190 div {
1191 key: "{idx}",
1192 class: "{class}",
1193 style: "{style}",
1194 span { class: "gc-diff-gutter", "{diff_sign(kind)}" }
1195 span { class: "gc-diff-code", "{line.content}" }
1196 }
1197 }
1198 }
1199 }
1200 }
1201 }
1202 }
1203}
1204
1205fn diff_kind_slug(kind: DiffKind) -> &'static str {
1206 match kind {
1207 DiffKind::Added => "added",
1208 DiffKind::Removed => "removed",
1209 DiffKind::Context => "context",
1210 }
1211}
1212
1213fn diff_sign(kind: DiffKind) -> &'static str {
1214 match kind {
1215 DiffKind::Added => "+",
1216 DiffKind::Removed => "-",
1217 DiffKind::Context => " ",
1218 }
1219}
1220
1221fn document_icon(kind: DocumentKind) -> &'static str {
1222 match kind {
1223 DocumentKind::Image => "🖼️",
1224 DocumentKind::Pdf => "📕",
1225 DocumentKind::Text => "📄",
1226 DocumentKind::Other => "📎",
1227 DocumentKind::Custom => "🧩",
1228 }
1229}
1230
1231fn text_data_uri(text: &str) -> String {
1233 let mut out = String::from("data:text/plain;charset=utf-8,");
1234 for &b in text.as_bytes() {
1235 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
1236 out.push(b as char);
1237 } else {
1238 out.push('%');
1239 out.push(char::from_digit((b >> 4) as u32, 16).unwrap().to_ascii_uppercase());
1240 out.push(char::from_digit((b & 0x0f) as u32, 16).unwrap().to_ascii_uppercase());
1241 }
1242 }
1243 out
1244}
1245
1246fn download_href(doc: &Document) -> Option<String> {
1248 if let Some(url) = &doc.url {
1249 Some(url.clone())
1250 } else if let Some(image) = &doc.image {
1251 Some(image.clone())
1252 } else {
1253 doc.text.as_deref().map(text_data_uri)
1254 }
1255}
1256
1257#[derive(Props, Clone, PartialEq)]
1258struct DocumentGalleryProps {
1259 documents: Vec<Document>,
1260 #[props(default)]
1261 render_document: Option<Callback<Document, Element>>,
1262 #[props(default)]
1263 on_document: EventHandler<DocumentEvent>,
1264 #[props(default)]
1265 selectable: bool,
1266}
1267
1268#[component]
1275fn DocumentGallery(props: DocumentGalleryProps) -> Element {
1276 let mut expanded = use_signal(|| None::<usize>);
1277 let mut selected = use_signal(std::collections::HashSet::<String>::new);
1278 let documents = props.documents.clone();
1279 let render_document = props.render_document;
1280 let on_document = props.on_document;
1281 let selectable = props.selectable;
1282
1283 let selected_count = selected.read().len();
1284
1285 rsx! {
1286 if selectable && selected_count > 0 {
1287 div {
1288 class: "gc-doc-bar",
1289 span { "{selected_count} selected" }
1290 button {
1291 class: "button is-small is-primary",
1292 onclick: {
1293 let documents = documents.clone();
1294 move |_| {
1295 let sel = selected.read();
1296 let chosen: Vec<Document> =
1297 documents.iter().filter(|d| sel.contains(&d.id)).cloned().collect();
1298 drop(sel);
1299 on_document.call(DocumentEvent::AddToContext(chosen));
1300 selected.write().clear();
1301 }
1302 },
1303 "➕ Add to context"
1304 }
1305 button {
1306 class: "button is-small is-light",
1307 onclick: move |_| selected.write().clear(),
1308 "Clear"
1309 }
1310 }
1311 }
1312 div {
1313 class: "gc-docs",
1314 for (idx, doc) in documents.iter().enumerate() {
1315 div {
1316 key: "{doc.id}",
1317 class: if selected.read().contains(&doc.id) { "gc-doc gc-selected" } else { "gc-doc" },
1318 if selectable {
1319 input {
1320 r#type: "checkbox",
1321 class: "gc-doc-select",
1322 checked: selected.read().contains(&doc.id),
1323 onclick: move |e| e.stop_propagation(),
1324 onchange: {
1325 let id = doc.id.clone();
1326 move |e: FormEvent| {
1327 let mut sel = selected.write();
1328 if e.checked() {
1329 sel.insert(id.clone());
1330 } else {
1331 sel.remove(&id);
1332 }
1333 }
1334 },
1335 }
1336 }
1337 button {
1338 class: "gc-doc-thumb",
1339 title: "{doc.name}",
1340 onclick: move |_| expanded.set(Some(idx)),
1341 div {
1342 class: "gc-doc-preview",
1343 if let Some(src) = &doc.image {
1344 img { src: "{src}", alt: "{doc.name}" }
1345 } else {
1346 span { class: "gc-doc-icon", "{document_icon(doc.kind)}" }
1347 }
1348 }
1349 span { class: "gc-doc-name", "{doc.name}" }
1350 }
1351 }
1352 }
1353 }
1354 if let Some(idx) = expanded() {
1355 if let Some(doc) = documents.get(idx) {
1356 div {
1357 class: "gc-lightbox",
1358 onclick: move |_| expanded.set(None),
1359 div {
1360 class: "gc-lightbox-card",
1361 onclick: move |e| e.stop_propagation(),
1362 div {
1363 class: "gc-lightbox-head",
1364 span { class: "gc-lightbox-title", "{doc.name}" }
1365 div {
1366 class: "gc-lightbox-actions",
1367 if let Some(href) = download_href(doc) {
1368 a {
1369 class: "gc-lightbox-download",
1370 href: "{href}",
1371 download: "{doc.name}",
1372 title: "Download",
1373 "⤓ Download"
1374 }
1375 }
1376 button {
1377 class: "gc-lightbox-close",
1378 title: "Close",
1379 onclick: move |_| expanded.set(None),
1380 "×"
1381 }
1382 }
1383 }
1384 div {
1385 class: "gc-lightbox-body",
1386 if doc.kind == DocumentKind::Custom {
1387 if let Some(cb) = render_document {
1388 {cb.call(doc.clone())}
1389 } else {
1390 p { class: "gc-muted", "No handler registered for this document." }
1391 }
1392 } else if doc.kind == DocumentKind::Pdf {
1393 if let Some(src) = &doc.image {
1394 img { src: "{src}", alt: "{doc.name}" }
1395 } else if let Some(url) = &doc.url {
1396 iframe { class: "gc-pdf", src: "{url}", title: "{doc.name}" }
1397 }
1398 if let Some(url) = &doc.url {
1399 a {
1400 class: "gc-doc-link",
1401 href: "{url}",
1402 target: "_blank",
1403 rel: "noopener",
1404 "Open original ↗"
1405 }
1406 }
1407 } else if let Some(src) = &doc.image {
1408 img { src: "{src}", alt: "{doc.name}" }
1409 } else if let Some(text) = &doc.text {
1410 pre { class: "gc-lightbox-text", "{text}" }
1411 } else if let Some(url) = &doc.url {
1412 a {
1413 class: "gc-doc-link",
1414 href: "{url}",
1415 target: "_blank",
1416 rel: "noopener",
1417 "Open ↗"
1418 }
1419 } else {
1420 p { class: "gc-muted", "No preview available." }
1421 }
1422 }
1423 }
1424 }
1425 }
1426 }
1427 }
1428}
1429
1430#[derive(Props, Clone, PartialEq)]
1431struct ReasoningPanelProps {
1432 reasoning: Reasoning,
1433}
1434
1435#[component]
1437fn ReasoningPanel(props: ReasoningPanelProps) -> Element {
1438 let reasoning = &props.reasoning;
1439
1440 rsx! {
1441 details {
1442 class: "gc-reasoning",
1443 open: !reasoning.collapsed,
1444 summary {
1445 class: "gc-reasoning-summary",
1446 span { class: "gc-chevron" }
1447 span { class: "gc-reasoning-title", "{reasoning.summary}" }
1448 }
1449 ol {
1450 class: "gc-timeline",
1451 for (idx, step) in reasoning.steps.iter().enumerate() {
1452 li {
1453 key: "{idx}",
1454 class: "gc-step gc-step-{step_slug(step.status)}",
1455 span {
1456 class: "gc-step-marker",
1457 if step.status == StepStatus::Active {
1458 span { class: "gc-spinner gc-spinner-sm" }
1459 } else {
1460 "{step_marker(step.status)}"
1461 }
1462 }
1463 div {
1464 class: "gc-step-content",
1465 span { class: "gc-step-title", "{step.title}" }
1466 if let Some(detail) = &step.detail {
1467 span { class: "gc-step-detail", "{detail}" }
1468 }
1469 }
1470 }
1471 }
1472 }
1473 }
1474 }
1475}
1476
1477fn context_kind_icon(kind: ContextKind) -> &'static str {
1478 match kind {
1479 ContextKind::File => "📄",
1480 ContextKind::Directory => "📁",
1481 }
1482}
1483
1484fn role_label(role: &ChatRole) -> &'static str {
1485 match role {
1486 ChatRole::System => "System",
1487 ChatRole::User => "User",
1488 ChatRole::Assistant => "Assistant",
1489 ChatRole::Tool => "Tool",
1490 }
1491}
1492
1493fn role_slug(role: &ChatRole) -> &'static str {
1494 match role {
1495 ChatRole::System => "system",
1496 ChatRole::User => "user",
1497 ChatRole::Assistant => "assistant",
1498 ChatRole::Tool => "tool",
1499 }
1500}
1501
1502fn step_slug(status: StepStatus) -> &'static str {
1503 match status {
1504 StepStatus::Pending => "pending",
1505 StepStatus::Active => "active",
1506 StepStatus::Done => "done",
1507 StepStatus::Failed => "failed",
1508 }
1509}
1510
1511fn step_marker(status: StepStatus) -> &'static str {
1512 match status {
1513 StepStatus::Pending => "○",
1514 StepStatus::Active => "●",
1515 StepStatus::Done => "✓",
1516 StepStatus::Failed => "✕",
1517 }
1518}
1519
1520fn tool_step_status(status: &ToolCallStatus) -> StepStatus {
1521 match status {
1522 ToolCallStatus::Pending => StepStatus::Pending,
1523 ToolCallStatus::Running => StepStatus::Active,
1524 ToolCallStatus::Completed => StepStatus::Done,
1525 ToolCallStatus::Failed => StepStatus::Failed,
1526 }
1527}
1528
1529pub fn sample_transcript() -> ChatTranscript {
1530 let mut transcript = ChatTranscript::default();
1531
1532 transcript.push(
1533 ChatRole::System,
1534 ChatMessagePayload::Text("You are an expert Rust assistant.".to_string()),
1535 );
1536 transcript.push(
1537 ChatRole::User,
1538 ChatMessagePayload::Text("Summarize the latest telemetry report.".to_string()),
1539 );
1540 transcript.push(
1541 ChatRole::Assistant,
1542 ChatMessagePayload::Reasoning(Reasoning {
1543 summary: "Worked through 3 steps".to_string(),
1544 collapsed: false,
1545 steps: vec![
1546 ReasoningStep::new("Understood the request", StepStatus::Done)
1547 .with_detail("Summarize the last 24h of telemetry"),
1548 ReasoningStep::new("Decided to fetch the report", StepStatus::Done)
1549 .with_detail("Tool: fetch_report(source = telemetry)"),
1550 ReasoningStep::new("Waiting on tool output", StepStatus::Active),
1551 ],
1552 }),
1553 );
1554 transcript.push(
1555 ChatRole::Assistant,
1556 ChatMessagePayload::ToolCall(ToolCall {
1557 name: "fetch_report".to_string(),
1558 arguments: serde_json::json!({"source": "telemetry", "period": "24h"}),
1559 status: ToolCallStatus::Running,
1560 }),
1561 );
1562 transcript.push(
1563 ChatRole::Tool,
1564 ChatMessagePayload::Progress(ProgressState {
1565 label: "Fetching report data".to_string(),
1566 percent: 74.0,
1567 }),
1568 );
1569 transcript.push(
1570 ChatRole::Tool,
1571 ChatMessagePayload::ToolResult {
1572 name: "fetch_report".to_string(),
1573 content: "Error rate dropped by 14% while latency remained stable.".to_string(),
1574 },
1575 );
1576 transcript.push(
1577 ChatRole::Tool,
1578 ChatMessagePayload::Status("Generating summary…".to_string()),
1579 );
1580 transcript.push(
1581 ChatRole::Assistant,
1582 ChatMessagePayload::Markdown(
1583 "### Summary\n- Error rate improved by **14%**\n- Latency remained stable".to_string(),
1584 ),
1585 );
1586 transcript.push(
1587 ChatRole::Assistant,
1588 ChatMessagePayload::Diff(FileDiff {
1589 path: "src/alerts.rs".to_string(),
1590 animate: true,
1591 lines: vec![
1592 DiffLine::context("fn alert_threshold() -> f32 {"),
1593 DiffLine::removed(" 0.20 // 20% error rate"),
1594 DiffLine::added(" 0.14 // tightened after telemetry review"),
1595 DiffLine::context("}"),
1596 ],
1597 }),
1598 );
1599 transcript.push(
1600 ChatRole::Assistant,
1601 ChatMessagePayload::Documents(vec![
1602 Document::image(
1603 "doc-diagram",
1604 "diagram.svg",
1605 "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNjAiIGhlaWdodD0iMTIwIj48cmVjdCB3aWR0aD0iMTYwIiBoZWlnaHQ9IjEyMCIgZmlsbD0iIzQ4NWZjNyIvPjxjaXJjbGUgY3g9IjgwIiBjeT0iNTUiIHI9IjI4IiBmaWxsPSIjZmZkNTdlIi8+PHJlY3QgeT0iOTIiIHdpZHRoPSIxNjAiIGhlaWdodD0iMjgiIGZpbGw9IiMzYTRmYjAiLz48dGV4dCB4PSI4MCIgeT0iMTExIiBmb250LWZhbWlseT0ic2Fucy1zZXJpZiIgZm9udC1zaXplPSIxMyIgZmlsbD0iI2ZmZiIgdGV4dC1hbmNob3I9Im1pZGRsZSI+ZGlhZ3JhbS5zdmc8L3RleHQ+PC9zdmc+",
1606 ),
1607 Document::text(
1608 "doc-report",
1609 "report.md",
1610 "# Telemetry report\n\n- Error rate: 0.14 (was 0.20)\n- Latency p95: stable\n- Window: last 24h",
1611 ),
1612 Document::pdf("doc-pdf", "report.pdf", "https://example.com/report.pdf").with_image(
1613 "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNjAiIGhlaWdodD0iMjAwIj48cmVjdCB3aWR0aD0iMTYwIiBoZWlnaHQ9IjIwMCIgZmlsbD0iI2ZmZiIgc3Ryb2tlPSIjZGJkYmRiIi8+PHJlY3QgeD0iMTYiIHk9IjIwIiB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyIiBmaWxsPSIjZjE0NjY4Ii8+PHJlY3QgeD0iMTYiIHk9IjQ4IiB3aWR0aD0iMTI4IiBoZWlnaHQ9IjgiIGZpbGw9IiNkYmRiZGIiLz48cmVjdCB4PSIxNiIgeT0iNjQiIHdpZHRoPSIxMTAiIGhlaWdodD0iOCIgZmlsbD0iI2RiZGJkYiIvPjxyZWN0IHg9IjE2IiB5PSI4MCIgd2lkdGg9IjEyMCIgaGVpZ2h0PSI4IiBmaWxsPSIjZGJkYmRiIi8+PHRleHQgeD0iODAiIHk9IjE3MCIgZm9udC1mYW1pbHk9InNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iMTYiIGZpbGw9IiNmMTQ2NjgiIHRleHQtYW5jaG9yPSJtaWRkbGUiPlBERjwvdGV4dD48L3N2Zz4=",
1614 ),
1615 Document::custom(
1616 "doc-loc",
1617 "location.geo",
1618 serde_json::json!({ "coords": "47.6062° N, 122.3321° W" }),
1619 ),
1620 ]),
1621 );
1622 transcript.push(
1623 ChatRole::Assistant,
1624 ChatMessagePayload::Controls(vec![
1625 InlineControl::Button {
1626 id: "accept".to_string(),
1627 label: "Keep summary".to_string(),
1628 style: ControlStyle::Primary,
1629 disabled: false,
1630 },
1631 InlineControl::Button {
1632 id: "retry".to_string(),
1633 label: "Regenerate".to_string(),
1634 style: ControlStyle::Neutral,
1635 disabled: false,
1636 },
1637 InlineControl::Select {
1638 id: "detail".to_string(),
1639 label: Some("Detail".to_string()),
1640 options: vec![
1641 SelectOption::new("brief", "Brief"),
1642 SelectOption::new("normal", "Normal"),
1643 SelectOption::new("verbose", "Verbose"),
1644 ],
1645 selected: Some("normal".to_string()),
1646 },
1647 InlineControl::Toggle {
1648 id: "cite_sources".to_string(),
1649 label: "Cite sources".to_string(),
1650 value: true,
1651 },
1652 ]),
1653 );
1654
1655 transcript
1656}
1657
1658#[cfg(test)]
1659mod tests {
1660 use super::*;
1661 #[cfg(feature = "genai")]
1662 use genai::chat::ChatRole as GenAiRole;
1663 use pretty_assertions::assert_eq;
1664
1665 #[test]
1666 fn chat_controls_default_to_enabled() {
1667 let controls = ChatControls::default();
1668
1669 assert!(controls.show_input);
1670 assert!(controls.show_send_button);
1671 assert!(controls.show_stop_button);
1672 assert!(controls.show_retry_button);
1673 assert!(controls.show_clear_button);
1674 assert!(controls.input_enabled);
1675 assert_eq!(controls.placeholder, "Ask anything, or invoke a tool...");
1676 }
1677
1678 #[cfg(feature = "genai")]
1679 #[test]
1680 fn transcript_to_genai_request_maps_roles_and_tool_events() {
1681 let mut transcript = ChatTranscript::default();
1682 transcript.push(
1683 ChatRole::System,
1684 ChatMessagePayload::Text("Be concise".to_string()),
1685 );
1686 transcript.push(
1687 ChatRole::User,
1688 ChatMessagePayload::Text("Hello".to_string()),
1689 );
1690 transcript.push(
1691 ChatRole::Assistant,
1692 ChatMessagePayload::Text("Hi".to_string()),
1693 );
1694 transcript.push(
1695 ChatRole::Assistant,
1696 ChatMessagePayload::ToolCall(ToolCall {
1697 name: "lookup".to_string(),
1698 arguments: serde_json::json!({"q": "status"}),
1699 status: ToolCallStatus::Completed,
1700 }),
1701 );
1702 transcript.push(
1703 ChatRole::Tool,
1704 ChatMessagePayload::ToolResult {
1705 name: "lookup".to_string(),
1706 content: "All systems healthy".to_string(),
1707 },
1708 );
1709 transcript.push(
1710 ChatRole::Tool,
1711 ChatMessagePayload::Progress(ProgressState {
1712 label: "Unused in request".to_string(),
1713 percent: 50.0,
1714 }),
1715 );
1716
1717 let request = transcript.to_genai_request();
1718
1719 assert_eq!(request.system, Some("Be concise".to_string()));
1720 assert_eq!(request.messages.len(), 4);
1721 assert!(matches!(request.messages[0].role, GenAiRole::User));
1722 assert!(matches!(request.messages[1].role, GenAiRole::Assistant));
1723 assert!(matches!(request.messages[2].role, GenAiRole::Assistant));
1724 assert!(matches!(request.messages[3].role, GenAiRole::Tool));
1725 }
1726
1727 #[test]
1728 fn sample_transcript_includes_tooling_and_progress() {
1729 let transcript = sample_transcript();
1730
1731 assert!(
1732 transcript
1733 .messages
1734 .iter()
1735 .any(|m| matches!(m.payload, ChatMessagePayload::ToolCall(_)))
1736 );
1737 assert!(
1738 transcript
1739 .messages
1740 .iter()
1741 .any(|m| matches!(m.payload, ChatMessagePayload::Progress(_)))
1742 );
1743 assert!(
1744 transcript
1745 .messages
1746 .iter()
1747 .any(|m| matches!(m.payload, ChatMessagePayload::ToolResult { .. }))
1748 );
1749 }
1750}