Three changes to the ProdCom tile, all per-tile and persisted with the layout (version 1.2.0): - A title-bar button flips the transcript between top-down (newest at the bottom, still the default) and bottom-up. It is CSS only — the entries container becomes column-reverse, DOM order is untouched — but everything that follows "latest" flips with it: auto-follow, jump-to-latest, the near-latest scroll test, and the "New messages" pill, which moves to the top of the tile and points up. - The "Channels ▾" text button is now a funnel icon. It keeps what the label carried: while any channel is hidden the icon takes the accent color and a compact "3/4" count sits beside it, with the full wording in the tooltip. - Tile settings drop the channel-name filter and text size, both of which the title bar already handles, and gain "Show channel icons" for the per-entry avatars. Removing the channel filter made its code dead, so channelFilter(), filteredOut() and the data-channel-name attribute went with it — the menubar's hidden-channel set is now the only filter. Text size keeps working without a schema entry: the A−/A+ buttons persist it through saveInstanceSettings, the shell's settings popover only writes schema keys so it survives an Apply, and the client falls back to 15px when it is absent. Existing tiles have no saved showAvatars value and the check is `=== false`, so they keep their icons. Verified against tools/prodcom-mock.js: both directions render and auto-follow correctly, the pill appears at the right end and jumps to the newest entry, the count badge tracks the channel toggles, a tile bumped to 19px kept it across an Apply and a reload, a freshly added tile started at 15px with icons shown, and two tiles held independent state. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
478 lines
18 KiB
JavaScript
478 lines
18 KiB
JavaScript
/* ProdCom Transcript — client part.
|
||
|
||
Ported from prodcom-listener/public/app.js into the ProdDash module
|
||
contract: everything lives inside the tile's root, per-tile state
|
||
(hidden channels, channel icons, timestamps, text size, flow direction)
|
||
persists in instance settings, and all network traffic goes through
|
||
moduleApi (the module's server proxy at /prodcom/*). */
|
||
|
||
export default function create({ root, moduleApi }) {
|
||
/* ── per-instance state ─────────────────────────────────────────── */
|
||
|
||
const channels = new Map(); // channelId -> {name, color}
|
||
const groups = new Map(); // groupId -> {name, channelIds}
|
||
const entryEls = new Map(); // entryId -> element
|
||
let pinnedToLatest = true;
|
||
let hiddenChannels = new Set(moduleApi.instanceSettings.hiddenChannels || []);
|
||
let stream = null; // moduleApi.sse handle
|
||
let bootTimer = null;
|
||
let stopped = false;
|
||
|
||
/* ── DOM skeleton ───────────────────────────────────────────────── */
|
||
|
||
root.innerHTML = `
|
||
<div class="pt-wrap">
|
||
<div class="pt-stream">
|
||
<div class="pt-empty">Waiting for transcript…</div>
|
||
<div class="pt-entries"></div>
|
||
</div>
|
||
<button class="pt-jump" hidden>↓ New messages</button>
|
||
</div>`;
|
||
|
||
const wrap = root.querySelector('.pt-wrap');
|
||
const streamEl = root.querySelector('.pt-stream');
|
||
const entriesEl = root.querySelector('.pt-entries');
|
||
const emptyEl = root.querySelector('.pt-empty');
|
||
const jumpBtn = root.querySelector('.pt-jump');
|
||
|
||
/* ── title-bar controls ─────────────────────────────────────────── */
|
||
|
||
const CLOCK_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>';
|
||
const JUMP_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 4v11"/><path d="m7 10 5 5 5-5"/><path d="M5 20h14"/></svg>';
|
||
const CLEAR_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="m9 9 6 6M15 9l-6 6"/></svg>';
|
||
const CHANNELS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 5h16l-6 7.4V19l-4 2v-8.6z"/></svg>';
|
||
// arrow shows where new messages land: down = oldest first, up = newest first
|
||
const FLOW_DOWN_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 4v14"/><path d="m3 15 3 3 3-3"/><path d="M12 5h8"/><path d="M12 12h8"/><path d="M12 19h6"/></svg>';
|
||
const FLOW_UP_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 20V6"/><path d="m3 9 3-3 3 3"/><path d="M12 5h8"/><path d="M12 12h8"/><path d="M12 19h6"/></svg>';
|
||
|
||
let channelMenuEl = null; // the open menu's element (filled on each open)
|
||
const channelsMenu = moduleApi.header.addMenu({
|
||
icon: CHANNELS_SVG,
|
||
title: 'Choose visible channels',
|
||
build(menu) {
|
||
menu.classList.add('pt-menu');
|
||
channelMenuEl = menu;
|
||
rebuildChannelMenu();
|
||
},
|
||
});
|
||
channelsMenu.button.classList.add('pt-channels-btn');
|
||
// the icon carries the label; a count rides alongside it while filtering
|
||
const channelCountEl = document.createElement('span');
|
||
channelCountEl.className = 'pt-count';
|
||
channelCountEl.hidden = true;
|
||
channelsMenu.button.appendChild(channelCountEl);
|
||
|
||
let timeBtn = null;
|
||
timeBtn = moduleApi.header.addButton({
|
||
icon: CLOCK_SVG,
|
||
title: 'Show / hide timestamps',
|
||
onClick() {
|
||
moduleApi.saveInstanceSettings({ showTimes: !moduleApi.instanceSettings.showTimes });
|
||
applyInstanceSettings();
|
||
},
|
||
});
|
||
|
||
function bumpTextSize(delta) {
|
||
const current = Number(moduleApi.instanceSettings.textSize) || 15;
|
||
moduleApi.saveInstanceSettings({ textSize: Math.min(48, Math.max(10, current + delta)) });
|
||
applyInstanceSettings();
|
||
}
|
||
moduleApi.header.addButton({ label: 'A−', title: 'Smaller text', onClick: () => bumpTextSize(-2) });
|
||
moduleApi.header.addButton({ label: 'A+', title: 'Larger text', onClick: () => bumpTextSize(2) });
|
||
|
||
// icon and tooltip track the current direction — applyInstanceSettings sets both
|
||
const flowBtn = moduleApi.header.addButton({
|
||
icon: FLOW_DOWN_SVG,
|
||
onClick() {
|
||
moduleApi.saveInstanceSettings({ newestFirst: !moduleApi.instanceSettings.newestFirst });
|
||
applyInstanceSettings();
|
||
// the two directions have opposite "latest" ends — follow the flip
|
||
if (pinnedToLatest) scrollToLatest();
|
||
},
|
||
});
|
||
|
||
moduleApi.header.addButton({
|
||
icon: JUMP_SVG,
|
||
title: 'Jump to latest',
|
||
onClick() {
|
||
pinnedToLatest = true;
|
||
jumpBtn.hidden = true;
|
||
scrollToLatest();
|
||
},
|
||
});
|
||
|
||
const clearBtn = moduleApi.header.addButton({
|
||
icon: CLEAR_SVG,
|
||
title: 'Clear this tile (does not affect ProdCom)',
|
||
onClick: clearView,
|
||
});
|
||
clearBtn.classList.add('pt-clear');
|
||
|
||
function applyInstanceSettings() {
|
||
const s = moduleApi.instanceSettings;
|
||
const size = Math.min(48, Math.max(10, Number(s.textSize) || 15));
|
||
wrap.style.setProperty('--pt-size', size + 'px');
|
||
wrap.classList.toggle('show-times', Boolean(s.showTimes));
|
||
wrap.classList.toggle('hide-avatars', s.showAvatars === false);
|
||
timeBtn?.classList.toggle('active', Boolean(s.showTimes));
|
||
|
||
const up = newestFirst();
|
||
wrap.classList.toggle('newest-first', up);
|
||
flowBtn.innerHTML = up ? FLOW_UP_SVG : FLOW_DOWN_SVG;
|
||
flowBtn.classList.toggle('active', up);
|
||
flowBtn.title = up
|
||
? 'Newest at top — click for newest at bottom'
|
||
: 'Newest at bottom — click for newest at top';
|
||
jumpBtn.textContent = (up ? '↑' : '↓') + ' New messages';
|
||
}
|
||
applyInstanceSettings();
|
||
|
||
/* ── channel visibility toggles ─────────────────────────────────── */
|
||
|
||
function persistHidden() {
|
||
moduleApi.saveInstanceSettings({ hiddenChannels: [...hiddenChannels] });
|
||
}
|
||
|
||
function applyFilter(el) {
|
||
el.classList.toggle('hidden-by-filter', hiddenChannels.has(el.dataset.channelId));
|
||
}
|
||
|
||
function toggleChannel(id) {
|
||
if (hiddenChannels.has(id)) hiddenChannels.delete(id);
|
||
else hiddenChannels.add(id);
|
||
persistHidden();
|
||
rebuildChannelMenu();
|
||
entryEls.forEach((el) => applyFilter(el));
|
||
if (pinnedToLatest) scrollToLatest();
|
||
}
|
||
|
||
/** Show exactly this group's channels, hide the rest */
|
||
function applyGroup(g) {
|
||
hiddenChannels = new Set([...channels.keys()].filter((cid) => !g.channelIds.includes(cid)));
|
||
persistHidden();
|
||
rebuildChannelMenu();
|
||
entryEls.forEach((el) => applyFilter(el));
|
||
if (pinnedToLatest) scrollToLatest();
|
||
}
|
||
|
||
function groupIsActive(g) {
|
||
const members = g.channelIds.filter((cid) => channels.has(cid));
|
||
if (!members.length) return false;
|
||
return [...channels.keys()].every((cid) =>
|
||
members.includes(cid) ? !hiddenChannels.has(cid) : hiddenChannels.has(cid)
|
||
);
|
||
}
|
||
|
||
/* The button is an icon, so the "some channels are hidden" state shows as
|
||
an accent tint plus a compact count beside it. */
|
||
function updateChannelsButton() {
|
||
let visibleCount = 0;
|
||
for (const id of channels.keys()) {
|
||
if (!hiddenChannels.has(id)) visibleCount++;
|
||
}
|
||
const filtering = channels.size > 0 && visibleCount !== channels.size;
|
||
channelCountEl.textContent = filtering ? `${visibleCount}/${channels.size}` : '';
|
||
channelCountEl.hidden = !filtering;
|
||
channelsMenu.button.classList.toggle('active', filtering);
|
||
channelsMenu.button.title = filtering
|
||
? `Channels — showing ${visibleCount} of ${channels.size}`
|
||
: 'Choose visible channels';
|
||
}
|
||
|
||
function rebuildChannelMenu() {
|
||
updateChannelsButton();
|
||
const channelMenu = channelMenuEl;
|
||
if (!channelMenu) return; // menu not opened yet — the label is enough
|
||
channelMenu.innerHTML = '';
|
||
|
||
if (groups.size) {
|
||
for (const g of groups.values()) {
|
||
const on = groupIsActive(g);
|
||
const item = document.createElement('button');
|
||
item.className = 'pt-channel-item pt-group-item' + (on ? ' on' : '');
|
||
item.setAttribute('role', 'menuitemradio');
|
||
item.setAttribute('aria-checked', String(on));
|
||
const check = document.createElement('span');
|
||
check.className = 'check';
|
||
check.textContent = '✓';
|
||
const name = document.createElement('span');
|
||
name.className = 'name';
|
||
name.textContent = g.name;
|
||
item.append(check, name);
|
||
item.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
applyGroup(g);
|
||
});
|
||
channelMenu.appendChild(item);
|
||
}
|
||
const divider = document.createElement('div');
|
||
divider.className = 'pt-menu-divider';
|
||
channelMenu.appendChild(divider);
|
||
}
|
||
|
||
for (const [id, info] of channels) {
|
||
const on = !hiddenChannels.has(id);
|
||
const item = document.createElement('button');
|
||
item.className = 'pt-channel-item' + (on ? ' on' : '');
|
||
item.setAttribute('role', 'menuitemcheckbox');
|
||
item.setAttribute('aria-checked', String(on));
|
||
const check = document.createElement('span');
|
||
check.className = 'check';
|
||
check.textContent = '✓';
|
||
const name = document.createElement('span');
|
||
name.className = 'name';
|
||
name.textContent = info.name;
|
||
name.style.color = info.color;
|
||
item.append(check, name);
|
||
item.addEventListener('click', (e) => {
|
||
// keep the menu open: the rebuild detaches this item, which would
|
||
// make the outside-click handler think we clicked outside
|
||
e.stopPropagation();
|
||
toggleChannel(id);
|
||
});
|
||
channelMenu.appendChild(item);
|
||
}
|
||
}
|
||
|
||
/* ── clear (local view only) ────────────────────────────────────── */
|
||
|
||
function clearView() {
|
||
entryEls.forEach((el) => el.remove());
|
||
entryEls.clear();
|
||
emptyEl.hidden = false;
|
||
}
|
||
|
||
/* ── scrolling ──────────────────────────────────────────────────── */
|
||
|
||
/** Newest entry at the top (bottom-up) instead of at the bottom? */
|
||
function newestFirst() {
|
||
return Boolean(moduleApi.instanceSettings.newestFirst);
|
||
}
|
||
|
||
streamEl.addEventListener('scroll', () => {
|
||
const near = newestFirst()
|
||
? streamEl.scrollTop < 60
|
||
: streamEl.scrollHeight - streamEl.scrollTop - streamEl.clientHeight < 60;
|
||
pinnedToLatest = near;
|
||
if (near) jumpBtn.hidden = true;
|
||
});
|
||
|
||
jumpBtn.addEventListener('click', () => {
|
||
pinnedToLatest = true;
|
||
jumpBtn.hidden = true;
|
||
scrollToLatest();
|
||
});
|
||
|
||
/** The latest entry is at the top when flipped, at the bottom otherwise. */
|
||
function scrollToLatest() {
|
||
streamEl.scrollTop = newestFirst() ? 0 : streamEl.scrollHeight;
|
||
}
|
||
|
||
function afterInsert(el) {
|
||
if (el.classList.contains('hidden-by-filter')) return;
|
||
if (pinnedToLatest) scrollToLatest();
|
||
else jumpBtn.hidden = false;
|
||
}
|
||
|
||
/* ── entry rendering ────────────────────────────────────────────── */
|
||
|
||
function channelInfo(entry) {
|
||
const known = channels.get(entry.channelId);
|
||
return {
|
||
name: (known && known.name) || entry.channelName || 'Unknown',
|
||
color: (known && known.color) || 'var(--muted)',
|
||
};
|
||
}
|
||
|
||
function fmtTime(iso) {
|
||
const d = iso ? new Date(iso) : new Date();
|
||
return isNaN(d) ? '' : d.toLocaleTimeString([], { hour12: false });
|
||
}
|
||
|
||
function upsertEntry(entry) {
|
||
if (!entry || typeof entry.text !== 'string') return;
|
||
const id = entry.id || entry.channelId + '|' + entry.date;
|
||
|
||
let el = entryEls.get(id);
|
||
if (!el) {
|
||
emptyEl.hidden = true;
|
||
el = document.createElement('div');
|
||
el.className = 'pt-entry';
|
||
el.dataset.channelId = entry.channelId || '';
|
||
const info = channelInfo(entry);
|
||
el.style.setProperty('--ch', info.color);
|
||
|
||
const time = document.createElement('span');
|
||
time.className = 'time';
|
||
time.textContent = fmtTime(entry.date);
|
||
|
||
const avatar = document.createElement('span');
|
||
avatar.className = 'avatar';
|
||
avatar.style.color = info.color;
|
||
avatar.innerHTML =
|
||
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">' +
|
||
'<circle cx="12" cy="8.5" r="3.2"/>' +
|
||
'<path d="M5.5 19c1.6-3.2 3.9-4.8 6.5-4.8s4.9 1.6 6.5 4.8"/></svg>';
|
||
|
||
const ch = document.createElement('span');
|
||
ch.className = 'channel';
|
||
ch.textContent = info.name;
|
||
ch.style.color = info.color;
|
||
|
||
const text = document.createElement('span');
|
||
text.className = 'text';
|
||
|
||
// channel name with its timestamp to the right; the clock button
|
||
// only toggles the timestamp's visibility
|
||
const chLine = document.createElement('span');
|
||
chLine.className = 'channel-line';
|
||
chLine.append(ch, time);
|
||
|
||
const body = document.createElement('div');
|
||
body.className = 'body';
|
||
body.append(chLine, text);
|
||
|
||
el.append(avatar, body);
|
||
entryEls.set(id, el);
|
||
applyFilter(el);
|
||
entriesEl.appendChild(el);
|
||
updateEntryEl(el, entry);
|
||
afterInsert(el);
|
||
} else {
|
||
updateEntryEl(el, entry);
|
||
if (pinnedToLatest && !el.classList.contains('hidden-by-filter')) scrollToLatest();
|
||
}
|
||
}
|
||
|
||
function updateEntryEl(el, entry) {
|
||
el.querySelector('.text').textContent = entry.translatedText || entry.text;
|
||
el.classList.toggle('in-progress', entry.inProgress === true);
|
||
}
|
||
|
||
/* ── SSE event parsing ──────────────────────────────────────────── */
|
||
|
||
/* The stream sends JSON objects for new/updated/completed transcript
|
||
entries. Be liberal about the envelope shape. */
|
||
function extractEntries(obj) {
|
||
if (!obj || typeof obj !== 'object') return [];
|
||
if (Array.isArray(obj)) return obj.flatMap(extractEntries);
|
||
if (typeof obj.text === 'string' && (obj.id || obj.channelId)) return [obj];
|
||
for (const key of ['data', 'payload', 'entry', 'entries', 'transcript']) {
|
||
if (obj[key] && typeof obj[key] === 'object') {
|
||
const found = extractEntries(obj[key]);
|
||
if (found.length) return found;
|
||
}
|
||
}
|
||
return [];
|
||
}
|
||
|
||
function handleEventData(raw) {
|
||
let obj;
|
||
try { obj = JSON.parse(raw); } catch { return; }
|
||
const kind = String(obj.type || obj.event || '').toLowerCase();
|
||
if (kind.includes('clear')) {
|
||
entryEls.forEach((el) => el.remove());
|
||
entryEls.clear();
|
||
emptyEl.hidden = false;
|
||
return;
|
||
}
|
||
extractEntries(obj).forEach(upsertEntry);
|
||
}
|
||
|
||
/* ── data loading (all via the module's proxy) ──────────────────── */
|
||
|
||
async function loadChannels() {
|
||
const res = await moduleApi.fetch('/prodcom/api/v1/channels');
|
||
if (!res.ok) throw new Error('channels ' + res.status);
|
||
const body = await res.json();
|
||
channels.clear();
|
||
for (const ch of body.data || []) {
|
||
channels.set(ch.id, { name: ch.name, color: ch.color || '#8b98a5' });
|
||
}
|
||
rebuildChannelMenu();
|
||
}
|
||
|
||
async function loadGroups() {
|
||
const res = await moduleApi.fetch('/prodcom/api/v1/groups');
|
||
if (!res.ok) throw new Error('groups ' + res.status);
|
||
const body = await res.json();
|
||
groups.clear();
|
||
for (const g of body.data || []) {
|
||
groups.set(g.id, { name: g.name, channelIds: g.channelIds || [] });
|
||
}
|
||
rebuildChannelMenu();
|
||
}
|
||
|
||
async function loadHistory() {
|
||
const res = await moduleApi.fetch('/prodcom/api/v1/transcript?limit=100');
|
||
if (!res.ok) throw new Error('transcript ' + res.status);
|
||
const body = await res.json();
|
||
const entries = (body.data || []).slice();
|
||
entries.sort((a, b) => new Date(a.date) - new Date(b.date));
|
||
entries.forEach(upsertEntry);
|
||
}
|
||
|
||
/* ── live stream ────────────────────────────────────────────────── */
|
||
|
||
const SSE_EVENT_NAMES = [
|
||
'transcript', 'transcript.new', 'transcript.updated', 'transcript.update',
|
||
'transcript.completed', 'transcript.complete', 'entry', 'new', 'update',
|
||
'updated', 'complete', 'completed',
|
||
];
|
||
|
||
function connect() {
|
||
const onEvent = (ev) => handleEventData(ev.data);
|
||
const events = {};
|
||
for (const name of SSE_EVENT_NAMES) events[name] = onEvent;
|
||
|
||
stream = moduleApi.sse('/prodcom/api/v1/transcript/stream', {
|
||
open() {
|
||
moduleApi.setStatus('ok', 'Live');
|
||
// refresh channels/groups and backfill entries missed while
|
||
// disconnected (upsertEntry dedupes by id, so this is harmless)
|
||
loadChannels().catch(() => {});
|
||
loadGroups().catch(() => {});
|
||
loadHistory().catch(() => {});
|
||
},
|
||
error() {
|
||
moduleApi.setStatus('error', 'ProdCom unreachable — reconnecting…');
|
||
},
|
||
message: onEvent,
|
||
events,
|
||
});
|
||
}
|
||
|
||
/* ── lifecycle ──────────────────────────────────────────────────── */
|
||
|
||
async function boot() {
|
||
if (stopped) return;
|
||
moduleApi.setStatus('connecting', 'Connecting to ProdCom…');
|
||
try {
|
||
await loadChannels();
|
||
await loadGroups().catch(() => {}); // groups are optional
|
||
await loadHistory();
|
||
} catch {
|
||
moduleApi.setStatus('error', 'ProdCom unreachable — retrying…');
|
||
bootTimer = setTimeout(boot, 5000);
|
||
return;
|
||
}
|
||
scrollToLatest();
|
||
connect();
|
||
}
|
||
|
||
return {
|
||
start() {
|
||
boot();
|
||
},
|
||
stop() {
|
||
stopped = true;
|
||
clearTimeout(bootTimer);
|
||
stream?.close();
|
||
root.innerHTML = ''; // header controls are removed by the shell
|
||
},
|
||
onResize() {
|
||
if (pinnedToLatest) scrollToLatest();
|
||
},
|
||
};
|
||
}
|