Allow sharing conversations

This commit is contained in:
2026-03-02 03:23:52 +00:00
parent b94219fc5b
commit 00588ed1b8
5 changed files with 903 additions and 134 deletions

View File

@@ -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);