Source code for scripts.ai_assistant.ui.shell

"""Shell: CSS injection, JS bridge, topbar, sidebar."""

from __future__ import annotations

import html as _html
import uuid
from pathlib import Path

import streamlit as st

from scripts.ai_assistant.agent_graph import reset_agent
from scripts.ai_assistant.ui.conversations import (
    _delete_conversation,
    _list_conversations,
    _load_conversation,
    _rename_conversation,
    _save_conversation,
    _toggle_pin,
)

_ASSETS_DIR = Path(__file__).resolve().parent / "assets"
_SIDEBAR_PIN_LIMIT = 5
_SIDEBAR_VISIBLE_CHAT_LIMIT = 10
_TITLE_DISPLAY_LIMIT = 48


def _read(path: Path) -> str:
    try:
        return path.read_text(encoding="utf-8")
    except FileNotFoundError:
        return ""


def _normalize_title(title: str | None) -> str:
    normalized = " ".join((title or "").split())
    return normalized or "Untitled"


def _truncate_title(title: str, *, limit: int = _TITLE_DISPLAY_LIMIT) -> tuple[str, bool]:
    if limit <= 3 or len(title) <= limit:
        return title, False
    return f"{title[: limit - 3].rstrip()}...", True


[docs] def inject_css() -> None: css = _read(_ASSETS_DIR / "theme.css") st.markdown(f"<style>{css}</style>", unsafe_allow_html=True)
[docs] def install_bridge() -> None: js = _read(_ASSETS_DIR / "bridge.js") with st.container(key="rpln_ui_bridge_shell"): st.iframe( f"<!doctype html><html><body style='margin:0;overflow:hidden'><script>{js}</script></body></html>", width="content", height="content", tab_index=-1, )
def _reset_session_to_new_conversation() -> None: ss = st.session_state new_id = str(uuid.uuid4()) ss.current_conversation_id = new_id ss.current_conversation_title = "New conversation" ss.thread_id = new_id ss.messages = [] ss.messages_meta = {} ss.rename_target = None reset_agent()
[docs] def topbar() -> None: ss = st.session_state title = _normalize_title(ss.get("current_conversation_title") or "New conversation") title_label, title_truncated = _truncate_title(title) # Topbar shell renders the mobile menu in HTML and layers native # Streamlit popovers for the title dropdown and the separate Share # control on top of the sticky header track. conv_id = ss.get("current_conversation_id", "") has_messages = bool(ss.get("messages")) current_conv = next((conv for conv in _list_conversations() if conv["id"] == conv_id), None) is_pinned = bool(current_conv and current_conv.get("pinned")) with st.container(key="rpln_topbar_shell"): st.html( """ <div class="rpln-topbar"> <button class="rpln-topbar-btn rpln-mobile-menu-btn" type="button" title="Menu" data-rpln-action="toggle-mobile-nav"> <span class="material-symbols-rounded">menu</span> </button> </div> """ ) with st.container(key="rpln_topbar_title_menu"): st.markdown( ( '<div class="rpln-topbar-title-overlay" aria-hidden="true" ' f'title="{_html.escape(title, quote=True)}">' f"{_html.escape(title_label)}" "</div>" ), unsafe_allow_html=True, ) with st.popover( title_label, width="content", disabled=not has_messages, help=( title if title_truncated else ( "Conversation actions" if has_messages else "Start a chat to enable conversation actions" ) ), ): if conv_id and has_messages: rename_key = f"rpln_topbar_title_{conv_id}" rename_seed_key = f"{rename_key}_seed" rename_mode_key = f"rpln_topbar_mode_{conv_id}" if ss.get(rename_mode_key) not in {"menu", "rename"}: ss[rename_mode_key] = "menu" if ss.get(rename_seed_key) != title: ss[rename_key] = title ss[rename_seed_key] = title st.markdown( '<div class="rpln-topbar-menu-sentinel" aria-hidden="true"></div>', unsafe_allow_html=True, ) if ss.get(rename_mode_key) == "rename": st.text_input( "Conversation title", key=rename_key, label_visibility="collapsed", placeholder="Rename conversation", ) save_col, cancel_col = st.columns(2, gap="small") if save_col.button( "Save", key=f"rpln_topbar_rename_save_{conv_id}", width="stretch", ): new_title = (ss.get(rename_key) or "").strip() if new_title: _rename_conversation(conv_id, new_title) ss.current_conversation_title = new_title ss[rename_mode_key] = "menu" st.rerun() if cancel_col.button( "Cancel", key=f"rpln_topbar_rename_cancel_{conv_id}", width="stretch", ): ss[rename_mode_key] = "menu" st.rerun() else: st.markdown( '<div class="rpln-topbar-action-list" aria-hidden="true"></div>', unsafe_allow_html=True, ) if st.button( "Rename", key=f"rpln_topbar_rename_open_{conv_id}", width="stretch", ): ss[rename_mode_key] = "rename" st.rerun() if st.button( ("\u2605 Unstar" if is_pinned else "\u2606 Star"), key=f"rpln_topbar_pin_{conv_id}", width="stretch", ): _toggle_pin(conv_id) ss[rename_mode_key] = "menu" st.rerun() if st.button( "Delete", key=f"rpln_topbar_delete_{conv_id}", width="stretch", ): _delete_conversation(conv_id) ss[rename_mode_key] = "menu" _reset_session_to_new_conversation() st.rerun() else: st.caption("Start a chat to enable conversation actions.") with ( st.container(key="rpln_topbar_share"), st.popover( "Share", width="content", disabled=not has_messages, help=("Share and export" if has_messages else "Start a chat to enable sharing"), ), ): if conv_id and has_messages: from scripts.ai_assistant.ui.conversations import ( _export_conversation_as_md, _export_conversation_as_text, ) st.markdown( '<div class="rpln-topbar-share-sentinel" aria-hidden="true"></div>', unsafe_allow_html=True, ) st.download_button( "Markdown (.md)", data=_export_conversation_as_md(conv_id), file_name=f"{conv_id[:8]}.md", mime="text/markdown", key=f"rpln_share_export_md_{conv_id}", width="stretch", ) st.download_button( "Plain text (.txt)", data=_export_conversation_as_text(conv_id), file_name=f"{conv_id[:8]}.txt", mime="text/plain", key=f"rpln_share_export_txt_{conv_id}", width="stretch", ) else: st.caption("Start a chat to enable sharing.")
def _render_group(group_name: str, label: str, convs: list[dict]) -> None: ss = st.session_state current_id = ss.get("current_conversation_id", "") rows_html = [] for conv in convs: cid = conv["id"] full_title = _normalize_title(conv.get("title")) display_title, _ = _truncate_title(full_title) title = _html.escape(display_title) escaped_full_title = _html.escape(full_title) active = "1" if cid == current_id else "0" pin_mark = ( '<span class="material-symbols-rounded rpln-pin-mark">push_pin</span>' if group_name == "pinned" else "" ) rows_html.append( f""" <div class="rpln-conv-row" data-rpln-active="{active}" data-rpln-action="switch-conv" data-rpln-conv-id="{cid}" title="{escaped_full_title}" aria-label="{escaped_full_title}"> {pin_mark} <span class="rpln-conv-title" data-rpln-conv-id="{cid}" title="{escaped_full_title}" aria-label="{escaped_full_title}">{title}</span> <button class="rpln-row-close" type="button" data-rpln-action="pin-conv" data-rpln-conv-id="{cid}" title="{"Unpin" if group_name == "pinned" else "Pin"}"> <span class="material-symbols-rounded"> {"close" if group_name == "pinned" else "push_pin"} </span> </button> <div class="rpln-row-menu-wrap"> <button class="rpln-row-kebab" type="button" data-rpln-action="toggle-conv-menu" data-rpln-conv-id="{cid}" title="More actions for {escaped_full_title}" aria-label="More actions for {escaped_full_title}"> <span class="material-symbols-rounded">more_horiz</span> </button> <div class="rpln-row-menu" data-rpln-conv-id="{cid}"> <button type="button" data-rpln-action="rename-conv" data-rpln-conv-id="{cid}"> <span class="material-symbols-rounded">edit</span> <span>Rename</span> </button> <button type="button" data-rpln-action="pin-conv" data-rpln-conv-id="{cid}"> <span class="material-symbols-rounded"> {"keep_off" if group_name == "pinned" else "keep"} </span> <span>{"Unpin" if group_name == "pinned" else "Pin"}</span> </button> <button type="button" data-rpln-action="delete-conv" data-rpln-conv-id="{cid}" class="rpln-row-menu-danger"> <span class="material-symbols-rounded">delete</span> <span>Delete</span> </button> </div> </div> </div> """ ) st.html( f""" <div class="rpln-group" data-group="{group_name}" data-open="1"> <div class="rpln-group-header" data-rpln-action="toggle-group"> <span class="rpln-chev">&rsaquo;</span> <span>{label}</span> </div> <div class="rpln-conv-list"> {"".join(rows_html)} </div> </div> """ ) for conv in convs: cid = conv["id"] if st.button("switch", key=f"rpln_switch_{cid}"): _save_conversation() # _load_conversation populates session state directly + resets the # agent on success; nothing further to do on this side. _load_conversation(cid) st.rerun() if st.button("delete", key=f"rpln_del_{cid}"): _delete_conversation(cid) if ss.current_conversation_id == cid: _reset_session_to_new_conversation() st.rerun() if st.button("pin", key=f"rpln_pin_{cid}"): _toggle_pin(cid) st.rerun()