use std::borrow::Cow;
use super::theme::{color, radius, space, surface, typeface};
use crate::context::{PresentationAction, PresentationContext};
use bcode_session_view_models::{
ChatMessageView, SessionViewSnapshot, TextFormat, TranscriptViewItemKind,
};
use hyperchad::template::{Containers, container};
use super::adapters::{json_panel, render_tool_contribution};
use super::components::{
MessageArticle, conversation_timeline, disclosure, empty_state, message_article,
truncation_notice,
};
use super::permissions::permission_history;
use super::semantic_dom_id;
use super::tools::render_tool_lifecycle_with_context;
const MAX_INLINE_MESSAGE_CHARS: usize = 100_000;
fn bounded_message_text(text: &str) -> (Cow<'_, str>, bool) {
let Some((byte_index, _)) = text.char_indices().nth(MAX_INLINE_MESSAGE_CHARS) else {
return (Cow::Borrowed(text), false);
};
(Cow::Owned(text[..byte_index].to_owned()), true)
}
pub(super) fn is_superseded_tool_request(
items: &[bcode_session_view_models::TranscriptViewItem],
index: usize,
) -> bool {
let Some(tool_call_id) = (match &items[index].kind {
TranscriptViewItemKind::ToolRequest { tool } => Some(tool.tool_call_id.as_str()),
_ => None,
}) else {
return false;
};
items[index + 1..].iter().any(|item| {
matches!(
&item.kind,
TranscriptViewItemKind::ToolInvocation { tool }
if tool.tool_call_id == tool_call_id
)
})
}
pub(super) fn transcript_section(
snapshot: &SessionViewSnapshot,
context: &impl PresentationContext,
) -> Containers {
conversation_timeline(&container! {
@if snapshot.transcript.has_older_history {
@if let (Some(session_id), Some(anchor_sequence)) = (snapshot.session_id, snapshot.transcript.source_start_sequence) {
form hx-post=(context.action_target(PresentationAction::MoveHistoryWindow)) hx-target="#bcode-web-shell" hx-swap=this margin-bottom=((space::MD)) {
input type=hidden name="session_id" value=(session_id.to_string());
input type=hidden name="direction" value="older";
input type=hidden name="anchor_sequence" value=(anchor_sequence.to_string());
button type=submit background=(surface::CONTROL) color=(color::INFO) border=((1, surface::BORDER)) border-radius=((radius::CONTROL)) padding="10, 14" { "load older history" }
}
}
}
@if snapshot.transcript.items.is_empty() {
(empty_state(if snapshot.session_id.is_some() {
"This session has no conversation entries in the current history view."
} else {
"Attach or create a session to begin."
}))
} @else {
@for (index, item) in snapshot.transcript.items.iter().enumerate() {
@if should_render_transcript_item(item)
&& !is_superseded_tool_request(&snapshot.transcript.items, index)
&& !is_active_interaction_summary(item, &snapshot.interactions) {
(transcript_item_with_context(item, snapshot.session_id, context))
}
}
}
@if snapshot.transcript.has_newer_history {
@if let (Some(session_id), Some(anchor_sequence)) = (snapshot.session_id, snapshot.transcript.source_end_sequence) {
form hx-post=(context.action_target(PresentationAction::MoveHistoryWindow)) hx-target="#bcode-web-shell" hx-swap=this margin-top=((space::MD)) {
input type=hidden name="session_id" value=(session_id.to_string());
input type=hidden name="direction" value="newer";
input type=hidden name="anchor_sequence" value=(anchor_sequence.to_string());
button type=submit background=(surface::CONTROL) color=(color::INFO) border=((1, surface::BORDER)) border-radius=((radius::CONTROL)) padding="10, 14" { "load newer history" }
}
}
}
})
}
pub(super) const fn should_render_transcript_item(
_item: &bcode_session_view_models::TranscriptViewItem,
) -> bool {
true
}
pub(super) fn is_active_interaction_summary(
item: &bcode_session_view_models::TranscriptViewItem,
active_interactions: &[bcode_session_view_models::InteractionViewSummary],
) -> bool {
let TranscriptViewItemKind::Interaction { interaction } = &item.kind else {
return false;
};
!interaction.resolved
&& active_interactions
.iter()
.any(|active| active.interaction_id == interaction.interaction_id)
}
#[cfg(test)]
pub(super) fn transcript_item(item: &bcode_session_view_models::TranscriptViewItem) -> Containers {
transcript_item_with_context(item, None, &crate::context::StaticPresentationContext)
}
fn transcript_item_with_context(
item: &bcode_session_view_models::TranscriptViewItem,
session_id: Option<bcode_session_models::SessionId>,
context: &impl PresentationContext,
) -> Containers {
let (background, accent, margin_left, margin_right) = match &item.kind {
TranscriptViewItemKind::UserMessage { .. } => (surface::USER_MESSAGE, color::INFO, 48, 0),
TranscriptViewItemKind::AssistantMessage { .. } => (surface::APP, color::SUCCESS, 0, 48),
TranscriptViewItemKind::ReasoningMessage { .. }
| TranscriptViewItemKind::ReasoningActivity { .. } => {
(surface::PANEL, color::REASONING, 24, 24)
}
TranscriptViewItemKind::SystemMessage { .. }
| TranscriptViewItemKind::Compaction { .. }
| TranscriptViewItemKind::Skill { .. } => (surface::PANEL, color::MUTED, 24, 24),
_ => (surface::APP, surface::DISABLED, 0, 0),
};
let item_id = semantic_dom_id("transcript-item", item.id.get());
let developer_detail = disclosure(
"developer details",
&container! {
div color=(color::MUTED) font-size=((typeface::DETAIL)) {
"item " (item.id.get().to_string()) " · revision " (item.revision.to_string())
@if let Some(sequence) = item.sequence {
" · event " (sequence.to_string())
}
@if let Some(timestamp_ms) = item.timestamp_ms {
" · timestamp " (timestamp_ms.to_string())
}
}
},
);
message_article(&MessageArticle {
id: &item_id,
label: item_label(&item.kind),
streaming: item.streaming,
background,
accent,
margins: (margin_left, margin_right),
content: &transcript_item_body_with_context(&item.kind, session_id, context),
developer_detail: &developer_detail,
})
}
pub(super) fn message_content(message: &ChatMessageView) -> Containers {
if message.text.is_empty() {
return container! {
div color=(color::MUTED) { "Empty message" }
};
}
let (text, truncated) = bounded_message_text(&message.text);
let content = match message.format {
TextFormat::Markdown => vec![hyperchad::markdown::markdown_to_container(&text)],
TextFormat::PlainText | TextFormat::Json => container! {
div white-space="preserve-wrap" margin=0 color=(color::TEXT) { (text) }
},
};
container! {
div {
(content)
@if truncated {
(truncation_notice("Message truncated for display."))
}
@if let Some(activity) = &message.activity {
(disclosure("Activity details", &container! {
div white-space="preserve-wrap" color=(color::MUTED) {
"Execution: " (&activity.execution.execution_id)
" · unit: " (&activity.execution.unit_id)
" · attempt: " (activity.execution.attempt.to_string())
}
div color=(color::MUTED) {
"Canonical instructions: session event " (activity.source_sequence.to_string())
". The compact message is a preview, not the exact submitted instructions."
}
}))
}
}
}
}
fn compaction_notice(compaction: &bcode_session_view_models::CompactionView) -> Containers {
container! {
aside color=(color::MUTED) {
div font-size=((typeface::DETAIL)) margin-bottom=((space::XS)) {
(match compaction.status {
bcode_session_view_models::CompactionViewStatus::Local => "Local context compacted",
bcode_session_view_models::CompactionViewStatus::Provider => "Provider context compacted",
})
}
div white-space="preserve-wrap" margin=0 { (compaction.text) }
@if let Some(provider) = &compaction.provider_plugin_id { div font-size=((typeface::DETAIL)) margin-top=((space::XS)) { "provider: " (provider) } }
@if let Some(model) = &compaction.model_id { div font-size=((typeface::DETAIL)) margin-top=((space::S2)) { "model: " (model) } }
}
}
}
fn interaction_notice(
interaction: &bcode_session_view_models::InteractionViewSummary,
) -> Containers {
let status = if interaction.resolved {
"resolved"
} else {
"pending"
};
container! {
aside {
div justify-content=space-between gap=((space::SM)) {
div color=(color::STRONG) { (interaction.title.as_deref().unwrap_or("Interactive request")) }
div color=(if interaction.resolved { color::MUTED } else { color::WARNING }) font-size=((typeface::DETAIL)) { (status) }
}
div color=(color::MUTED) font-size=((typeface::LABEL)) { (interaction.kind) @if interaction.required { " · required" } }
@if let Some(resolution) = &interaction.resolution {
(json_panel(
"resolution",
&serde_json::to_value(resolution).unwrap_or(serde_json::Value::Null),
))
} @else if let Some(snapshot) = &interaction.snapshot {
(json_panel("snapshot", snapshot))
}
}
}
}
fn skill_notice(skill: &bcode_session_view_models::SkillView) -> Containers {
container! {
aside color=(if matches!(skill.status, bcode_session_view_models::SkillViewStatus::Failed) { color::ERROR } else { color::MUTED }) {
div font-size=((typeface::DETAIL)) margin-bottom=((space::XS)) { (item_label(&TranscriptViewItemKind::Skill { skill: skill.clone() })) }
div white-space="preserve-wrap" margin=0 { (skill.text) }
}
}
}
#[cfg(test)]
pub(super) fn transcript_item_body(kind: &TranscriptViewItemKind) -> Containers {
transcript_item_body_with_context(kind, None, &crate::context::StaticPresentationContext)
}
fn transcript_item_body_with_context(
kind: &TranscriptViewItemKind,
session_id: Option<bcode_session_models::SessionId>,
context: &impl PresentationContext,
) -> Containers {
match kind {
TranscriptViewItemKind::UserMessage { message } => message_content(message),
TranscriptViewItemKind::AssistantMessage { message } => {
let mut readable = message.clone();
readable.text =
bcode_session_view::presentation::model_output_text(&message.text).into_owned();
message_content(&readable)
}
TranscriptViewItemKind::ReasoningMessage { message } => container! {
details {
summary color=(color::REASONING) { "Reasoning" }
@if !message.text.is_empty() {
div margin-top=((space::SM)) { (message_content(message)) }
}
}
},
TranscriptViewItemKind::ReasoningActivity { activity } => container! {
details {
summary color=(color::REASONING) {
(reasoning_activity_label(activity.status))
}
@let text = activity.text();
@if !text.is_empty() {
div margin-top=((space::SM)) { (message_content(&ChatMessageView::markdown(text))) }
} @else if let Some(note) = reasoning_activity_note(activity) {
div margin-top=((space::SM)) color=(color::MUTED) { (note) }
}
}
},
TranscriptViewItemKind::SystemMessage { message } => container! {
aside color=(color::MUTED) { (message_content(message)) }
},
TranscriptViewItemKind::Compaction { compaction } => compaction_notice(compaction),
TranscriptViewItemKind::Skill { skill } => skill_notice(skill),
TranscriptViewItemKind::ToolRequest { tool } => {
render_tool_lifecycle_with_context(tool, session_id, context)
}
TranscriptViewItemKind::ToolInvocation { tool } => {
if let Some(draft) = tool.request_draft.as_ref()
&& tool.presentation.is_none()
&& tool.result.is_none()
&& tool.result_text.is_none()
{
tool_request_draft_body(draft)
} else {
render_tool_lifecycle_with_context(tool, session_id, context)
}
}
TranscriptViewItemKind::ToolRequestDraft { draft } => tool_request_draft_body(draft),
TranscriptViewItemKind::Permission { permission } => permission_history(permission),
TranscriptViewItemKind::Interaction { interaction } => interaction_notice(interaction),
TranscriptViewItemKind::ToolContribution {
contribution,
placement,
invocation: _,
} => render_tool_contribution(contribution, *placement),
}
}
fn tool_request_draft_body(draft: &bcode_session_view_models::ToolRequestDraftView) -> Containers {
container! {
aside color=(color::MUTED) {
div color=(color::STRONG) { (format!("{} · assembling…", draft.tool_name)) }
div font-size=((typeface::LABEL)) { (format!("{} argument bytes", draft.argument_bytes)) }
div white-space="preserve-wrap" { (draft.preview) }
}
}
}
const fn reasoning_activity_label(
status: bcode_session_models::ReasoningActivityStatus,
) -> &'static str {
match status {
bcode_session_models::ReasoningActivityStatus::Completed => "Reasoning",
bcode_session_models::ReasoningActivityStatus::Interrupted => "Reasoning interrupted",
bcode_session_models::ReasoningActivityStatus::Failed => "Reasoning failed",
}
}
fn reasoning_activity_note(
activity: &bcode_session_view_models::ReasoningActivityView,
) -> Option<&'static str> {
use bcode_session_view_models::ReasoningContentAvailability;
match activity.content_availability() {
ReasoningContentAvailability::Readable | ReasoningContentAvailability::Pending => None,
ReasoningContentAvailability::Filtered => {
Some("Reasoning content hidden by the current display setting.")
}
ReasoningContentAvailability::Withheld => Some(
"The model reasoned about this step but did not return readable reasoning content.",
),
}
}
pub(super) const fn item_label(kind: &TranscriptViewItemKind) -> &'static str {
match kind {
TranscriptViewItemKind::UserMessage { .. } => "user",
TranscriptViewItemKind::AssistantMessage { .. } => "assistant",
TranscriptViewItemKind::ReasoningMessage { .. }
| TranscriptViewItemKind::ReasoningActivity { .. } => "reasoning",
TranscriptViewItemKind::ToolInvocation { .. } => "tool",
TranscriptViewItemKind::ToolRequest { .. } => "tool request",
TranscriptViewItemKind::ToolRequestDraft { .. } => "tool request draft",
TranscriptViewItemKind::Permission { .. } => "permission",
TranscriptViewItemKind::Compaction { .. } => "compaction",
TranscriptViewItemKind::Interaction { .. } => "interaction",
TranscriptViewItemKind::Skill { skill } => match skill.status {
bcode_session_view_models::SkillViewStatus::ContextLoaded => "skill context",
bcode_session_view_models::SkillViewStatus::Failed => "skill error",
bcode_session_view_models::SkillViewStatus::Invoked
| bcode_session_view_models::SkillViewStatus::Suggested => "skill",
},
TranscriptViewItemKind::SystemMessage { .. } => "system",
TranscriptViewItemKind::ToolContribution { .. } => "tool contribution",
}
}