Implement business plans

This commit is contained in:
2026-03-02 00:00:53 +00:00
parent d22924f6aa
commit b3e183eb0a
26 changed files with 4109 additions and 39 deletions

View File

@@ -392,6 +392,9 @@
<a class="navbar-item" href="{% url 'ais' type='page' %}">
AI
</a>
<a class="navbar-item" href="{% url 'command_routing' %}">
Command Routing
</a>
{% if user.is_superuser %}
<a class="navbar-item" href="{% url 'system_settings' %}">
System

View File

@@ -0,0 +1,57 @@
{% extends "base.html" %}
{% block content %}
<section class="section">
<div class="container">
<h1 class="title is-4">Business Plan Editor</h1>
<p class="subtitle is-6">{{ document.source_service }} · {{ document.source_channel_identifier }}</p>
<article class="box">
<form method="post">
{% csrf_token %}
<div class="columns">
<div class="column is-8">
<label class="label is-size-7">Title</label>
<input class="input" name="title" value="{{ document.title }}">
</div>
<div class="column is-4">
<label class="label is-size-7">Status</label>
<div class="select is-fullwidth">
<select name="status">
<option value="draft" {% if document.status == 'draft' %}selected{% endif %}>draft</option>
<option value="final" {% if document.status == 'final' %}selected{% endif %}>final</option>
</select>
</div>
</div>
</div>
<label class="label is-size-7">Content (Markdown)</label>
<textarea class="textarea" name="content_markdown" rows="18">{{ document.content_markdown }}</textarea>
<div class="buttons" style="margin-top: 0.75rem;">
<button class="button is-link" type="submit">Save Revision</button>
<a class="button is-light" href="{% url 'command_routing' %}">Back</a>
</div>
</form>
</article>
<article class="box">
<h2 class="title is-6">Revisions</h2>
<table class="table is-fullwidth is-striped is-size-7">
<thead>
<tr><th>Created</th><th>Editor</th><th>Excerpt</th></tr>
</thead>
<tbody>
{% for row in revisions %}
<tr>
<td>{{ row.created_at }}</td>
<td>{{ row.editor_user.username }}</td>
<td>{{ row.content_markdown|truncatechars:180 }}</td>
</tr>
{% empty %}
<tr><td colspan="3">No revisions yet.</td></tr>
{% endfor %}
</tbody>
</table>
</article>
</div>
</section>
{% endblock %}

View File

