Allow sharing conversations
This commit is contained in:
@@ -97,6 +97,10 @@
|
||||
<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 compose-export-toggle" aria-expanded="false">
|
||||
<span class="icon is-small"><i class="fa-solid fa-layer-group"></i></span>
|
||||
<span>Select/Export</span>
|
||||
</button>
|
||||
<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>
|
||||
@@ -252,6 +256,75 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div id="{{ panel_id }}-export" class="compose-export is-hidden">
|
||||
<div class="compose-export-head">
|
||||
<p class="compose-export-title">Conversation Range Export</p>
|
||||
<span id="{{ panel_id }}-export-summary" class="compose-export-summary">Select two messages to define a range.</span>
|
||||
</div>
|
||||
<div class="compose-export-controls">
|
||||
<div class="select is-small">
|
||||
<select id="{{ panel_id }}-export-scope">
|
||||
<option value="inside">Inside Range</option>
|
||||
<option value="outside">Outside Range</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="select is-small">
|
||||
<select id="{{ panel_id }}-export-format">
|
||||
<option value="plain">Plain Text</option>
|
||||
<option value="markdown">Markdown</option>
|
||||
<option value="share">Share Statements</option>
|
||||
<option value="jsonl">JSONL</option>
|
||||
<option value="csv">CSV</option>
|
||||
</select>
|
||||
</div>
|
||||
<details class="compose-export-voice">
|
||||
<summary>Speaker Labels</summary>
|
||||
<div class="compose-export-voice-grid">
|
||||
<div class="compose-export-voice-col">
|
||||
<p class="compose-export-voice-title">Outgoing</p>
|
||||
<label class="radio is-size-7"><input type="radio" name="{{ panel_id }}-voice-out" class="compose-export-voice-out" value="I said" checked> I said</label>
|
||||
<label class="radio is-size-7"><input type="radio" name="{{ panel_id }}-voice-out" class="compose-export-voice-out" value="You said"> You said</label>
|
||||
<label class="radio is-size-7"><input type="radio" name="{{ panel_id }}-voice-out" class="compose-export-voice-out" value="We said"> We said</label>
|
||||
<label class="radio is-size-7"><input type="radio" name="{{ panel_id }}-voice-out" class="compose-export-voice-out" value="They said"> They said</label>
|
||||
</div>
|
||||
<div class="compose-export-voice-col">
|
||||
<p class="compose-export-voice-title">Incoming</p>
|
||||
<label class="radio is-size-7"><input type="radio" name="{{ panel_id }}-voice-in" class="compose-export-voice-in" value="They said" checked> They said</label>
|
||||
<label class="radio is-size-7"><input type="radio" name="{{ panel_id }}-voice-in" class="compose-export-voice-in" value="You said"> You said</label>
|
||||
<label class="radio is-size-7"><input type="radio" name="{{ panel_id }}-voice-in" class="compose-export-voice-in" value="I said"> I said</label>
|
||||
<label class="radio is-size-7"><input type="radio" name="{{ panel_id }}-voice-in" class="compose-export-voice-in" value="We said"> We said</label>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<details class="compose-export-fields">
|
||||
<summary>Fields</summary>
|
||||
<div class="compose-export-fields-grid">
|
||||
<label class="checkbox is-size-7"><input type="checkbox" class="compose-export-field" value="text" checked> Text</label>
|
||||
<label class="checkbox is-size-7"><input type="checkbox" class="compose-export-field" value="time" checked> Time</label>
|
||||
<label class="checkbox is-size-7"><input type="checkbox" class="compose-export-field" value="sender" checked> Sender</label>
|
||||
<label class="checkbox is-size-7"><input type="checkbox" class="compose-export-field" value="source_service"> Source</label>
|
||||
<label class="checkbox is-size-7"><input type="checkbox" class="compose-export-field" value="source_label"> Source Label</label>
|
||||
<label class="checkbox is-size-7"><input type="checkbox" class="compose-export-field" value="direction"> Direction</label>
|
||||
<label class="checkbox is-size-7"><input type="checkbox" class="compose-export-field" value="message_id"> Message ID</label>
|
||||
<label class="checkbox is-size-7"><input type="checkbox" class="compose-export-field" value="source_message_id"> Source Message ID</label>
|
||||
<label class="checkbox is-size-7"><input type="checkbox" class="compose-export-field" value="ts"> Timestamp</label>
|
||||
</div>
|
||||
</details>
|
||||
<button type="button" id="{{ panel_id }}-export-copy" class="button is-small is-link is-light">Copy</button>
|
||||
<button type="button" id="{{ panel_id }}-export-clear" class="button is-small is-light">Clear</button>
|
||||
</div>
|
||||
<div class="compose-export-presets">
|
||||
<span class="compose-export-help">Click one message for start, second for end. Third click starts a new range.</span>
|
||||
</div>
|
||||
<textarea
|
||||
id="{{ panel_id }}-export-buffer"
|
||||
class="textarea is-small compose-export-buffer"
|
||||
readonly
|
||||
spellcheck="false"
|
||||
rows="10"
|
||||
aria-label="Export conversation history"></textarea>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="{{ panel_id }}-thread"
|
||||
class="compose-thread"
|
||||
@@ -270,11 +343,13 @@
|
||||
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 }}"{% 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 }}">
|
||||
<div class="compose-row {% if msg.outgoing %}is-out{% else %}is-in{% endif %}" data-ts="{{ msg.ts }}" data-message-id="{{ msg.id }}" data-author="{{ msg.author|default:''|escape }}" data-display-ts="{{ msg.display_ts|escape }}" data-source-service="{{ msg.source_service|default:''|escape }}" data-source-label="{{ msg.source_label|default:''|escape }}" data-source-message-id="{{ msg.source_message_id|default:''|escape }}" data-direction="{% if msg.outgoing %}outgoing{% else %}incoming{% endif %}"{% 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
|
||||
class="compose-latency-chip"
|
||||
data-lag-ms="{{ gap.lag_ms|default:0 }}"
|
||||
data-turn="{% if msg.outgoing %}outgoing{% else %}incoming{% endif %}"
|
||||
title="{{ gap.focus|default:'Opponent delay between turns.' }} · Latency {{ gap.lag|default:'-' }}{% if gap.calculation %} · How it is calculated: {{ gap.calculation }}{% endif %}{% if gap.psychology %} · Psychological interpretation: {{ gap.psychology }}{% endif %}">
|
||||
<span class="icon is-small"><i class="fa-regular fa-clock"></i></span>
|
||||
<span class="compose-latency-val">{{ gap.lag|default:"-" }}</span>
|
||||
@@ -507,17 +582,36 @@
|
||||
gap: 0.2rem;
|
||||
background: rgba(247, 249, 252, 0.88);
|
||||
border: 1px solid rgba(103, 121, 145, 0.16);
|
||||
position: relative;
|
||||
--latency-pair-color: rgba(103, 121, 145, 0.45);
|
||||
z-index: 2;
|
||||
}
|
||||
#{{ panel_id }} .compose-latency-val {
|
||||
font-weight: 600;
|
||||
color: #58667a;
|
||||
}
|
||||
#{{ panel_id }} .compose-latency-chip::before,
|
||||
#{{ panel_id }} .compose-latency-chip::after {
|
||||
content: "";
|
||||
width: 0.95rem;
|
||||
height: 1px;
|
||||
background: rgba(101, 119, 141, 0.28);
|
||||
#{{ panel_id }} .compose-latency-chip.is-pace-matched {
|
||||
--latency-pair-color: rgba(43, 133, 74, 0.85);
|
||||
border-color: rgba(43, 133, 74, 0.52);
|
||||
background: rgba(234, 251, 240, 0.98);
|
||||
color: #236140;
|
||||
}
|
||||
#{{ panel_id }} .compose-latency-chip.is-pace-fast {
|
||||
--latency-pair-color: rgba(39, 98, 189, 0.82);
|
||||
border-color: rgba(39, 98, 189, 0.45);
|
||||
background: rgba(234, 244, 255, 0.98);
|
||||
color: #234d8f;
|
||||
}
|
||||
#{{ panel_id }} .compose-latency-chip.is-pace-slow {
|
||||
--latency-pair-color: rgba(191, 95, 36, 0.85);
|
||||
border-color: rgba(191, 95, 36, 0.5);
|
||||
background: rgba(255, 242, 232, 0.98);
|
||||
color: #8e4620;
|
||||
}
|
||||
#{{ panel_id }} .compose-latency-chip.is-pace-matched .compose-latency-val,
|
||||
#{{ panel_id }} .compose-latency-chip.is-pace-fast .compose-latency-val,
|
||||
#{{ panel_id }} .compose-latency-chip.is-pace-slow .compose-latency-val {
|
||||
color: inherit;
|
||||
}
|
||||
#{{ panel_id }} .compose-bubble {
|
||||
max-width: min(85%, 46rem);
|
||||
@@ -990,24 +1084,31 @@
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
max-width: 100%;
|
||||
border: 1px solid rgba(0, 0, 0, 0.14);
|
||||
border: 1px solid rgba(47, 79, 122, 0.32);
|
||||
border-radius: 999px;
|
||||
padding: 0.12rem 0.45rem;
|
||||
background: rgba(250, 252, 255, 0.95);
|
||||
padding: 0.14rem 0.5rem;
|
||||
background: #f5f9ff;
|
||||
font-size: 0.64rem;
|
||||
line-height: 1.2;
|
||||
min-width: 0;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8);
|
||||
position: relative;
|
||||
}
|
||||
#{{ panel_id }} .compose-glance-item.is-equal-size {
|
||||
width: 10.6rem;
|
||||
max-width: 10.6rem;
|
||||
width: 12.8rem;
|
||||
max-width: 12.8rem;
|
||||
justify-content: space-between;
|
||||
}
|
||||
#{{ panel_id }} .compose-glance-item.compose-glance-item-reply {
|
||||
gap: 0.22rem;
|
||||
}
|
||||
#{{ panel_id }} .compose-glance-item.compose-glance-item-reply .compose-glance-val {
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
#{{ panel_id }} .compose-reply-mini-track {
|
||||
width: 2.35rem;
|
||||
height: 0.22rem;
|
||||
@@ -1034,6 +1135,11 @@
|
||||
color: #5b6a7c;
|
||||
white-space: nowrap;
|
||||
}
|
||||
#{{ panel_id }} .compose-glance-key::after {
|
||||
content: ":";
|
||||
margin-left: 0.14rem;
|
||||
color: #7a8ca3;
|
||||
}
|
||||
#{{ panel_id }} .compose-glance-val {
|
||||
color: #26384f;
|
||||
font-weight: 700;
|
||||
@@ -1041,6 +1147,116 @@
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
#{{ panel_id }} .compose-row.is-range-anchor .compose-bubble {
|
||||
border-color: rgba(35, 84, 175, 0.68);
|
||||
box-shadow: 0 0 0 2px rgba(35, 84, 175, 0.12);
|
||||
}
|
||||
#{{ panel_id }} .compose-row.is-range-selected .compose-bubble {
|
||||
border-color: rgba(53, 124, 77, 0.44);
|
||||
background: linear-gradient(180deg, rgba(239, 253, 244, 0.95), rgba(255, 255, 255, 0.98));
|
||||
}
|
||||
#{{ panel_id }} .compose-export {
|
||||
margin-top: 0.4rem;
|
||||
margin-bottom: 0.45rem;
|
||||
border: 1px solid rgba(0, 0, 0, 0.14);
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem;
|
||||
background: linear-gradient(180deg, rgba(248, 252, 255, 0.9), rgba(255, 255, 255, 0.98));
|
||||
}
|
||||
#{{ panel_id }} .compose-export.is-hidden {
|
||||
display: none;
|
||||
}
|
||||
#{{ panel_id }} .compose-export-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
#{{ panel_id }} .compose-export-title {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
color: #233651;
|
||||
}
|
||||
#{{ panel_id }} .compose-export-summary {
|
||||
font-size: 0.68rem;
|
||||
color: #5b6a7c;
|
||||
}
|
||||
#{{ panel_id }} .compose-export-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
#{{ panel_id }} .compose-export-fields {
|
||||
border: 1px solid rgba(0, 0, 0, 0.12);
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
padding: 0.18rem 0.34rem;
|
||||
}
|
||||
#{{ panel_id }} .compose-export-voice {
|
||||
border: 1px solid rgba(0, 0, 0, 0.12);
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
padding: 0.18rem 0.34rem;
|
||||
}
|
||||
#{{ panel_id }} .compose-export-voice > summary {
|
||||
cursor: pointer;
|
||||
font-size: 0.68rem;
|
||||
color: #2b3d56;
|
||||
user-select: none;
|
||||
}
|
||||
#{{ panel_id }} .compose-export-voice-grid {
|
||||
margin-top: 0.28rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.2rem 0.6rem;
|
||||
min-width: min(20rem, 72vw);
|
||||
}
|
||||
#{{ panel_id }} .compose-export-voice-col {
|
||||
display: grid;
|
||||
gap: 0.14rem;
|
||||
}
|
||||
#{{ panel_id }} .compose-export-voice-title {
|
||||
margin: 0 0 0.08rem 0;
|
||||
font-size: 0.64rem;
|
||||
color: #5b6a7c;
|
||||
font-weight: 700;
|
||||
}
|
||||
#{{ panel_id }} .compose-export-fields > summary {
|
||||
cursor: pointer;
|
||||
font-size: 0.68rem;
|
||||
color: #2b3d56;
|
||||
user-select: none;
|
||||
}
|
||||
#{{ panel_id }} .compose-export-fields-grid {
|
||||
margin-top: 0.28rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.24rem 0.5rem;
|
||||
min-width: min(26rem, 72vw);
|
||||
}
|
||||
#{{ panel_id }} .compose-export-presets {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
#{{ panel_id }} .compose-export-help {
|
||||
font-size: 0.64rem;
|
||||
color: #657283;
|
||||
}
|
||||
#{{ panel_id }} .compose-export-buffer {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.35;
|
||||
white-space: pre;
|
||||
overflow: auto;
|
||||
}
|
||||
#{{ panel_id }} .compose-status-line {
|
||||
margin: 0;
|
||||
font-size: 0.76rem;
|
||||
@@ -1487,6 +1703,17 @@
|
||||
const lightboxImage = document.getElementById(panelId + "-lightbox-image");
|
||||
const lightboxPrev = document.getElementById(panelId + "-lightbox-prev");
|
||||
const lightboxNext = document.getElementById(panelId + "-lightbox-next");
|
||||
const exportToggle = panel.querySelector(".compose-export-toggle");
|
||||
const exportBox = document.getElementById(panelId + "-export");
|
||||
const exportSummary = document.getElementById(panelId + "-export-summary");
|
||||
const exportScope = document.getElementById(panelId + "-export-scope");
|
||||
const exportFormat = document.getElementById(panelId + "-export-format");
|
||||
const exportVoiceOut = Array.from(panel.querySelectorAll(".compose-export-voice-out"));
|
||||
const exportVoiceIn = Array.from(panel.querySelectorAll(".compose-export-voice-in"));
|
||||
const exportFieldChecks = Array.from(panel.querySelectorAll(".compose-export-field"));
|
||||
const exportCopy = document.getElementById(panelId + "-export-copy");
|
||||
const exportClear = document.getElementById(panelId + "-export-clear");
|
||||
const exportBuffer = document.getElementById(panelId + "-export-buffer");
|
||||
const csrfToken = "{{ csrf_token }}";
|
||||
if (lightbox && lightbox.parentElement !== document.body) {
|
||||
document.body.appendChild(lightbox);
|
||||
@@ -1528,6 +1755,9 @@
|
||||
seenMessageIds: new Set(),
|
||||
replyTimingTimer: null,
|
||||
replyTargetId: "",
|
||||
rangeStartId: "",
|
||||
rangeEndId: "",
|
||||
rangeMode: "inside",
|
||||
};
|
||||
window.giaComposePanels[panelId] = panelState;
|
||||
const triggerButtons = Array.from(panel.querySelectorAll(".js-ai-trigger"));
|
||||
@@ -2187,6 +2417,79 @@
|
||||
chip.classList.toggle("is-over-target", !!replyTimingState.isOverTarget);
|
||||
};
|
||||
|
||||
const resetLatencyPacingState = function (chip) {
|
||||
if (!chip || !chip.classList) {
|
||||
return;
|
||||
}
|
||||
chip.classList.remove(
|
||||
"is-pace-matched",
|
||||
"is-pace-fast",
|
||||
"is-pace-slow"
|
||||
);
|
||||
if (chip.dataset) {
|
||||
chip.dataset.pacingNote = "";
|
||||
}
|
||||
if (chip.dataset && chip.dataset.baseTitle !== undefined) {
|
||||
chip.title = chip.dataset.baseTitle || "";
|
||||
}
|
||||
};
|
||||
|
||||
const applyLatencyPacingPairs = function () {
|
||||
if (!thread) {
|
||||
return;
|
||||
}
|
||||
const chips = Array.from(thread.querySelectorAll(".compose-latency-chip"));
|
||||
chips.forEach(function (chip) {
|
||||
if (!chip.dataset.baseTitle) {
|
||||
chip.dataset.baseTitle = String(chip.title || "");
|
||||
}
|
||||
resetLatencyPacingState(chip);
|
||||
});
|
||||
if (chips.length < 2) {
|
||||
return;
|
||||
}
|
||||
for (let index = 1; index < chips.length; index += 1) {
|
||||
const prev = chips[index - 1];
|
||||
const curr = chips[index];
|
||||
const prevMs = toInt(prev.dataset.lagMs || 0);
|
||||
const currMs = toInt(curr.dataset.lagMs || 0);
|
||||
const prevTurn = String(prev.dataset.turn || "");
|
||||
const currTurn = String(curr.dataset.turn || "");
|
||||
if (!prevMs || !currMs || !prevTurn || !currTurn || prevTurn === currTurn) {
|
||||
continue;
|
||||
}
|
||||
const ratio = currMs / prevMs;
|
||||
const pct = Math.max(0, Math.round(ratio * 100));
|
||||
let paceClass = "is-pace-matched";
|
||||
let paceLabel = "Matched turn pacing";
|
||||
if (pct < 80) {
|
||||
paceClass = "is-pace-fast";
|
||||
paceLabel = "Faster than the previous turn";
|
||||
} else if (pct > 125) {
|
||||
paceClass = "is-pace-slow";
|
||||
paceLabel = "Slower than the previous turn";
|
||||
}
|
||||
if (
|
||||
!prev.classList.contains("is-pace-matched")
|
||||
&& !prev.classList.contains("is-pace-fast")
|
||||
&& !prev.classList.contains("is-pace-slow")
|
||||
) {
|
||||
prev.classList.add(paceClass);
|
||||
}
|
||||
curr.classList.add(paceClass);
|
||||
if (!String(prev.dataset.pacingNote || "").trim()) {
|
||||
prev.dataset.pacingNote = "Pacing: " + paceLabel;
|
||||
}
|
||||
prev.title = [String(prev.dataset.baseTitle || ""), String(prev.dataset.pacingNote || "")]
|
||||
.filter(Boolean)
|
||||
.join(" | ");
|
||||
curr.dataset.pacingNote = "Pacing: " + paceLabel;
|
||||
curr.title = [String(curr.dataset.baseTitle || ""), "Pacing: " + paceLabel]
|
||||
.filter(Boolean)
|
||||
.join(" | ");
|
||||
}
|
||||
};
|
||||
|
||||
const updateGlanceFromState = function () {
|
||||
const items = [];
|
||||
if (glanceState.gap) {
|
||||
@@ -2218,6 +2521,20 @@
|
||||
url: insightUrlForMetric(metricSlug),
|
||||
});
|
||||
});
|
||||
if (!glanceState.metrics || !glanceState.metrics.length) {
|
||||
items.push({
|
||||
label: "Stability Score",
|
||||
value: "n/a",
|
||||
tooltip: "No stability score available yet for this conversation.",
|
||||
url: "",
|
||||
});
|
||||
items.push({
|
||||
label: "Stability Confidence",
|
||||
value: "n/a",
|
||||
tooltip: "No stability confidence available yet for this conversation.",
|
||||
url: "",
|
||||
});
|
||||
}
|
||||
renderGlanceItems(items);
|
||||
};
|
||||
|
||||
@@ -2263,6 +2580,8 @@
|
||||
}
|
||||
const chip = document.createElement("p");
|
||||
chip.className = "compose-latency-chip";
|
||||
chip.dataset.lagMs = String(gap.lag_ms || 0);
|
||||
chip.dataset.turn = msg.outgoing ? "outgoing" : "incoming";
|
||||
chip.title = latencyTooltip(gap);
|
||||
const icon = document.createElement("span");
|
||||
icon.className = "icon is-small";
|
||||
@@ -2317,6 +2636,12 @@
|
||||
row.dataset.replySnippet = normalizeSnippet(
|
||||
msg.display_text || msg.text || (msg.image_url ? "" : "(no text)")
|
||||
);
|
||||
row.dataset.author = String(msg.author || "");
|
||||
row.dataset.displayTs = String(msg.display_ts || msg.ts || "");
|
||||
row.dataset.sourceService = String(msg.source_service || "");
|
||||
row.dataset.sourceLabel = String(msg.source_label || "");
|
||||
row.dataset.sourceMessageId = String(msg.source_message_id || "");
|
||||
row.dataset.direction = outgoing ? "outgoing" : "incoming";
|
||||
if (msg.reply_to_id) {
|
||||
row.dataset.replyToId = String(msg.reply_to_id || "");
|
||||
}
|
||||
@@ -2482,6 +2807,7 @@
|
||||
}
|
||||
row.appendChild(bubble);
|
||||
insertRowByTs(row);
|
||||
applyLatencyPacingPairs();
|
||||
wireImageFallbacks(row);
|
||||
bindReplyReferences(row);
|
||||
updateGlanceFromMessage(msg);
|
||||
@@ -2556,6 +2882,16 @@
|
||||
}
|
||||
return;
|
||||
}
|
||||
const rowClick = ev.target.closest && ev.target.closest(".compose-row[data-message-id]");
|
||||
if (
|
||||
rowClick
|
||||
&& exportBox
|
||||
&& !exportBox.classList.contains("is-hidden")
|
||||
&& !ev.target.closest(".compose-reply-link")
|
||||
) {
|
||||
setRangeAnchor(rowClick.dataset.messageId || "");
|
||||
return;
|
||||
}
|
||||
const btn = ev.target.closest && ev.target.closest('.js-receipt-trigger');
|
||||
if (!btn) return;
|
||||
if (activeReceiptBtn === btn && !receiptPopover.classList.contains('is-hidden')) {
|
||||
@@ -2631,6 +2967,7 @@
|
||||
scrollToBottom(shouldStick);
|
||||
}
|
||||
updateReplyTimingUi();
|
||||
updateExportBuffer();
|
||||
};
|
||||
|
||||
const applyTyping = function (typingPayload) {
|
||||
@@ -2866,6 +3203,344 @@
|
||||
return thread.querySelector('.compose-row[data-message-id="' + key + '"]');
|
||||
};
|
||||
|
||||
const allMessageRows = function () {
|
||||
return Array.from(thread.querySelectorAll(".compose-row[data-message-id]"));
|
||||
};
|
||||
|
||||
const selectedRangeRows = function () {
|
||||
const rows = allMessageRows();
|
||||
if (!rows.length || !panelState.rangeStartId) {
|
||||
return [];
|
||||
}
|
||||
const startIndex = rows.findIndex(function (row) {
|
||||
return String(row.dataset.messageId || "") === String(panelState.rangeStartId || "");
|
||||
});
|
||||
if (startIndex < 0) {
|
||||
return [];
|
||||
}
|
||||
let endIndex = startIndex;
|
||||
if (panelState.rangeEndId) {
|
||||
const idx = rows.findIndex(function (row) {
|
||||
return String(row.dataset.messageId || "") === String(panelState.rangeEndId || "");
|
||||
});
|
||||
if (idx >= 0) {
|
||||
endIndex = idx;
|
||||
}
|
||||
}
|
||||
const lo = Math.min(startIndex, endIndex);
|
||||
const hi = Math.max(startIndex, endIndex);
|
||||
if (String(panelState.rangeMode || "inside") === "outside") {
|
||||
return rows.filter(function (_row, idx) {
|
||||
return idx < lo || idx > hi;
|
||||
});
|
||||
}
|
||||
return rows.filter(function (_row, idx) {
|
||||
return idx >= lo && idx <= hi;
|
||||
});
|
||||
};
|
||||
|
||||
const updateRangeSelectionUi = function () {
|
||||
const selectedSet = new Set(
|
||||
selectedRangeRows().map(function (row) {
|
||||
return String(row.dataset.messageId || "");
|
||||
})
|
||||
);
|
||||
allMessageRows().forEach(function (row) {
|
||||
const id = String(row.dataset.messageId || "");
|
||||
row.classList.toggle("is-range-selected", selectedSet.has(id));
|
||||
row.classList.toggle("is-range-anchor", id === String(panelState.rangeStartId || "") || id === String(panelState.rangeEndId || ""));
|
||||
});
|
||||
};
|
||||
|
||||
const messageRowsToExportRecords = function () {
|
||||
return selectedRangeRows().map(function (row) {
|
||||
const bodyNode = row.querySelector(".compose-body");
|
||||
const text = String(bodyNode ? bodyNode.textContent || "" : "").trim();
|
||||
const authorRaw = String(row.dataset.author || "").trim();
|
||||
const author = authorRaw || (row.classList.contains("is-out") ? "USER" : "CONTACT");
|
||||
const when = String(row.dataset.displayTs || "").trim();
|
||||
return {
|
||||
message_id: String(row.dataset.messageId || ""),
|
||||
ts: toInt(row.dataset.ts || 0),
|
||||
sender: author,
|
||||
time: when,
|
||||
direction: String(row.dataset.direction || (row.classList.contains("is-out") ? "outgoing" : "incoming")),
|
||||
source_service: String(row.dataset.sourceService || "").trim(),
|
||||
source_label: String(row.dataset.sourceLabel || "").trim(),
|
||||
source_message_id: String(row.dataset.sourceMessageId || "").trim(),
|
||||
text: text || "(no text)",
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const selectedExportFields = function () {
|
||||
const defaults = ["text", "time", "sender"];
|
||||
const picked = exportFieldChecks
|
||||
.filter(function (node) { return !!(node && node.checked); })
|
||||
.map(function (node) { return String(node.value || "").trim(); })
|
||||
.filter(Boolean);
|
||||
return picked.length ? picked : defaults;
|
||||
};
|
||||
|
||||
const timeFieldNode = function () {
|
||||
return exportFieldChecks.find(function (node) {
|
||||
return String(node.value || "").trim() === "time";
|
||||
}) || null;
|
||||
};
|
||||
|
||||
const syncExportFieldLocks = function () {
|
||||
const format = String(exportFormat && exportFormat.value ? exportFormat.value : "plain");
|
||||
const timeNode = timeFieldNode();
|
||||
if (!timeNode) {
|
||||
return;
|
||||
}
|
||||
if (format === "share") {
|
||||
timeNode.checked = true;
|
||||
timeNode.disabled = true;
|
||||
} else {
|
||||
timeNode.disabled = false;
|
||||
}
|
||||
};
|
||||
|
||||
const orderedExportFields = function (fields) {
|
||||
const seen = new Set();
|
||||
const input = Array.isArray(fields) ? fields.slice() : [];
|
||||
const normalized = input.filter(function (field) {
|
||||
const key = String(field || "").trim();
|
||||
if (!key || seen.has(key)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
const priority = ["time", "sender"];
|
||||
const ordered = [];
|
||||
priority.forEach(function (key) {
|
||||
if (normalized.includes(key)) {
|
||||
ordered.push(key);
|
||||
}
|
||||
});
|
||||
normalized.forEach(function (key) {
|
||||
if (!ordered.includes(key)) {
|
||||
ordered.push(key);
|
||||
}
|
||||
});
|
||||
return ordered;
|
||||
};
|
||||
|
||||
const selectedVoiceLabel = function (nodes, fallback) {
|
||||
const match = (nodes || []).find(function (node) {
|
||||
return !!(node && node.checked);
|
||||
});
|
||||
return String(match && match.value ? match.value : fallback || "").trim();
|
||||
};
|
||||
|
||||
const buildShareStatements = function (records) {
|
||||
const outLabel = selectedVoiceLabel(exportVoiceOut, "I said");
|
||||
const inLabel = selectedVoiceLabel(exportVoiceIn, "They said");
|
||||
const lines = [];
|
||||
let previous = null;
|
||||
records.forEach(function (row) {
|
||||
const speaker = row.direction === "outgoing" ? "you" : "they";
|
||||
const speakerLabel = speaker === "you" ? (outLabel + ": ") : (inLabel + ": ");
|
||||
const timePrefix = row.time ? ("[" + row.time + "] ") : "";
|
||||
if (!previous) {
|
||||
lines.push(timePrefix + speakerLabel + row.text);
|
||||
previous = row;
|
||||
return;
|
||||
}
|
||||
const deltaMs = Math.max(0, toInt(row.ts) - toInt(previous.ts));
|
||||
const waitLabel = formatElapsedCompact(deltaMs || 0);
|
||||
const phrasing = speaker === "you"
|
||||
? ("After " + waitLabel + ", " + outLabel + ": ")
|
||||
: ("After " + waitLabel + ", " + inLabel + ": ");
|
||||
lines.push(timePrefix + phrasing + row.text);
|
||||
previous = row;
|
||||
});
|
||||
return lines.join("\n");
|
||||
};
|
||||
|
||||
const toCsvCell = function (value) {
|
||||
const text = String(value || "");
|
||||
if (!/[\",\n]/.test(text)) {
|
||||
return text;
|
||||
}
|
||||
return '"' + text.replace(/"/g, '""') + '"';
|
||||
};
|
||||
|
||||
const exportTextForRecords = function (records) {
|
||||
const format = String(exportFormat && exportFormat.value ? exportFormat.value : "plain");
|
||||
const fields = orderedExportFields(selectedExportFields());
|
||||
if (!records.length) {
|
||||
return "";
|
||||
}
|
||||
if (format === "share") {
|
||||
return buildShareStatements(records);
|
||||
}
|
||||
if (format === "jsonl") {
|
||||
return records.map(function (row) {
|
||||
const payload = {};
|
||||
fields.forEach(function (field) {
|
||||
payload[field] = row[field];
|
||||
});
|
||||
return JSON.stringify(payload);
|
||||
}).join("\n");
|
||||
}
|
||||
if (format === "csv") {
|
||||
const header = fields.slice();
|
||||
const rows = [header.map(toCsvCell).join(",")];
|
||||
records.forEach(function (row) {
|
||||
const line = fields.map(function (field) {
|
||||
return toCsvCell(row[field]);
|
||||
});
|
||||
rows.push(line.join(","));
|
||||
});
|
||||
return rows.join("\n");
|
||||
}
|
||||
if (format === "markdown") {
|
||||
return records.map(function (row) {
|
||||
const meta = fields.filter(function (field) { return field !== "text"; }).map(function (field) {
|
||||
const value = row[field];
|
||||
if (value === undefined || value === null || value === "") {
|
||||
return "";
|
||||
}
|
||||
if (field === "time") {
|
||||
return "[" + String(value) + "]";
|
||||
}
|
||||
return "**" + field + "**=" + String(value);
|
||||
}).filter(Boolean).join(" ");
|
||||
const text = fields.includes("text") ? String(row.text || "") : "";
|
||||
if (meta && text) {
|
||||
return "- " + meta + " | " + text;
|
||||
}
|
||||
return "- " + (meta || text);
|
||||
}).join("\n");
|
||||
}
|
||||
return records.map(function (row) {
|
||||
const parts = [];
|
||||
fields.forEach(function (field) {
|
||||
const value = row[field];
|
||||
if (value === undefined || value === null || value === "") {
|
||||
return;
|
||||
}
|
||||
if (field === "text") {
|
||||
parts.push(String(value));
|
||||
} else if (field === "time") {
|
||||
parts.push("[" + String(value) + "]");
|
||||
} else {
|
||||
parts.push(field + "=" + String(value));
|
||||
}
|
||||
});
|
||||
return parts.join(" ");
|
||||
}).join("\n");
|
||||
};
|
||||
|
||||
const updateExportBuffer = function () {
|
||||
if (!exportBuffer) {
|
||||
return;
|
||||
}
|
||||
const records = messageRowsToExportRecords();
|
||||
exportBuffer.value = exportTextForRecords(records);
|
||||
if (exportSummary) {
|
||||
const count = records.length;
|
||||
const modeLabel = String(panelState.rangeMode || "inside");
|
||||
if (!panelState.rangeStartId) {
|
||||
exportSummary.textContent = "Select two messages to define a range.";
|
||||
} else if (!panelState.rangeEndId) {
|
||||
exportSummary.textContent = "Start set. Pick an end message.";
|
||||
} else {
|
||||
exportSummary.textContent = count + " messages selected (" + modeLabel + ").";
|
||||
}
|
||||
}
|
||||
updateRangeSelectionUi();
|
||||
};
|
||||
|
||||
const setRangeAnchor = function (messageId) {
|
||||
const id = String(messageId || "").trim();
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
if (!panelState.rangeStartId || (panelState.rangeStartId && panelState.rangeEndId)) {
|
||||
panelState.rangeStartId = id;
|
||||
panelState.rangeEndId = "";
|
||||
} else if (panelState.rangeStartId && !panelState.rangeEndId) {
|
||||
panelState.rangeEndId = id;
|
||||
}
|
||||
updateExportBuffer();
|
||||
};
|
||||
|
||||
const clearRangeSelection = function () {
|
||||
panelState.rangeStartId = "";
|
||||
panelState.rangeEndId = "";
|
||||
updateExportBuffer();
|
||||
};
|
||||
|
||||
const initExportUi = function () {
|
||||
updateExportBuffer();
|
||||
if (exportToggle && exportBox) {
|
||||
exportToggle.addEventListener("click", function () {
|
||||
const hidden = exportBox.classList.toggle("is-hidden");
|
||||
exportToggle.setAttribute("aria-expanded", hidden ? "false" : "true");
|
||||
if (!hidden && exportBuffer) {
|
||||
exportBuffer.focus();
|
||||
exportBuffer.select();
|
||||
}
|
||||
});
|
||||
}
|
||||
if (exportScope) {
|
||||
exportScope.addEventListener("change", function () {
|
||||
panelState.rangeMode = String(exportScope.value || "inside");
|
||||
updateExportBuffer();
|
||||
});
|
||||
}
|
||||
if (exportFormat) {
|
||||
exportFormat.addEventListener("change", function () {
|
||||
syncExportFieldLocks();
|
||||
updateExportBuffer();
|
||||
});
|
||||
}
|
||||
exportVoiceOut.forEach(function (node) { node.addEventListener("change", updateExportBuffer); });
|
||||
exportVoiceIn.forEach(function (node) { node.addEventListener("change", updateExportBuffer); });
|
||||
exportFieldChecks.forEach(function (node) {
|
||||
node.addEventListener("change", updateExportBuffer);
|
||||
});
|
||||
if (exportCopy) {
|
||||
exportCopy.addEventListener("click", async function () {
|
||||
if (!exportBuffer) {
|
||||
return;
|
||||
}
|
||||
const text = String(exportBuffer.value || "");
|
||||
if (!text) {
|
||||
setStatus("No export text available for current selection.", "warning");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setStatus("Copied selected conversation export.", "success");
|
||||
} catch (err) {
|
||||
exportBuffer.focus();
|
||||
exportBuffer.select();
|
||||
setStatus("Clipboard blocked. Press Ctrl+C to copy selected export text.", "warning");
|
||||
}
|
||||
});
|
||||
}
|
||||
if (exportClear) {
|
||||
exportClear.addEventListener("click", function () {
|
||||
clearRangeSelection();
|
||||
});
|
||||
}
|
||||
if (exportBuffer) {
|
||||
exportBuffer.addEventListener("keydown", function (event) {
|
||||
if ((event.ctrlKey || event.metaKey) && String(event.key || "").toLowerCase() === "a") {
|
||||
event.preventDefault();
|
||||
exportBuffer.select();
|
||||
}
|
||||
});
|
||||
}
|
||||
syncExportFieldLocks();
|
||||
updateExportBuffer();
|
||||
};
|
||||
|
||||
const flashReplyTarget = function (row) {
|
||||
if (!row) {
|
||||
return;
|
||||
@@ -2967,6 +3642,7 @@
|
||||
});
|
||||
};
|
||||
bindReplyReferences(panel);
|
||||
initExportUi();
|
||||
if (replyClearBtn) {
|
||||
replyClearBtn.addEventListener("click", function () {
|
||||
clearReplyTarget();
|
||||
@@ -3045,6 +3721,7 @@
|
||||
metaLine.textContent = titleCase(service) + " · " + identifier;
|
||||
}
|
||||
clearReplyTarget();
|
||||
clearRangeSelection();
|
||||
if (panelState.socket) {
|
||||
try {
|
||||
panelState.socket.close();
|
||||
@@ -3780,10 +4457,10 @@
|
||||
document.addEventListener("keydown", panelState.lightboxKeyHandler);
|
||||
}
|
||||
panelState.resizeHandler = function () {
|
||||
if (!popover || popover.classList.contains("is-hidden")) {
|
||||
return;
|
||||
if (popover && !popover.classList.contains("is-hidden")) {
|
||||
positionPopover(panelState.activePanel);
|
||||
}
|
||||
positionPopover(panelState.activePanel);
|
||||
applyLatencyPacingPairs();
|
||||
};
|
||||
window.addEventListener("resize", panelState.resizeHandler);
|
||||
document.addEventListener("mousedown", panelState.docClickHandler);
|
||||
@@ -4008,6 +4685,7 @@
|
||||
document.body.addEventListener("composeSendResult", panelState.sendResultHandler);
|
||||
|
||||
hydrateBodyUrlsAsImages(thread);
|
||||
applyLatencyPacingPairs();
|
||||
updateReplyTimingUi();
|
||||
panelState.replyTimingTimer = setInterval(updateReplyTimingUi, 1000);
|
||||
scrollToBottom(true);
|
||||
|
||||
Reference in New Issue
Block a user