Skip to main content

dioxus_genai_chat/
lib.rs

1//! A configurable [Dioxus] + [Bulma] chat UI.
2//!
3//! The crate provides a [`ChatSurface`] component plus a small data model
4//! ([`ChatTranscript`], [`ChatMessage`], [`ChatMessagePayload`]) for rendering
5//! chat conversations, including:
6//!
7//! - chained, collapsible reasoning timelines ([`Reasoning`]),
8//! - inline message controls — buttons, selectors, toggles ([`InlineControl`]),
9//!   surfaced through [`ChatSurface`]'s `on_action` handler,
10//! - spinning status indicators, tool calls, progress, and errors.
11//!
12//! The composer is **controlled**: bind [`ChatSurfaceProps::input`] and handle
13//! `on_send`/`on_stop`/`on_retry`/`on_clear`. Set [`ChatSurfaceProps::embedded`]
14//! to host the surface inside an app that already provides Bulma and a theme,
15//! and inject app-specific composer controls via
16//! [`ChatSurfaceProps::input_accessory`].
17//!
18//! With the default `genai` feature enabled, [`ChatTranscript::to_genai_request`]
19//! converts a transcript into a [`genai`] chat request. Disable default features
20//! to build for `wasm32-unknown-unknown` (the web target), where `genai` is not
21//! available. The `markdown` feature (on by default, pure Rust) renders
22//! [`ChatMessagePayload::Markdown`] to HTML.
23//!
24//! [Dioxus]: https://dioxuslabs.com/
25//! [Bulma]: https://bulma.io/
26//! [`genai`]: https://docs.rs/genai/
27
28use 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/// Lifecycle of a single step inside a chained reasoning trace.
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
77pub enum StepStatus {
78    /// Not started yet.
79    Pending,
80    /// Currently running.
81    Active,
82    /// Finished successfully.
83    Done,
84    /// Finished with an error.
85    Failed,
86}
87
88/// A single phase in a chained "thinking" trace (à la VS Code agent steps).
89#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
90pub struct ReasoningStep {
91    pub title: String,
92    /// Optional secondary line (e.g. a file path, a short result).
93    #[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/// A collapsible group of chained reasoning steps shown as a connected timeline.
110#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
111pub struct Reasoning {
112    /// One-line summary shown on the collapsed header (e.g. "Thought for 4s").
113    pub summary: String,
114    pub steps: Vec<ReasoningStep>,
115    /// Whether the panel starts collapsed.
116    #[serde(default)]
117    pub collapsed: bool,
118}
119
120/// Visual emphasis for an inline control.
121#[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/// An option in an inline [`InlineControl::Select`].
131#[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/// A small interactive element rendered inline inside a message.
144///
145/// Interactions are surfaced through the [`ChatSurface`] `on_action` handler;
146/// the component is "controlled", so update the transcript in response to events.
147#[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/// The kind of interaction that produced a [`ControlEvent`].
174#[derive(Debug, Clone, PartialEq)]
175pub enum ControlValue {
176    /// A button was clicked.
177    Clicked,
178    /// A select changed to the given option value.
179    Selected(String),
180    /// A toggle changed to the given state.
181    Toggled(bool),
182}
183
184/// Emitted when the user interacts with an [`InlineControl`].
185#[derive(Debug, Clone, PartialEq)]
186pub struct ControlEvent {
187    /// The `id` of the control that was interacted with.
188    pub id: String,
189    pub value: ControlValue,
190}
191
192/// Whether a piece of attached context is a file or a directory.
193#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
194pub enum ContextKind {
195    File,
196    Directory,
197}
198
199/// A piece of context (a file or directory) attached to the next message.
200///
201/// The pending list is owned by the caller and passed to [`ChatSurface`] via
202/// `attachments`; the component renders it and emits [`ContextEvent`]s. How files
203/// and directories are actually chosen (native dialog, browser input, typed path)
204/// is up to the caller — see the `on_context` handler.
205#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
206pub struct ContextItem {
207    /// Stable id used when the user removes the item.
208    pub id: String,
209    /// Display label, e.g. a file name or directory path.
210    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    /// A file context item derived from a [`Document`] (id and name carried over).
224    pub fn from_document(doc: &Document) -> Self {
225        Self::file(doc.id.clone(), doc.name.clone())
226    }
227}
228
229/// Emitted when the user adds or removes context via the input area.
230///
231/// Handle this in [`ChatSurface`]'s `on_context` and update the `attachments`
232/// list you pass back in (a controlled component, like [`InlineControl`]).
233#[derive(Debug, Clone, PartialEq)]
234pub enum ContextEvent {
235    /// The user asked to attach file(s); open a picker and add [`ContextItem`]s.
236    AddFilesRequested,
237    /// The user asked to add a directory as context.
238    AddDirectoryRequested,
239    /// The user removed the attached item with this `id`.
240    Remove(String),
241}
242
243/// The role of a single line in a [`FileDiff`].
244#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
245pub enum DiffKind {
246    /// An added line (rendered green, prefixed `+`).
247    Added,
248    /// A removed line (rendered red, prefixed `-`).
249    Removed,
250    /// An unchanged context line.
251    Context,
252}
253
254/// A single line within a unified [`FileDiff`].
255#[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/// A unified diff for a single file, rendered with an apply animation.
276#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
277pub struct FileDiff {
278    /// File path shown in the diff header.
279    pub path: String,
280    pub lines: Vec<DiffLine>,
281    /// When `true`, changed lines stream in (staggered) and flash on apply.
282    /// Set `false` to render the final state with no animation.
283    #[serde(default = "default_true")]
284    pub animate: bool,
285}
286
287fn default_true() -> bool {
288    true
289}
290
291/// The type of a [`Document`], used to pick an icon when there is no image preview.
292#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
293pub enum DocumentKind {
294    Image,
295    Pdf,
296    Text,
297    Other,
298    /// Rendered by a caller-provided handler — see [`ChatSurface`]'s
299    /// `render_document`.
300    Custom,
301}
302
303/// A document shown as a thumbnail that expands to a full view on click.
304///
305/// Built-in full views: image (`image`), PDF (`url`, with an optional `image`
306/// page preview), and text (`text`). For anything else, set `kind` to
307/// [`DocumentKind::Custom`] and provide a `render_document` handler on the
308/// [`ChatSurface`]; `data` carries an arbitrary payload for that handler.
309#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
310pub struct Document {
311    pub id: String,
312    /// Display name, e.g. "diagram.png".
313    pub name: String,
314    pub kind: DocumentKind,
315    /// Image source (URL or data URI): thumbnail + enlarged image, or a PDF page
316    /// preview.
317    #[serde(default)]
318    pub image: Option<String>,
319    /// Source URL (PDF, downloadable original, custom resource).
320    #[serde(default)]
321    pub url: Option<String>,
322    /// Inline text content shown in the expanded view (for text/code documents).
323    #[serde(default)]
324    pub text: Option<String>,
325    /// Arbitrary payload for a custom handler to interpret.
326    #[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    /// An image document: the same `src` is used for the thumbnail and full view.
344    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    /// A text document: shown as an icon thumbnail that expands to the content.
349    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    /// A PDF document. `url` points at the file; set [`Document::image`]-style
354    /// `image` too for a page preview where inline PDF rendering is unavailable.
355    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    /// A custom document rendered by a `render_document` handler. `data` is passed
360    /// through for the handler to interpret.
361    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    /// Attach a page-preview image (e.g. for a PDF).
366    pub fn with_image(mut self, src: impl Into<String>) -> Self {
367        self.image = Some(src.into());
368        self
369    }
370}
371
372/// Emitted when the user acts on documents in a gallery.
373///
374/// Handle this in [`ChatSurface`]'s `on_document`. Document selection requires
375/// `ChatControls::allow_document_selection`; downloads use a native link and do
376/// not emit an event.
377#[derive(Debug, Clone, PartialEq)]
378pub enum DocumentEvent {
379    /// The user selected one or more documents and asked to add them to the chat
380    /// context. Convert them (e.g. via [`ContextItem::from_document`]) and append
381    /// to the `attachments` list.
382    AddToContext(Vec<Document>),
383}
384
385#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
386pub enum ChatMessagePayload {
387    Text(String),
388    Markdown(String),
389    /// A chained, collapsible reasoning/thinking trace.
390    Reasoning(Reasoning),
391    ToolCall(ToolCall),
392    ToolResult { name: String, content: String },
393    Progress(ProgressState),
394    /// A spinning status line with a custom label (e.g. "Running tests…").
395    Status(String),
396    /// A row of inline controls (buttons / selectors / toggles).
397    Controls(Vec<InlineControl>),
398    /// A unified file diff, optionally animated as it is applied.
399    Diff(FileDiff),
400    /// A gallery of document thumbnails that expand to a full view.
401    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    /// Show the "attach files" affordance. Off by default — enabling it requires
493    /// wiring [`ChatSurface`]'s `on_context` handler to do the picking.
494    pub allow_file_attachments: bool,
495    /// Show the "add directory" affordance.
496    pub allow_directory_context: bool,
497    /// Label for the attach-files button.
498    pub attach_files_label: String,
499    /// Label for the add-directory button.
500    pub add_directory_label: String,
501    /// Show selection checkboxes on document thumbnails plus an "add to context"
502    /// bar. Off by default — enabling it requires wiring `on_document`.
503    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    /// Embed the surface inside a host app instead of rendering a standalone
535    /// page. When `true`, the component does **not** wrap itself in a
536    /// [`BulmaProvider`] (so it pulls no Bulma CSS and applies no theme of its
537    /// own — the host owns both) and drops the `Section`/`Container`/`Box`/title
538    /// page chrome, rendering the transcript and composer directly. The scoped
539    /// `.gc-*` styles are still injected either way.
540    #[props(default)]
541    pub embedded: bool,
542    /// The current value of the composer text input (controlled, like
543    /// [`transcript`](Self::transcript) and [`attachments`](Self::attachments)).
544    /// Update it from [`on_input`](Self::on_input).
545    #[props(default)]
546    pub input: String,
547    /// Fired on every keystroke in the composer with the new input value.
548    #[props(default)]
549    pub on_input: EventHandler<String>,
550    /// Fired when the user submits the composer (Send button or Enter). Carries
551    /// the current input text; clear [`input`](Self::input) in response.
552    #[props(default)]
553    pub on_send: EventHandler<String>,
554    /// Fired when the user clicks Stop to interrupt an in-flight response.
555    #[props(default)]
556    pub on_stop: EventHandler<()>,
557    /// Fired when the user clicks Retry.
558    #[props(default)]
559    pub on_retry: EventHandler<()>,
560    /// Fired when the user clicks Clear.
561    #[props(default)]
562    pub on_clear: EventHandler<()>,
563    /// Optional caller-supplied controls rendered inside the composer (e.g. a
564    /// model picker or a working-directory selector). App-specific affordances
565    /// live here rather than in the crate.
566    #[props(default)]
567    pub input_accessory: Option<Element>,
568    /// Fired when the user interacts with an inline [`InlineControl`].
569    #[props(default)]
570    pub on_action: EventHandler<ControlEvent>,
571    /// Context (files/directories) currently attached to the next message.
572    /// Owned by the caller; render-only here.
573    #[props(default)]
574    pub attachments: Vec<ContextItem>,
575    /// Fired when the user adds or removes context via the input area.
576    #[props(default)]
577    pub on_context: EventHandler<ContextEvent>,
578    /// Custom renderer for the expanded (full) view of [`DocumentKind::Custom`]
579    /// documents. Receives the [`Document`] and returns the element to show in the
580    /// lightbox. Without it, custom documents show a "no handler" placeholder.
581    #[props(default)]
582    pub render_document: Option<Callback<Document, Element>>,
583    /// Fired when the user adds selected documents to the context. Requires
584    /// `ChatControls::allow_document_selection`.
585    #[props(default)]
586    pub on_document: EventHandler<DocumentEvent>,
587}
588
589/// Scoped styling for the compact captions and the chained reasoning timeline.
590/// Colors are pulled from Bulma CSS variables so it adapts to light/dark themes.
591const 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    // The scoped `.gc-*` styles are needed in both modes. In embedded mode the
725    // host app owns Bulma + theming, so we skip `BulmaProvider` (no CDN CSS, no
726    // theme wrapper) and the standalone page chrome.
727    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/// Render a Markdown payload to an HTML string.
760///
761/// With the `markdown` feature (on by default) this parses CommonMark (plus
762/// tables and strikethrough) via `pulldown-cmark`. Without it, the source is
763/// HTML-escaped and wrapped in a paragraph so raw markup is shown verbatim
764/// and never interpreted as HTML.
765#[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('&', "&amp;")
781        .replace('<', "&lt;")
782        .replace('>', "&gt;");
783    format!("<p>{escaped}</p>")
784}
785
786/// The transcript + composer, with no page chrome. Rendered directly in
787/// embedded mode, and inside the standalone `Box` otherwise.
788#[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                // Native textarea (not the Bulma component) so we can bind a
841                // controlled value and handle Enter-to-send via `onkeydown`.
842                // Enter submits the controlled `input` value (the keydown event
843                // itself carries no text); Shift+Enter inserts a newline.
844                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/// A row of small inline controls (buttons, selectors, toggles).
1064#[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/// Renders a unified file diff. When `diff.animate` is set, changed lines stream
1156/// in top-to-bottom (staggered) and flash green/red as they are "applied".
1157#[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                        // Stagger the reveal so the diff appears to apply top-to-bottom.
1184                        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
1231/// Percent-encode text into a `data:` URI so it can be downloaded via `<a download>`.
1232fn 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
1246/// Resolve a downloadable source for a document, if any.
1247fn 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/// A row of document thumbnails. Clicking one opens a full-view lightbox with a
1269/// download button; with `selectable`, thumbnails get checkboxes and an
1270/// "add to context" bar.
1271///
1272/// The expanded index and selection set are local view state (like a native
1273/// `<details>` toggle) — not part of the transcript — so they live in signals.
1274#[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/// VS Code-style collapsible chain of reasoning steps rendered as a timeline.
1436#[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}