@@ -0,0 +1,267 @@
{% extends "base.html" %}
{% block content %}
<section class="section">
<div class="container">
<h1 class="title is-4">Command Routing</h1>
<p class="subtitle is-6">Manage command profiles, channel bindings, business-plan outputs, and translation bridges.</p>
<article class="box">
<h2 class="title is-6">Create Command Profile</h2>
<form method="post">
{% csrf_token %}
<input type="hidden" name="action" value="profile_create">
<div class="columns">
<div class="column">
<input class="input is-small" name="slug" placeholder="slug (bp)" value="bp">
</div>
<div class="column">
<input class="input is-small" name="name" placeholder="name" value="Business Plan">
</div>
<div class="column">
<input class="input is-small" name="trigger_token" placeholder="trigger token" value="#bp#">
</div>
</div>
<textarea class="textarea is-small" name="template_text" rows="4" placeholder="Business plan template"></textarea>
<button class="button is-link is-small" type="submit">Create Profile</button>
</form>
</article>
{% for profile in profiles %}
<article class="box">
<h2 class="title is-6">{{ profile.name }} ({{ profile.slug }})</h2>
<form method="post" style="margin-bottom: 0.75rem;">
{% csrf_token %}
<input type="hidden" name="action" value="profile_update">
<input type="hidden" name="profile_id" value="{{ profile.id }}">
<div class="columns is-multiline">
<div class="column is-3">
<label class="label is-size-7">Name</label>
<input class="input is-small" name="name" value="{{ profile.name }}">
</div>
<div class="column is-2">
<label class="label is-size-7">Trigger</label>
<input class="input is-small" name="trigger_token" value="{{ profile.trigger_token }}">
</div>
<div class="column is-2">
<label class="label is-size-7">Visibility</label>
<div class="select is-small is-fullwidth">
<select name="visibility_mode">
<option value="status_in_source" {% if profile.visibility_mode == 'status_in_source' %}selected{% endif %}>status_in_source</option>
<option value="silent" {% if profile.visibility_mode == 'silent' %}selected{% endif %}>silent</option>
</select>
</div>
</div>
<div class="column is-5">
<label class="label is-size-7">Flags</label>
<label class="checkbox is-size-7"><input type="checkbox" name="enabled" value="1" {% if profile.enabled %}checked{% endif %}> enabled</label>
<label class="checkbox is-size-7" style="margin-left: 0.6rem;"><input type="checkbox" name="reply_required" value="1" {% if profile.reply_required %}checked{% endif %}> reply required</label>
<label class="checkbox is-size-7" style="margin-left: 0.6rem;"><input type="checkbox" name="exact_match_only" value="1" {% if profile.exact_match_only %}checked{% endif %}> exact match</label>
</div>
</div>
<label class="label is-size-7">BP Template</label>
<textarea class="textarea is-small" name="template_text" rows="5">{{ profile.template_text }}</textarea>
<div class="buttons" style="margin-top: 0.6rem;">
<button class="button is-link is-small" type="submit">Save Profile</button>
</div>
</form>
<div class="columns">
<div class="column">
<h3 class="title is-7">Channel Bindings</h3>
<table class="table is-fullwidth is-striped is-size-7">
<thead>
<tr><th>Direction</th><th>Service</th><th>Channel</th><th></th></tr>
</thead>
<tbody>
{% for binding in profile.channel_bindings.all %}
<tr>
<td>{{ binding.direction }}</td>
<td>{{ binding.service }}</td>
<td>{{ binding.channel_identifier }}</td>
<td>
<form method="post">
{% csrf_token %}
<input type="hidden" name="action" value="binding_delete">
<input type="hidden" name="binding_id" value="{{ binding.id }}">
<button class="button is-danger is-light is-small" type="submit">Delete</button>
</form>
</td>
</tr>
{% empty %}
<tr><td colspan="4">No bindings yet.</td></tr>
{% endfor %}
</tbody>
</table>
<form method="post">
{% csrf_token %}
<input type="hidden" name="action" value="binding_create">
<input type="hidden" name="profile_id" value="{{ profile.id }}">
<div class="columns">
<div class="column">
<div class="select is-small is-fullwidth">
<select name="direction">
{% for value in directions %}
<option value="{{ value }}">{{ value }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="column">
<div class="select is-small is-fullwidth">
<select name="service">
{% for value in channel_services %}
<option value="{{ value }}">{{ value }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="column">
<input class="input is-small" name="channel_identifier" placeholder="channel identifier">
</div>
<div class="column is-narrow">
<button class="button is-link is-small" type="submit">Add</button>
</div>
</div>
</form>
</div>
<div class="column">
<h3 class="title is-7">Actions</h3>
<table class="table is-fullwidth is-striped is-size-7">
<thead>
<tr><th>Type</th><th>Enabled</th><th>Position</th><th></th></tr>
</thead>
<tbody>
{% for action_row in profile.actions.all %}
<tr>
<td>{{ action_row.action_type }}</td>
<td>{{ action_row.enabled }}</td>
<td>{{ action_row.position }}</td>
<td>
<form method="post">
{% csrf_token %}
<input type="hidden" name="action" value="action_update">
<input type="hidden" name="command_action_id" value="{{ action_row.id }}">
<label class="checkbox is-size-7"><input type="checkbox" name="enabled" value="1" {% if action_row.enabled %}checked{% endif %}> enabled</label>
<input class="input is-small" style="width: 5rem;" name="position" value="{{ action_row.position }}">
<button class="button is-link is-light is-small" type="submit">Save</button>
</form>
</td>
</tr>
{% empty %}
<tr><td colspan="4">No actions.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<form method="post" style="margin-top: 0.75rem;">
{% csrf_token %}
<input type="hidden" name="action" value="profile_delete">
<input type="hidden" name="profile_id" value="{{ profile.id }}">
<button class="button is-danger is-light is-small" type="submit">Delete Profile</button>
</form>
</article>
{% empty %}
<article class="notification is-light">No command profiles configured.</article>
{% endfor %}
<article class="box">
<h2 class="title is-6">Business Plan Documents</h2>
<table class="table is-fullwidth is-striped is-size-7">
<thead>
<tr><th>Title</th><th>Status</th><th>Source</th><th>Updated</th><th></th></tr>
</thead>
<tbody>
{% for doc in documents %}
<tr>
<td>{{ doc.title }}</td>
<td>{{ doc.status }}</td>
<td>{{ doc.source_service }} · {{ doc.source_channel_identifier }}</td>
<td>{{ doc.updated_at }}</td>
<td><a class="button is-small is-link is-light" href="{% url 'business_plan_editor' doc_id=doc.id %}">Open</a></td>
</tr>
{% empty %}
<tr><td colspan="5">No business plan documents yet.</td></tr>
{% endfor %}
</tbody>
</table>
</article>
<article class="box">
<h2 class="title is-6">Translation Bridges</h2>
<form method="post" style="margin-bottom: 0.75rem;">
{% csrf_token %}
<input type="hidden" name="action" value="bridge_create">
<div class="columns is-multiline">
<div class="column is-2"><input class="input is-small" name="name" placeholder="name"></div>
<div class="column is-2"><input class="input is-small" name="quick_mode_title" placeholder="quick mode: en|es"></div>
<div class="column is-2">
<div class="select is-small is-fullwidth"><select name="a_service">{% for value in channel_services %}<option value="{{ value }}">{{ value }}</option>{% endfor %}</select></div>
</div>
<div class="column is-2"><input class="input is-small" name="a_channel_identifier" placeholder="A channel"></div>
<div class="column is-1"><input class="input is-small" name="a_language" value="en"></div>
<div class="column is-2">
<div class="select is-small is-fullwidth"><select name="b_service">{% for value in channel_services %}<option value="{{ value }}">{{ value }}</option>{% endfor %}</select></div>
</div>
<div class="column is-2"><input class="input is-small" name="b_channel_identifier" placeholder="B channel"></div>
<div class="column is-1"><input class="input is-small" name="b_language" value="es"></div>
<div class="column is-2">
<div class="select is-small is-fullwidth"><select name="direction">{% for value in bridge_directions %}<option value="{{ value }}">{{ value }}</option>{% endfor %}</select></div>
</div>
<div class="column is-1"><button class="button is-link is-small" type="submit">Add</button></div>
</div>
</form>
<table class="table is-fullwidth is-striped is-size-7">
<thead>
<tr><th>Name</th><th>A</th><th>B</th><th>Direction</th><th></th></tr>
</thead>
<tbody>
{% for bridge in bridges %}
<tr>
<td>{{ bridge.name }}</td>
<td>{{ bridge.a_service }} · {{ bridge.a_channel_identifier }} · {{ bridge.a_language }}</td>
<td>{{ bridge.b_service }} · {{ bridge.b_channel_identifier }} · {{ bridge.b_language }}</td>
<td>{{ bridge.direction }}</td>
<td>
<form method="post">
{% csrf_token %}
<input type="hidden" name="action" value="bridge_delete">
<input type="hidden" name="bridge_id" value="{{ bridge.id }}">
<button class="button is-danger is-light is-small" type="submit">Delete</button>
</form>
</td>
</tr>
{% empty %}
<tr><td colspan="5">No translation bridges configured.</td></tr>
{% endfor %}
</tbody>
</table>
</article>
<article class="box">
<h2 class="title is-6">Translation Event Log</h2>
<table class="table is-fullwidth is-striped is-size-7">
<thead>
<tr><th>Bridge</th><th>Status</th><th>Target</th><th>Error</th><th>At</th></tr>
</thead>
<tbody>
{% for event in events %}
<tr>
<td>{{ event.bridge.name }}</td>
<td>{{ event.status }}</td>
<td>{{ event.target_service }} · {{ event.target_channel }}</td>
<td>{{ event.error|default:"-" }}</td>
<td>{{ event.created_at }}</td>
</tr>
{% empty %}
<tr><td colspan="5">No events yet.</td></tr>
{% endfor %}
</tbody>
</table>
</article>
</div>
</section>
{% endblock %}

View File

@@ -72,6 +72,31 @@
<span class="icon is-small"><i class="fa-solid fa-rotate-right"></i></span>
<span>Force Sync</span>
</button>
<div class="compose-command-menu">
<button
type="button"
class="button is-light is-rounded compose-command-menu-toggle"
title="Enable or disable command triggers for this chat">
<span class="icon is-small"><i class="fa-solid fa-diagram-project"></i></span>
<span>Commands</span>
</button>
<div class="compose-command-menu-panel is-hidden">
{% for option in command_options %}
<label class="compose-command-option">
<input
type="checkbox"
class="compose-command-toggle"
data-command-slug="{{ option.slug }}"
{% if option.enabled_here %}checked{% endif %}>
<span class="compose-command-option-title">{{ option.name }}</span>
{% if option.trigger_token %}
<span class="compose-command-option-token">{{ option.trigger_token }}</span>
{% endif %}
</label>
{% endfor %}
<a class="compose-command-settings-link" href="{% url 'command_routing' %}">Open command routing</a>
</div>
</div>
<button type="button" class="button is-light is-rounded js-ai-trigger" data-kind="drafts">
<span class="icon is-small"><i class="fa-solid fa-pen"></i></span>
<span>Drafts</span>
@@ -241,10 +266,11 @@
data-summary-url="{{ compose_summary_url }}"
data-quick-insights-url="{{ compose_quick_insights_url }}"
data-history-sync-url="{{ compose_history_sync_url }}"
data-toggle-command-url="{{ compose_toggle_command_url }}"
data-engage-preview-url="{{ compose_engage_preview_url }}"
data-engage-send-url="{{ compose_engage_send_url }}">
{% for msg in serialized_messages %}
<div class="compose-row {% if msg.outgoing %}is-out{% else %}is-in{% endif %}" data-ts="{{ msg.ts }}" data-message-id="{{ msg.id }}">
<div class="compose-row {% if msg.outgoing %}is-out{% else %}is-in{% endif %}" data-ts="{{ msg.ts }}" data-message-id="{{ msg.id }}"{% if msg.reply_to_id %} data-reply-to-id="{{ msg.reply_to_id }}"{% endif %} data-reply-snippet="{{ msg.display_text|default:msg.text|default:''|truncatechars:120|escape }}">
{% if msg.gap_fragments %}
{% with gap=msg.gap_fragments.0 %}
<p
@@ -256,6 +282,11 @@
{% endwith %}
{% endif %}
<article class="compose-bubble {% if msg.outgoing %}is-out{% else %}is-in{% endif %}">
{% if msg.reply_to_id %}
<div class="compose-reply-ref" data-reply-target-id="{{ msg.reply_to_id }}" data-reply-preview="{{ msg.reply_preview|default:''|escape }}">
<button type="button" class="compose-reply-link" title="Jump to referenced message"></button>
</div>
{% endif %}
<div class="compose-source-badge-wrap">
<span class="compose-source-badge source-{{ msg.source_service|default:'web'|lower }}">{{ msg.source_label }}</span>
</div>
@@ -336,6 +367,10 @@
</span>
{% endif %}
</p>
<button type="button" class="compose-reply-btn" title="Reply to this message" aria-label="Reply to this message">
<span class="icon is-small"><i class="fa-solid fa-reply"></i></span>
<span class="compose-reply-btn-label">Reply</span>
</button>
</article>
</div>
{% empty %}
@@ -365,6 +400,7 @@
<input type="hidden" name="render_mode" value="{{ render_mode }}">
<input type="hidden" name="limit" value="{{ limit }}">
<input type="hidden" name="panel_id" value="{{ panel_id }}">
<input type="hidden" name="reply_to_message_id" value="">
<input type="hidden" name="failsafe_arm" value="0">
<input type="hidden" name="failsafe_confirm" value="0">
<div class="compose-send-safety">
@@ -372,6 +408,11 @@
<input type="checkbox" class="manual-confirm"> Confirm Send
</label>
</div>
<div id="{{ panel_id }}-reply-banner" class="compose-reply-banner is-hidden">
<span class="compose-reply-banner-label">Replying to:</span>
<span id="{{ panel_id }}-reply-text" class="compose-reply-banner-text"></span>
<button type="button" id="{{ panel_id }}-reply-clear" class="button is-white is-small compose-reply-clear-btn">Clear</button>
</div>
<div class="compose-composer-capsule">
<textarea
id="{{ panel_id }}-textarea"
@@ -414,6 +455,13 @@
gap: 0.3rem;
margin-bottom: 0.5rem;
}
#{{ panel_id }} .compose-row.compose-reply-target {
animation: composeReplyFlash 1.1s ease-out;
}
#{{ panel_id }} .compose-row.compose-reply-selected .compose-bubble {
border-color: rgba(47, 79, 122, 0.6);
box-shadow: 0 0 0 2px rgba(47, 79, 122, 0.12);
}
#{{ panel_id }} .compose-row.is-in {
align-items: flex-start;
}
@@ -478,6 +526,56 @@
padding: 0.52rem 0.62rem;
box-shadow: none;
}
#{{ panel_id }} .compose-reply-ref {
margin-bottom: 0.28rem;
}
#{{ panel_id }} .compose-reply-link {
border: 0;
background: rgba(31, 41, 55, 0.06);
border-radius: 6px;
padding: 0.12rem 0.42rem;
font-size: 0.72rem;
line-height: 1.2;
color: #3b4b5e;
cursor: pointer;
max-width: 100%;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#{{ panel_id }} .compose-reply-link:hover {
background: rgba(31, 41, 55, 0.1);
}
#{{ panel_id }} .compose-reply-btn {
margin-top: 0.34rem;
border: 0;
background: transparent;
color: #5f6f82;
padding: 0.1rem 0.16rem;
height: 1.4rem;
min-height: 1.4rem;
display: inline-flex;
align-items: center;
gap: 0.15rem;
opacity: 0;
pointer-events: none;
transition: opacity 120ms ease;
}
#{{ panel_id }} .compose-reply-btn-label {
font-size: 0.68rem;
line-height: 1;
font-weight: 600;
}
#{{ panel_id }} .compose-row:hover .compose-reply-btn,
#{{ panel_id }} .compose-row.compose-reply-selected .compose-reply-btn,
#{{ panel_id }} .compose-reply-btn:focus-visible {
opacity: 1;
pointer-events: auto;
}
#{{ panel_id }} .compose-reply-btn:hover {
color: #2f4f7a;
}
#{{ panel_id }} .compose-source-badge-wrap {
display: flex;
justify-content: flex-start;
@@ -667,6 +765,47 @@
#{{ panel_id }} .compose-platform-select {
min-width: 11rem;
}
#{{ panel_id }} .compose-command-menu {
position: relative;
}
#{{ panel_id }} .compose-command-menu-panel {
position: absolute;
right: 0;
top: calc(100% + 0.3rem);
min-width: 14.5rem;
padding: 0.5rem;
border-radius: 8px;
border: 1px solid rgba(0, 0, 0, 0.14);
background: #fff;
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.12);
z-index: 9;
display: grid;
gap: 0.35rem;
}
#{{ panel_id }} .compose-command-menu-panel.is-hidden {
display: none;
}
#{{ panel_id }} .compose-command-option {
display: flex;
align-items: center;
gap: 0.45rem;
font-size: 0.76rem;
color: #334155;
}
#{{ panel_id }} .compose-command-option-title {
font-weight: 600;
}
#{{ panel_id }} .compose-command-option-token {
margin-left: auto;
color: #64748b;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.68rem;
}
#{{ panel_id }} .compose-command-settings-link {
margin-top: 0.2rem;
font-size: 0.72rem;
color: #2563eb;
}
#{{ panel_id }} .compose-gap-artifacts {
align-self: center;
width: min(92%, 34rem);
@@ -800,6 +939,38 @@
margin-bottom: 0.45rem;
color: #505050;
}
#{{ panel_id }} .compose-reply-banner {
margin-top: 0.42rem;
margin-bottom: 0.3rem;
display: flex;
align-items: center;
gap: 0.45rem;
padding: 0.3rem 0.45rem;
border: 1px solid rgba(47, 79, 122, 0.24);
border-radius: 7px;
background: rgba(238, 246, 255, 0.7);
}
#{{ panel_id }} .compose-reply-banner.is-hidden {
display: none;
}
#{{ panel_id }} .compose-reply-banner-label {
font-size: 0.72rem;
color: #34506f;
font-weight: 700;
}
#{{ panel_id }} .compose-reply-banner-text {
flex: 1 1 auto;
min-width: 0;
font-size: 0.75rem;
color: #213447;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#{{ panel_id }} .compose-reply-clear-btn {
border: 1px solid rgba(47, 79, 122, 0.25);
color: #2f4f7a;
}
#{{ panel_id }} .compose-status {
margin-top: 0.55rem;
min-height: 1.1rem;
@@ -1252,6 +1423,10 @@
50% { transform: translateX(2px); }
75% { transform: translateX(-1px); }
}
@keyframes composeReplyFlash {
0% { box-shadow: 0 0 0 0 rgba(47, 79, 122, 0.45); }
100% { box-shadow: 0 0 0 14px rgba(47, 79, 122, 0); }
}
@media (max-width: 768px) {
#{{ panel_id }} .compose-thread {
max-height: 52vh;
@@ -1294,6 +1469,10 @@
const hiddenService = document.getElementById(panelId + "-input-service");
const hiddenIdentifier = document.getElementById(panelId + "-input-identifier");
const hiddenPerson = document.getElementById(panelId + "-input-person");
const hiddenReplyTo = form.querySelector('input[name="reply_to_message_id"]');
const replyBanner = document.getElementById(panelId + "-reply-banner");
const replyBannerText = document.getElementById(panelId + "-reply-text");
const replyClearBtn = document.getElementById(panelId + "-reply-clear");
const renderMode = "{{ render_mode }}";
if (!thread || !form || !textarea) {
return;
@@ -1348,6 +1527,7 @@
lightboxIndex: -1,
seenMessageIds: new Set(),
replyTimingTimer: null,
replyTargetId: "",
};
window.giaComposePanels[panelId] = panelState;
const triggerButtons = Array.from(panel.querySelectorAll(".js-ai-trigger"));
@@ -1681,6 +1861,80 @@
});
});
};
const bindCommandMenu = function (rootNode) {
const scope = rootNode || panel;
if (!scope) {
return;
}
scope.querySelectorAll(".compose-command-menu").forEach(function (menu) {
if (menu.dataset.bound === "1") {
return;
}
menu.dataset.bound = "1";
const toggleButton = menu.querySelector(".compose-command-menu-toggle");
const menuPanel = menu.querySelector(".compose-command-menu-panel");
if (!toggleButton || !menuPanel) {
return;
}
const closeMenu = function () {
menuPanel.classList.add("is-hidden");
};
const openMenu = function () {
menuPanel.classList.remove("is-hidden");
};
toggleButton.addEventListener("click", function (ev) {
ev.preventDefault();
ev.stopPropagation();
if (menuPanel.classList.contains("is-hidden")) {
openMenu();
} else {
closeMenu();
}
});
document.addEventListener("click", function (ev) {
if (!menu.contains(ev.target)) {
closeMenu();
}
});
menu.querySelectorAll(".compose-command-toggle").forEach(function (checkbox) {
checkbox.addEventListener("change", async function () {
const toggleUrl = String(thread.dataset.toggleCommandUrl || "").trim();
const slug = String(checkbox.dataset.commandSlug || "").trim();
if (!toggleUrl || !slug) {
checkbox.checked = !checkbox.checked;
setStatus("Command toggle endpoint is unavailable.", "warning");
return;
}
const shouldEnable = !!checkbox.checked;
checkbox.disabled = true;
try {
const params = queryParams({
slug: slug,
enabled: shouldEnable ? "1" : "0",
});
const result = await postFormJson(toggleUrl, params);
if (!result.ok) {
checkbox.checked = !shouldEnable;
setStatus(
String(result.message || result.error || "Command update failed."),
String(result.level || "danger")
);
return;
}
setStatus(
String(result.message || (slug + (shouldEnable ? " enabled." : " disabled."))),
"success"
);
} catch (err) {
checkbox.checked = !shouldEnable;
setStatus("Failed to update command binding.", "danger");
} finally {
checkbox.disabled = false;
}
});
});
});
};
const ensureEmptyState = function (messageText) {
if (!thread) {
@@ -2060,6 +2314,12 @@
row.className = "compose-row " + (outgoing ? "is-out" : "is-in");
row.dataset.ts = String(msg.ts || 0);
row.dataset.minute = minuteBucketFromTs(msg.ts || 0);
row.dataset.replySnippet = normalizeSnippet(
msg.display_text || msg.text || (msg.image_url ? "" : "(no text)")
);
if (msg.reply_to_id) {
row.dataset.replyToId = String(msg.reply_to_id || "");
}
if (messageId) {
row.dataset.messageId = messageId;
panelState.seenMessageIds.add(messageId);
@@ -2069,6 +2329,19 @@
const bubble = document.createElement("article");
bubble.className = "compose-bubble " + (outgoing ? "is-out" : "is-in");
if (msg.reply_to_id) {
const replyRef = document.createElement("div");
replyRef.className = "compose-reply-ref";
replyRef.dataset.replyTargetId = String(msg.reply_to_id || "");
replyRef.dataset.replyPreview = String(msg.reply_preview || "");
const link = document.createElement("button");
link.type = "button";
link.className = "compose-reply-link";
link.title = "Jump to referenced message";
replyRef.appendChild(link);
bubble.appendChild(replyRef);
}
// Add source badge for client-side rendered messages
if (msg.source_label) {
const badgeWrap = document.createElement("div");
@@ -2178,6 +2451,14 @@
meta.appendChild(tickWrap);
}
bubble.appendChild(meta);
const replyBtn = document.createElement("button");
replyBtn.type = "button";
replyBtn.className = "compose-reply-btn";
replyBtn.title = "Reply to this message";
replyBtn.setAttribute("aria-label", "Reply to this message");
replyBtn.innerHTML =
'<span class="icon is-small"><i class="fa-solid fa-reply"></i></span><span class="compose-reply-btn-label">Reply</span>';
bubble.appendChild(replyBtn);
// If message carries receipt metadata, append dataset so the popover can use it.
if (msg.receipt_payload || msg.read_source_service || msg.read_by_identifier) {
@@ -2202,6 +2483,7 @@
row.appendChild(bubble);
insertRowByTs(row);
wireImageFallbacks(row);
bindReplyReferences(row);
updateGlanceFromMessage(msg);
};
@@ -2261,6 +2543,19 @@
// Delegate click on tick triggers inside thread
thread.addEventListener("click", function (ev) {
const replyBtn = ev.target.closest && ev.target.closest(".compose-reply-btn");
if (replyBtn) {
const row = replyBtn.closest(".compose-row");
if (!row) {
return;
}
const targetId = String(row.dataset.messageId || "").trim();
setReplyTarget(targetId, row.dataset.replySnippet || "");
if (textarea) {
textarea.focus();
}
return;
}
const btn = ev.target.closest && ev.target.closest('.js-receipt-trigger');
if (!btn) return;
if (activeReceiptBtn === btn && !receiptPopover.classList.contains('is-hidden')) {
@@ -2456,6 +2751,7 @@
}
applyMinuteGrouping();
bindHistorySyncButtons(panel);
bindCommandMenu(panel);
const setStatus = function (message, level) {
if (!statusBox) {
@@ -2551,6 +2847,135 @@
return params;
};
const normalizeSnippet = function (value) {
const compact = String(value || "").replace(/\s+/g, " ").trim();
if (!compact) {
return "(no text)";
}
if (compact.length <= 120) {
return compact;
}
return compact.slice(0, 117).trimEnd() + "...";
};
const rowByMessageId = function (messageId) {
const key = String(messageId || "").trim();
if (!key) {
return null;
}
return thread.querySelector('.compose-row[data-message-id="' + key + '"]');
};
const flashReplyTarget = function (row) {
if (!row) {
return;
}
row.classList.remove("compose-reply-target");
void row.offsetWidth;
row.classList.add("compose-reply-target");
window.setTimeout(function () {
row.classList.remove("compose-reply-target");
}, 1200);
};
const clearReplySelectionClass = function () {
thread.querySelectorAll(".compose-row.compose-reply-selected").forEach(function (row) {
row.classList.remove("compose-reply-selected");
});
};
const clearReplyTarget = function () {
panelState.replyTargetId = "";
if (hiddenReplyTo) {
hiddenReplyTo.value = "";
}
if (replyBanner) {
replyBanner.classList.add("is-hidden");
}
if (replyBannerText) {
replyBannerText.textContent = "";
}
clearReplySelectionClass();
};
const setReplyTarget = function (messageId, explicitPreview) {
const key = String(messageId || "").trim();
if (!key) {
clearReplyTarget();
return;
}
const row = rowByMessageId(key);
let preview = normalizeSnippet(explicitPreview || "");
if (row) {
const rowSnippet = normalizeSnippet(row.dataset.replySnippet || "");
if (rowSnippet && rowSnippet !== "(no text)") {
preview = rowSnippet;
}
}
panelState.replyTargetId = key;
if (hiddenReplyTo) {
hiddenReplyTo.value = key;
}
if (replyBannerText) {
replyBannerText.textContent = preview;
}
if (replyBanner) {
replyBanner.classList.remove("is-hidden");
}
clearReplySelectionClass();
if (row) {
row.classList.add("compose-reply-selected");
}
};
const bindReplyReferences = function (rootNode) {
const scope = rootNode || thread;
if (!scope) {
return;
}
scope.querySelectorAll(".compose-row").forEach(function (row) {
if (!row.dataset.replySnippet) {
const body = row.querySelector(".compose-body");
if (body) {
row.dataset.replySnippet = normalizeSnippet(body.textContent || "");
}
}
});
scope.querySelectorAll(".compose-reply-ref").forEach(function (ref) {
const button = ref.querySelector(".compose-reply-link");
const targetId = String(ref.dataset.replyTargetId || "").trim();
if (!button || !targetId) {
return;
}
const targetRow = rowByMessageId(targetId);
const inferredPreview = targetRow
? normalizeSnippet(targetRow.dataset.replySnippet || "")
: normalizeSnippet(ref.dataset.replyPreview || "");
button.textContent = "Reply to: " + inferredPreview;
if (button.dataset.bound === "1") {
return;
}
button.dataset.bound = "1";
button.addEventListener("click", function () {
const row = rowByMessageId(targetId);
if (!row) {
return;
}
row.scrollIntoView({ behavior: "smooth", block: "center" });
flashReplyTarget(row);
});
});
};
bindReplyReferences(panel);
if (replyClearBtn) {
replyClearBtn.addEventListener("click", function () {
clearReplyTarget();
if (textarea) {
textarea.focus();
}
});
}
const postFormJson = async function (url, params) {
const response = await fetch(url, {
method: "POST",
@@ -2619,6 +3044,7 @@
if (metaLine) {
metaLine.textContent = titleCase(service) + " · " + identifier;
}
clearReplyTarget();
if (panelState.socket) {
try {
panelState.socket.close();
@@ -3468,6 +3894,7 @@
if (result.ok) {
setStatus('', 'success');
textarea.value = '';
clearReplyTarget();
autosize();
flashCompose('is-send-success');
poll(true);
@@ -3552,6 +3979,7 @@
flashCompose("is-send-success");
setStatus("", "success");
textarea.value = "";
clearReplyTarget();
autosize();
poll(true);
} else {