Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 53 additions & 3 deletions node/api/public/admin/chat.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,34 @@ function useChat({ api, showToast, dashboard }) {
// scene_id collapse into one expandable scene row.
//
// Ordering: a scene group occupies the slot of its most recent message,
// so a fresh tavern conversation lands at the top. Inside the group the
// messages render chronologically (oldest first) so the conversation
// reads naturally when expanded.
// so a fresh tavern conversation lands at the top. Inside the group
// messages are sub-grouped by participant thread (chronicler ↔ engine,
// each villager ↔ engine), with each thread rendered chronologically.
// Without this sub-grouping, a chronicler-dispatched cascade interleaves
// chronicler turns with multiple villagers' turns by sub-second
// timestamps and reads as a jumble — flagged 2026-05-06.
//
// Threading: a "thread" within a scene is identified by the non-engine
// participant. Engine→X and X→engine messages both belong to thread X.
// For Salem the engine drives every interaction so every message has
// salem-engine as one endpoint; the rare exception (PC→NPC speech with
// no engine in the pair) falls back to "from ↔ to" as the key so the
// row still groups deterministically.
const ENGINE_AGENT = 'salem-engine';
function threadKeyFor(msg) {
if (msg.from_agent === ENGINE_AGENT && msg.to_agent) {
return msg.to_agent;
}
if (msg.to_agent === ENGINE_AGENT && msg.from_agent) {
return msg.from_agent;
}
// Neither side is the engine — keep the pair grouped together
// regardless of direction by sorting the names.
const a = msg.from_agent || '';
const b = msg.to_agent || '';
const pair = a < b ? a + ' ↔ ' + b : b + ' ↔ ' + a;
return pair || '(unknown)';
}
const chatGroups = computed(() => {
const sceneIndex = new Map(); // scene_id -> group ref
const groups = [];
Expand Down Expand Up @@ -50,6 +75,31 @@ function useChat({ api, showToast, dashboard }) {
g.messages.sort((a, b) => new Date(a.sent_at) - new Date(b.sent_at));
g.earliest_at = g.messages[0].sent_at;
g.latest_at = g.messages[g.messages.length - 1].sent_at;
// Build per-thread sub-groups so the expanded scene shows the
// chronicler's turn together, then each villager's turn
// together — instead of interleaved by sub-second timestamps.
// Threads are ordered by their first message's timestamp so
// the chronicler's perception (which always opens a cascade)
// appears first, then each villager in the order they were
// attended.
const threadIndex = new Map();
for (const m of g.messages) {
const key = threadKeyFor(m);
let t = threadIndex.get(key);
if (!t) {
t = { participant: key, messages: [] };
threadIndex.set(key, t);
}
t.messages.push(m);
}
const threads = Array.from(threadIndex.values());
for (const t of threads) {
t.earliest_at = t.messages[0].sent_at;
t.latest_at = t.messages[t.messages.length - 1].sent_at;
t.hasUnacked = t.messages.some(m => m.acked_at === null);
}
threads.sort((a, b) => new Date(a.earliest_at) - new Date(b.earliest_at));
g.threads = threads;
// Surface unacked status on the collapsed header so the admin
// doesn't lose the ack indicator that single rows show. The
// discussion-id branch of /admin/chat doesn't select acked_at
Expand Down
24 changes: 24 additions & 0 deletions node/api/public/admin/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -848,6 +848,30 @@ tr.scene-row .icon-cell { position: relative; }
transform: translateY(-50%);
}

/* Per-thread header inside an expanded scene. Sits above each
chronicler/villager subgroup so the cascade reads as
"chronicler turn, then Prudence's turn, then Josiah's turn"
instead of one timestamp-interleaved blob. */
.scene-thread-header {
background: rgba(113, 113, 122, 0.10);
}
.scene-thread-header td {
padding-top: 6px;
padding-bottom: 6px;
}
.scene-thread-label {
font-weight: 600;
font-size: 12px;
color: var(--text-secondary);
padding-left: 18px;
}
.scene-thread-count {
font-weight: 400;
color: var(--text-muted);
font-size: 11px;
margin-left: 4px;
}

/* ── Access view ── */
.sub-tabs button {
padding: 10px 16px;
Expand Down
47 changes: 31 additions & 16 deletions node/api/public/admin/views/comms.html
Original file line number Diff line number Diff line change
Expand Up @@ -120,24 +120,39 @@ <h2>Communications</h2>
<td class="message-cell"><span class="scene-summary">Scene<template v-if="group.location"> · {{ group.location }}</template> · {{ group.messages.length }} messages · <code class="scene-id">{{ group.scene_id }}</code></span></td>
<td class="time-cell">{{ formatDate(group.latest_at) }}</td>
</tr>
<!-- Expanded scene: chronological sub-rows. -->
<!-- Expanded scene: per-thread sub-groups. Each
thread's messages render chronologically under
a header row that names the non-engine
participant. Without this, chronicler turns
interleave with multiple villager turns by
sub-second timestamps and the log reads as a
jumble. -->
<template v-if="group.type === 'scene' && isSceneExpanded(group.scene_id)">
<tr v-for="msg in group.messages" :key="'scene-msg-' + msg.id" @click="selectedMessage = msg" class="clickable scene-child">
<td class="icon-cell"><i :class="msg.acked_at ? 'icon-check acked-icon' : 'icon-circle unacked-icon'"></i></td>
<td>{{ msg.from_agent }}</td>
<td>{{ msg.to_agent }}</td>
<td>{{ msg.channel || '—' }}</td>
<td class="message-cell">
<template v-if="msg.message">{{ msg.message }}</template>
<template v-else-if="msg.tool_calls && msg.tool_calls.length">
<template v-for="(tc, i) in msg.tool_calls" :key="tc.id || i">
<tool-call-display :tc="tc"></tool-call-display>
<span v-if="i < msg.tool_calls.length - 1">&nbsp;</span>
<template v-for="thread in group.threads" :key="'thread-' + group.scene_id + '-' + thread.participant">
<tr class="scene-thread-header">
<td class="icon-cell">
<i v-if="thread.hasUnacked" class="icon-circle unacked-icon"></i>
</td>
<td colspan="4" class="scene-thread-label">{{ thread.participant }} <span class="scene-thread-count">· {{ thread.messages.length }}</span></td>
<td class="time-cell">{{ formatDate(thread.earliest_at) }}</td>
</tr>
<tr v-for="msg in thread.messages" :key="'scene-msg-' + msg.id" @click="selectedMessage = msg" class="clickable scene-child">
<td class="icon-cell"><i :class="msg.acked_at ? 'icon-check acked-icon' : 'icon-circle unacked-icon'"></i></td>
<td>{{ msg.from_agent }}</td>
<td>{{ msg.to_agent }}</td>
<td>{{ msg.channel || '—' }}</td>
<td class="message-cell">
<template v-if="msg.message">{{ msg.message }}</template>
<template v-else-if="msg.tool_calls && msg.tool_calls.length">
<template v-for="(tc, i) in msg.tool_calls" :key="tc.id || i">
<tool-call-display :tc="tc"></tool-call-display>
<span v-if="i < msg.tool_calls.length - 1">&nbsp;</span>
</template>
</template>
</template>
</td>
<td class="time-cell">{{ formatDate(msg.sent_at) }}</td>
</tr>
</td>
<td class="time-cell">{{ formatDate(msg.sent_at) }}</td>
</tr>
</template>
</template>
</template>
</tbody>
Expand Down