bcode_hyperchad_ui 0.0.1-alpha.0

Portable HyperChad UI components for Bcode sessions
//! Top-level Bcode `HyperChad` application shell.

use super::theme::{color, radius, space, surface, typeface};
use crate::context::PresentationContext;
use bcode_plugin_sdk::path::display_from_current_dir;
use bcode_session_models::SessionSummary;
use bcode_session_view_models::SessionViewSnapshot;
use hyperchad::actions::logic::if_responsive;
use hyperchad::template::{Containers, container};
use hyperchad::transformer::models::{AlignItems, LayoutDirection};

use super::activity::{
    active_invocations_section, runtime_state_section, runtime_work_section,
    unrepresented_active_invocations, unrepresented_runtime_work,
};
use super::components::{
    StatusTone, application_content, composer_panel, section_panel, status_badge, status_notice,
};
use super::composer::composer;
use super::interactions::interaction_request;
use super::navigation::session_navigation;
use super::permissions::permission_request;
use super::transcript::transcript_section;

const fn connection_notice(
    status: &bcode_session_view_models::SessionConnectionViewStatus,
) -> (&str, Option<&str>, StatusTone) {
    match status {
        bcode_session_view_models::SessionConnectionViewStatus::Disconnected => (
            "Disconnected",
            Some("Live session updates are unavailable."),
            StatusTone::Error,
        ),
        bcode_session_view_models::SessionConnectionViewStatus::Connected => (
            "Connected · no active session",
            Some("Select a session or send a message to begin."),
            StatusTone::Neutral,
        ),
        bcode_session_view_models::SessionConnectionViewStatus::Attached => {
            ("Connected · session attached", None, StatusTone::Success)
        }
        bcode_session_view_models::SessionConnectionViewStatus::Reconnecting => (
            "Reconnecting to session…",
            Some("Showing the last available session view while live updates reconnect."),
            StatusTone::Warning,
        ),
        bcode_session_view_models::SessionConnectionViewStatus::Resyncing => (
            "Refreshing session state…",
            Some("A complete authoritative session view is being requested."),
            StatusTone::Info,
        ),
        bcode_session_view_models::SessionConnectionViewStatus::Error(_) => (
            "Session unavailable",
            Some(
                "The session could not be refreshed. Try reconnecting or restart Bcode if the problem continues.",
            ),
            StatusTone::Error,
        ),
    }
}

const fn catalog_notice(
    status: &bcode_session_view_models::SessionCatalogViewStatus,
) -> Option<(&str, StatusTone)> {
    match status {
        bcode_session_view_models::SessionCatalogViewStatus::NotStarted => {
            Some(("Session discovery has not started.", StatusTone::Neutral))
        }
        bcode_session_view_models::SessionCatalogViewStatus::Loading => {
            Some(("Loading available sessions…", StatusTone::Info))
        }
        bcode_session_view_models::SessionCatalogViewStatus::Loaded => None,
        bcode_session_view_models::SessionCatalogViewStatus::Degraded(_) => Some((
            "Some sessions could not be loaded. Available sessions remain usable; repair damaged sessions to restore the full list.",
            StatusTone::Warning,
        )),
        bcode_session_view_models::SessionCatalogViewStatus::Failed(_) => Some((
            "Sessions could not be loaded. Restart Bcode or run session repair before trying again.",
            StatusTone::Error,
        )),
    }
}

/// Render the Bcode `HyperChad` application shell.
#[must_use]
pub fn home(
    snapshot: &SessionViewSnapshot,
    sessions: &[SessionSummary],
    context: &impl PresentationContext,
) -> Containers {
    let application = home_content(snapshot, sessions, context);
    let shell = container! {
        div #bcode-web-shell padding=(if_responsive("narrow").then::<i32>(space::MD).or_else(space::XL)) background=(surface::APP) color=(color::TEXT) font-family=(typeface::UI) {
            (application)
        }
    };
    debug_assert_eq!(shell.len(), 1);
    shell
}

