{# -- Sticky header bar (tier 1) --
Mobile: only this slim row pins under the top nav (see media query
below); the meta block (#ticket-header-meta) scrolls away so the
thread gets the vertical space back. Desktop (lg+): the column is
its own scroll container, both blocks are always visible — the
lg:border-b-0 / lg:pb-0 keep the two divs rendering as one box. #}
{# Hidden below lg: the top nav already injects a mobile back button (nav_mobile_back) #}
arrow_back
{{ ticket.thread.subject }}
#{{ provider_thread_id or ticket.id }}
{# Mobile status chip — the meta block (with #status-select) scrolls
away, so the pinned bar gets a tappable chip to change status while
deep in the thread. Colors mirror the inbox badges; JS keeps it in
sync with the select via setTicketStatusUI (app.js). #}
{% set _chip_cls = {
'open': 'bg-yellow-100 dark:bg-yellow-500/10 text-yellow-800 dark:text-yellow-300',
'in_progress': 'bg-blue-100 dark:bg-blue-500/10 text-blue-800 dark:text-blue-300',
'waiting_customer': 'bg-orange-100 dark:bg-orange-500/10 text-orange-800 dark:text-orange-300',
'answered': 'bg-teal-100 dark:bg-teal-500/10 text-teal-800 dark:text-teal-300',
'closed': 'bg-green-100 dark:bg-green-500/10 text-green-800 dark:text-green-300'
} %}
{% set _chip_lbl = {'open':_('Open'),'in_progress':_('In Progress'),'waiting_customer':_('Waiting'),'answered':_('Answered'),'closed':_('Closed')} %}
{% for _val, _lbl in [('open',_('Open')),('in_progress',_('In Progress')),('waiting_customer',_('Waiting')),('answered',_('Answered')),('closed',_('Closed'))] %}
{% endfor %}
{% if provider_thread_id %}
open_in_new
{% endif %}
{% if ticket.thread.customer_hash %}
{% endif %}
{# Mobile overflow menu — the icon buttons above are hidden below lg
(duplicated in the bottom quick-action bar or rarely used); the
non-duplicated ones live here so nothing is lost on mobile. #}
{# -- Header meta block (tier 2): product, site, status, badges --
Not sticky on mobile: triage info is read once at the top of the
ticket, so it scrolls away with the page instead of permanently
eating viewport. On lg+ it is always visible (column scrolls). #}
{# Row 1b: product icon + name + customer website #}
{% if ticket.thread.product_name or ticket.thread.customer_website %}
{# ml-8 aligns under the subject past the back arrow — desktop only;
the back arrow is hidden below lg so the indent would be orphaned #}
{% if product and product.product_type == 'business_care' %}
BC
{% elif product and product.pico_url %}
{% endif %}
{% if ticket.thread.product_name %}
{{ ticket.thread.product_name }}
{% endif %}
{% if ticket.thread.customer_website %}
{% set ws_href = ticket.thread.customer_website if ticket.thread.customer_website.startswith('http') else 'https://' + ticket.thread.customer_website %}
{% if site_check and site_check.is_online == true %}
{% set tip = _('Online') %}
{% if site_check.error_message %}{% set tip = 'Online (' ~ site_check.http_status ~ ' — ' ~ site_check.error_message ~ ')' %}
{% elif site_check.http_status %}{% set tip = 'Online (' ~ site_check.http_status ~ ', ' ~ (site_check.response_time_ms or '?') ~ 'ms)' %}{% endif %}
{% elif site_check and site_check.is_online == false %}
{% set tip = _('Offline') %}
{% if site_check.error_message %}{% set tip = 'Offline — ' ~ site_check.error_message %}{% endif %}
{% endif %}
{{ ticket.thread.customer_website | truncate(32) }}
{% endif %}
{% if customer_thread_count and customer_thread_count > 0 and ticket.thread.customer_hash %}
{% endif %}
{% endif %}
{# -- Second row: controls + info -- #}
{# Support contract status — only show if we have actual support data (zen_type or has_support) #}
{% if ticket.thread.zen_type or ticket.thread.has_support is not none %}
{% if ticket.thread.has_support %}
verified
{{ ticket.thread.support_type or _('Support Active') }}
{% elif ticket.thread.is_support_expired %}
error
{{ _('Support Expired') }}
{% else %}
check_circle
{{ _('Support Active') }}
{% endif %}
{% endif %}
{# Intent / sentiment badge from latest AI classification #}
{% if suggestions %}
{% set _latest_s = suggestions|sort(attribute='created_at', reverse=True)|first %}
{% set _intent = (_latest_s.classification_json|fromjson).get('intent_category', '') %}
{% if _intent %}
{% set _intent_map = {
'angry': ['red', 'sentiment_very_dissatisfied', _('Angry')],
'escalation': ['red', 'priority_high', _('Escalation')],
'complaint': ['orange', 'feedback', _('Complaint')],
'refund': ['orange', 'currency_exchange', _('Refund')],
'bug': ['amber', 'bug_report', _('Bug')],
'question': ['blue', 'help', _('Question')],
'config': ['blue', 'settings', _('Config')],
'help': ['blue', 'support_agent', _('Help')],
'feature_request': ['purple', 'lightbulb', _('Feature Request')],
'thanks_solved': ['green', 'check_circle', _('Thanks / Solved')]
} %}
{% set _style = _intent_map.get(_intent, ['gray', 'label', _intent | replace('_', ' ') | title]) %}
{{ _style[1] }}
{{ _style[2] }}
{% endif %}
{% endif %}
{# Customer language — manual override (any language, not limited to what support knows) #}
{# Wrapper hides these below lg (duplicated in the mobile bottom bar /
overflow menu); JS toggles .hidden on the buttons themselves, so the
breakpoint gate must live on this wrapper, not on the buttons. #}
{% if not ticket.first_response_at %}
{% endif %}
{# -- Thread Summary / TL;DR -- shown when no segments, otherwise integrated into topic overlay #}
{% if thread_summary and segments|length <= 1 %}
summarize
{{ _('Thread Summary') }}
{{ thread_summary }}
{% endif %}
{# -- Review request banner --
Shown whenever a genuine thanks was detected on a ticket we replied to
(the review-request tag), regardless of status. Removed by replying,
"Already Sent", or "Not now"; re-armed by the next genuine thanks. #}
{% if is_review_request %}
sentiment_very_satisfied
{{ _('Happy customer — time for a review request!') }}
{% trans %}This customer sent a thank-you on a closed ticket. Reply to
clear the Addons "unanswered" counter and
earn a positive rating.{% endtrans %}
{% endif %}
{# -- Conversation thread -- #}
{# Floating topic overlay — single pill, updated via JS #}
{% if segments|length > 1 %}
{# Expandable per-topic summary panel under the pill #}
{% endif %}
{% set ns = namespace(prev_seg=-1) %}
{% for msg in messages %}
{% set cur_seg = message_segment_map.get(msg.id, -1) %}
{% if cur_seg != ns.prev_seg and cur_seg >= 0 %}
{# Inline topic marker (not sticky — observed by JS) #}
topic
{{ segments[cur_seg].label if cur_seg < segments|length else _('Topic') }}
{% set ns.prev_seg = cur_seg %}
{% endif %}
{% if scroll_to_message_id and msg.id == scroll_to_message_id %}
{# Bubble + hover actions #}
{# min-w-0: without it, unbreakable content (long URLs) props up the
flex min-width and the row overflows — the hover/translate action
buttons get clipped off-screen on mobile. #}
{# Translation result area — pre-populated from cache when available #}
{% set cached_tr = cached_translations.get(msg.id) %}
{% if cached_tr %}{{ cached_tr | safe }}{% endif %}
{# Attachments #}
{% if msg.attachments %}
{% for att in msg.attachments %}
{% set ext = att.filename.rsplit('.', 1)[-1].lower() if '.' in att.filename else '' %}
{% if ext in ('jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg') %}
{% set att_icon = 'image' %}
{% set att_color = 'bg-blue-50 hover:bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-500/10 dark:hover:bg-blue-500/20 dark:text-blue-400 dark:border-blue-500/30' %}
{% set att_label = _('Image') %}
{% elif ext == 'pdf' %}
{% set att_icon = 'picture_as_pdf' %}
{% set att_color = 'bg-red-50 hover:bg-red-100 text-red-700 border-red-200 dark:bg-red-500/10 dark:hover:bg-red-500/20 dark:text-red-400 dark:border-red-500/30' %}
{% set att_label = 'PDF' %}
{% elif ext in ('zip', 'rar', '7z', 'tar', 'gz') %}
{% set att_icon = 'folder_zip' %}
{% set att_color = 'bg-amber-50 hover:bg-amber-100 text-amber-700 border-amber-200 dark:bg-amber-500/10 dark:hover:bg-amber-500/20 dark:text-amber-400 dark:border-amber-500/30' %}
{% set att_label = ext.upper() %}
{% elif ext in ('doc', 'docx', 'odt', 'txt') %}
{% set att_icon = 'description' %}
{% set att_color = 'bg-indigo-50 hover:bg-indigo-100 text-indigo-700 border-indigo-200 dark:bg-indigo-500/10 dark:hover:bg-indigo-500/20 dark:text-indigo-400 dark:border-indigo-500/30' %}
{% set att_label = ext.upper() %}
{% else %}
{% set att_icon = 'attach_file' %}
{% set att_color = 'bg-gray-100 hover:bg-gray-200 text-gray-700 border-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 dark:text-gray-300 dark:border-gray-600' %}
{% set att_label = ext.upper() if ext else _('File') %}
{% endif %}
{% set att_url = url_for('attachment_view', attachment_id=att.id) %}
{% if ext in ('jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg', 'pdf', 'txt') %}
{# tojson emits a JS literal in DOUBLE quotes and escapes ' < > & as \uXXXX,
leaving " raw — so it is only safe inside a SINGLE-quoted attribute.
(`| e` alone emitted ', which the attribute parser decoded back into a
real quote inside the JS literal, which is the bug this replaces.) #}
{{ att_icon }}{{ att_label }}.{{ ext }}
{% else %}
{{ att_icon }}{{ att_label }}.{{ ext }}
{% endif %}
{% endfor %}
{% endif %}
{# Hover action bar. The column stretches to the bubble height and the
buttons stick to the top of #message-thread inside it, so on a long
message they stay reachable instead of sitting in the middle of the
bubble, far off-screen. z-10 keeps them under the topic overlay. #}
{% if has_feature('ai_translate') %}
{% else %}
{% endif %}
{% if known_languages | length > 1 %}
{% for lang in known_languages %}
{% if lang != preferred_language %}
{% endif %}
{% endfor %}
{% endif %}
{% endfor %}
{# -- Reply bar -- #}
{# Mobile FAB: opens the bottom-sheet side panel (Credentials, Notes, Topics,
Tags). Anchored to the reply bar so it always floats just above the
composer. The toggle/close JS (toggleAISidebar) predates this button —
the trigger was never rendered, leaving the panel unreachable on mobile. #}
{# Mobile-only quick-action bar — the ticket header scrolls away on mobile,
so the per-ticket actions (skip to last message, mark read/unread, next
ticket) are surfaced here, riding with the fixed reply bar. Hidden at lg+
where the header is permanently pinned. The .mobile-ticket-actions class
is excluded from the "expand composer on click" handler below. #}
{# Mobile: full-width tap target replaces the minimized editor #}
{# Minimize/expand toggle #}
{# Desktop collapsed state — a single cohesive card replacing the disjointed
bare AI button + plain editor. Visible only when reply-bar.minimized on lg+.
Mobile uses #reply-open-btn instead (see @media rule below). #}
{# Tool strip — compact icon buttons that expand panels on click #}
{% if id_product %}
{% endif %}
{% if product_has_release %}
{% endif %}
{# Collapsible: Quick Reply panel #}
{% if id_product %}
{# Image viewer #}
{# PDF viewer — explicit viewport size; parent wrapper is shrink-to-fit so w/h-full would collapse #}
{# Text viewer — same explicit sizing as the PDF viewer #}
{# Floating copy button — anchored to content top-right #}