/// Render the stable, targeted application fragment used by live browser updates.
#[must_use]
pub fn home_content(
    snapshot: &SessionViewSnapshot,
    sessions: &[SessionSummary],
    context: &impl PresentationContext,
) -> Containers {
    let title = snapshot
        .title
        .as_deref()
        .or_else(|| {
            snapshot
                .session_summary
                .as_ref()
                .and_then(SessionSummary::title)
        })
        .unwrap_or("No session attached");
    let (status, status_detail, status_tone) = connection_notice(&snapshot.connection_status);
    let catalog_status = catalog_notice(&snapshot.catalog_status);
    let unrepresented_invocations = unrepresented_active_invocations(snapshot);
    let unrepresented_work = unrepresented_runtime_work(snapshot);

    let header = container! {
        header #application-header direction=(if_responsive("narrow").then(LayoutDirection::Column).or_else(LayoutDirection::Row)) justify-content=space-between align-items=(if_responsive("narrow").then(AlignItems::Start).or_else(AlignItems::Center)) gap=((space::MD)) margin-bottom=((space::XL)) {
            div {
                h1 color=(color::SUCCESS) font-size=(typeface::TITLE) margin-bottom=((space::XS)) { "bcode web" }
                div color=(color::MUTED) font-size=(typeface::BODY) { "renderer-neutral session view powered by HyperChad" }
            }
            (status_badge(status, status_tone))
        }
    };
    let notices = container! {
        @if status_detail.is_some() {
            (status_notice(status, status_detail, status_tone))
        }
        @if let Some((message, tone)) = catalog_status {
            (status_notice("Session catalog", Some(message), tone))
        }
        @if let Some(notice) = &snapshot.notice {
            (status_notice(
                match notice.level {
                    bcode_session_view_models::SessionViewNoticeLevel::Info => "Status",
                    bcode_session_view_models::SessionViewNoticeLevel::Warning => "Attention",
                    bcode_session_view_models::SessionViewNoticeLevel::Error => "Action failed",
                },
                Some(&notice.message),
                match notice.level {
                    bcode_session_view_models::SessionViewNoticeLevel::Info => StatusTone::Info,
                    bcode_session_view_models::SessionViewNoticeLevel::Warning => StatusTone::Warning,
                    bcode_session_view_models::SessionViewNoticeLevel::Error => StatusTone::Error,
                },
            ))
        }
    };
    let body = container! {
        section background=(surface::PANEL) border=((1, surface::BORDER)) border-radius=((radius::PANEL)) padding=((space::LG)) margin-bottom=((space::S18)) {
            div justify-content=space-between gap=((space::MD)) align-items=start {
                div {
                    h2 color=(color::STRONG) font-size=((typeface::HEADING)) margin-bottom=((space::XS)) { (title) }
                    details {
                        summary color=(color::MUTED) font-size=((typeface::DETAIL)) { "session details" }
                        div color=(color::MUTED) font-size=((typeface::DETAIL)) margin-top=((space::XS)) {
                            "revision " (snapshot.revision.to_string())
                            @if let Some(sequence) = snapshot.latest_sequence {
                                " · latest event " (sequence.to_string())
                            }
                        }
                    }
                }
                div color=(color::MUTED) font-size=((typeface::LABEL)) {
                    (snapshot.working_directory.as_ref().map_or_else(|| "—".to_string(), |path| display_from_current_dir(path).to_string()))
                }
            }
        }
        @if !unrepresented_invocations.is_empty() { (active_invocations_section(&unrepresented_invocations)) }
        @if !unrepresented_work.is_empty() { (runtime_work_section(&unrepresented_work)) }
        (runtime_state_section(snapshot))
        (queue_recovery_panel(snapshot, context))
        (transcript_section(snapshot, context))
        @if !snapshot.interactions.is_empty() {
            (section_panel("interactions", &container! {
                @for interaction in &snapshot.interactions {
                    (interaction_request(interaction, snapshot.session_id, context))
                }
            }, true))
        }
        @if !snapshot.permissions.is_empty() {
            (section_panel("permissions", &container! {
                @for permission in &snapshot.permissions {
                    (permission_request(permission, snapshot.session_id, context))
                }
            }, true))
        }
        (composer_panel(&composer(snapshot, context)))
    };
    application_content(
        &header,
        &notices,
        &session_navigation(
            sessions,
            snapshot.session_id,
            &snapshot.catalog_status,
            context,
        ),
        &body,
    )
}

fn queue_recovery_panel(
    snapshot: &SessionViewSnapshot,
    context: &impl PresentationContext,
) -> Containers {
    container! {
        @if snapshot.runtime.submission_queue_unavailable {
            div { "Queued message status unavailable. Reconnect to refresh status." }
        }
        @if let (Some(session_id), Some(recovery)) = (snapshot.session_id, snapshot.runtime.queue_recovery()) {
            div #submission-queue-blocker padding=((space::MD)) background=(surface::INSET) {
                h3 { "Queued message needs attention" }
                div { (recovery.reason) }
                @if recovery.can_resume {
                    form hx-post=(context.action_target(crate::context::PresentationAction::PrepareSubmissionResume)) hx-target="#bcode-web-shell" hx-swap=this {
                        input type=hidden name="session_id" value=(session_id.to_string());
                        input type=hidden name="submission_id" value=(recovery.submission_id.0.clone());
                        input type=hidden name="expected_accepted_sequence" value=(recovery.expected_accepted_sequence.to_string());
                        button type=submit { "Review settings and resume…" }
                    }
                }
                @if recovery.can_cancel {
                    form hx-post=(context.action_target(crate::context::PresentationAction::CancelPendingSubmission)) hx-target="#bcode-web-shell" hx-swap=this {
                        input type=hidden name="session_id" value=(session_id.to_string());
                        input type=hidden name="submission_id" value=(recovery.submission_id.0.clone());
                        input type=hidden name="expected_accepted_sequence" value=(recovery.expected_accepted_sequence.to_string());
                        input type=hidden name="operation_id" value=(format!("cancel:{}:{}", recovery.submission_id.0, recovery.expected_accepted_sequence));
                        button type=submit { "Cancel this queued message" }
                    }
                }
            }
        }
    }
}