feat(bot-mode): real Slack-style threads in group rooms — main composer starts one, reply-in-thread continues it

Supersedes the display-only conversation folding from #89030 with actual
threads, per Teknium's direction: guessing topic boundaries from user-
message timing was wrong — tasks take many user turns.

- Every room entry carries a thread id. The main composer STARTS a new
  thread with the whole group ('New Thread'); each open thread has its own
  'Reply in thread' box that CONTINUES that work. Explicit intent, no
  heuristics.
- Member turns are thread-scoped end to end: the round-robin drive filters
  the room log to the triggering thread, watermarks key on
  thread::member, and responder resolution reads only that thread — so
  parallel topics never leak into each other's deltas or eat each other's
  watermarks.
- Stranded (timed-out) replies remember their thread and harvest back into
  it; pre-thread bare-number markers still parse.
- Pre-thread logs hydrate through assignLegacyThreads(): a user message
  after a 15-min lull starts a synthetic thread, follow-ups inside the
  window stay together — one-time conversion only, live sends always mint
  real ids.
- UI: threads ordered by last activity, newest open by default, older ones
  fold to summary rows (head text, reply count, last activity) with
  expand/collapse.

Tests: thread minting, explicit-thread continuation + delta scoping (other
thread's text never reaches the member prompt), legacy hydration split
behavior, thread UI source contracts. 258/258 plugin tests pass.
This commit is contained in:
Teknium
2026-08-18 02:13:42 -07:00
parent 7a0cdbcd79
commit 6abe6edee6
2 changed files with 276 additions and 107 deletions
+203 -88
View File
@@ -3579,8 +3579,8 @@ async function disbandGroupChat(group, members) {
}
}
function appendGroupChatEntry(group, from, text) {
const entry = { from, text: String(text).trim(), at: Date.now() }
function appendGroupChatEntry(group, from, text, thread) {
const entry = { at: Date.now(), from, text: String(text).trim(), thread: thread || 'legacy' }
updateGroupChat(group, room => {
room.log.push(entry)
@@ -3662,7 +3662,7 @@ const GROUP_TURN_HARD_CAP_MS = 20 * 60000
* so slow models aren't cut off mid-run. A turn that still times out
* records a stranded marker so the finished reply can be harvested into
* the room at the member's next turn instead of being lost. */
async function runGroupChatMemberTurn(group, member, prompt) {
async function runGroupChatMemberTurn(group, member, prompt, thread) {
const { runtime, stored } = await ensureGroupChatSession(group, member)
if (!runtime) {
@@ -3729,10 +3729,11 @@ async function runGroupChatMemberTurn(group, member, prompt) {
}
}
// Timeout — reads as a pass, but remember the baseline (runtime-only) so
// the finished reply can be posted late instead of vanishing.
// Timeout — reads as a pass, but remember the baseline + thread
// (runtime-only) so the finished reply can be posted late into the RIGHT
// thread instead of vanishing.
updateGroupChat(group, r => {
r.stranded = { ...(r.stranded || {}), [groupMemberKey(member)]: before }
r.stranded = { ...(r.stranded || {}), [groupMemberKey(member)]: { before, thread } }
return r
})
@@ -3745,7 +3746,10 @@ async function runGroupChatMemberTurn(group, member, prompt) {
async function harvestStrandedGroupReply(group, member) {
const memberKey = groupMemberKey(member)
const room = $groupChats.get()[group] || {}
const strandedBefore = room.stranded?.[memberKey]
const marker = room.stranded?.[memberKey]
// Markers were a bare number before threads; normalize both shapes.
const strandedBefore = typeof marker === 'number' ? marker : marker?.before
const strandedThread = (typeof marker === 'object' && marker?.thread) || 'legacy'
if (typeof strandedBefore !== 'number') {
return
@@ -3796,10 +3800,11 @@ async function harvestStrandedGroupReply(group, member) {
appendGroupChatEntry(
group,
{ kind: 'member', name: member.name, ...(member.remoteSource ? { source: member.connectionLabel || member.connectionId } : {}) },
reply
reply,
strandedThread
)
updateGroupChat(group, r => {
r.watermarks[memberKey] = r.log.length
r.watermarks[`${strandedThread}::${memberKey}`] = r.log.length
return r
})
}
@@ -3809,10 +3814,12 @@ async function harvestStrandedGroupReply(group, member) {
}
}
/** Drive one bounded round-robin room turn. Serial — one member at a time.
* A newer user send bumps the room epoch; this loop notices at the next
* member boundary, bails, and the newest send's own loop takes over. */
async function runGroupChatRounds(group, members) {
/** Drive one bounded round-robin turn for ONE THREAD. Serial — one member at
* a time. A newer user send bumps the room epoch; this loop notices at the
* next member boundary, bails, and the newest send's own loop takes over.
* Watermarks are per thread+member (`${thread}::${memberKey}`), so parallel
* topics never eat each other's deltas. */
async function runGroupChatRounds(group, members, thread) {
const startEpoch = ($groupChats.get()[group] || {}).epoch || 0
const isCurrent = () => (($groupChats.get()[group] || {}).epoch || 0) === startEpoch
let posted = 0
@@ -3830,7 +3837,7 @@ async function runGroupChatRounds(group, members) {
await harvestStrandedGroupReply(group, member)
}
const roomLog = ($groupChats.get()[group] || {}).log || []
const roomLog = (($groupChats.get()[group] || {}).log || []).filter(e => groupThreadOf(e) === thread)
const responders = rotateGroupSpeakers(resolveGroupResponders(roomLog, members), round)
let spokeThisRound = 0
@@ -3841,8 +3848,11 @@ async function runGroupChatRounds(group, members) {
const room = $groupChats.get()[group] || { log: [], watermarks: {} }
const memberKey = groupMemberKey(member)
const seen = room.watermarks[memberKey] || 0
const delta = room.log.slice(seen)
const markKey = `${thread}::${memberKey}`
const seen = room.watermarks[markKey] || 0
// Delta: NEW room entries, narrowed to this thread — the member's
// turn sees only the conversation it's part of.
const delta = room.log.slice(seen).filter(e => groupThreadOf(e) === thread)
if (!delta.length) {
continue
@@ -3866,14 +3876,14 @@ async function runGroupChatRounds(group, members) {
let reply = null
try {
reply = await runGroupChatMemberTurn(group, member, prompt)
reply = await runGroupChatMemberTurn(group, member, prompt, thread)
} catch {
reply = null // a failed turn is a pass, never a room error
}
// The member has now seen everything up to the pre-reply log length.
updateGroupChat(group, r => {
r.watermarks[memberKey] = r.log.length
r.watermarks[markKey] = r.log.length
return r
})
@@ -3881,11 +3891,12 @@ async function runGroupChatRounds(group, members) {
appendGroupChatEntry(
group,
{ kind: 'member', name: member.name, ...(member.remoteSource ? { source: member.connectionLabel || member.connectionId } : {}) },
reply
reply,
thread
)
// Its own message counts as seen too.
updateGroupChat(group, r => {
r.watermarks[memberKey] = r.log.length
r.watermarks[markKey] = r.log.length
return r
})
posted += 1
@@ -3908,18 +3919,22 @@ async function runGroupChatRounds(group, members) {
}
}
/** User send into a group room: append, bump epoch (supersedes any running
* loop at its next member boundary), and start the room turn unless one is
* already running under the new epoch semantics. */
function sendToGroupChat(group, members, text) {
/** User send into a group room. `thread` continues that thread (its reply
* box); omitted/null mints a NEW thread — the main composer's Slack shape.
* Appends, bumps the room epoch (supersedes any running loop at its next
* member boundary), and starts the turn drive for the target thread.
* Returns the thread id the message landed in. */
function sendToGroupChat(group, members, text, thread) {
const trimmed = String(text || '').trim()
if (!trimmed || !members.length) {
return
return null
}
const target = thread || mintGroupThreadId()
$groupNeedsYou.set({ ...$groupNeedsYou.get(), [group]: false })
appendGroupChatEntry(group, { kind: 'user', name: 'You' }, trimmed)
appendGroupChatEntry(group, { kind: 'user', name: 'You' }, trimmed, target)
const wasRunning = ($groupChats.get()[group] || {}).running === true
@@ -3930,7 +3945,7 @@ function sendToGroupChat(group, members, text) {
})
if (!wasRunning) {
void runGroupChatRounds(group, members).catch(() => {
void runGroupChatRounds(group, members, target).catch(() => {
updateGroupChat(group, r => {
r.running = false
return r
@@ -3940,7 +3955,7 @@ function sendToGroupChat(group, members, text) {
// A loop is live; it bails at its next boundary. Chain the fresh loop
// after a short settle so exactly one drive owns the room.
setTimeout(() => {
void runGroupChatRounds(group, members).catch(() => {
void runGroupChatRounds(group, members, target).catch(() => {
updateGroupChat(group, r => {
r.running = false
return r
@@ -3948,6 +3963,8 @@ function sendToGroupChat(group, members, text) {
})
}, 250)
}
return target
}
/** Share one in-flight async operation across concurrent callers. Failures
@@ -7608,26 +7625,45 @@ function CreateGroupChatDialog({ open, roster, onClose, onCreated }) {
})
}
/** Split a room log into conversations: each USER entry starts one (the same
* boundary the turn engine's delta scoping keys on); a leading run of member
* entries (log trimmed mid-conversation) forms a headless first block.
* Returns [{ head, entries, startIndex }] — head is the user entry or null. */
function groupChatConversations(log) {
const conversations = []
// ── threads: the Slack/Discord shape ─────────────────────────────────────────
// Every room entry belongs to a THREAD. Messaging the room composer starts a
// new thread with the whole group; replying inside a thread continues that
// work. Member turns are scoped to the thread that triggered them — deltas,
// watermarks, and responder resolution all key on the thread id.
function groupThreadOf(entry) {
return entry?.thread || 'legacy'
}
function mintGroupThreadId() {
return `t${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`
}
// Pre-thread logs (hydrated from storage) get synthetic thread ids: a user
// entry after a real lull starts one, so multi-turn tasks stay whole instead
// of splitting on every follow-up.
const GROUP_THREAD_GAP_MS = 15 * 60000
function assignLegacyThreads(log) {
let current = null
let n = 0
for (let i = 0; i < log.length; i++) {
const entry = log[i]
return (log || []).map((entry, i) => {
if (entry?.thread) {
current = null
if (entry.from.kind === 'user' || !current) {
current = { entries: [], head: entry.from.kind === 'user' ? entry : null, startIndex: i }
conversations.push(current)
return entry
}
current.entries.push(entry)
}
const prev = log[i - 1]
const lull = !prev || (entry.at || 0) - (prev.at || 0) > GROUP_THREAD_GAP_MS
return conversations
if (!current || (entry.from?.kind === 'user' && lull)) {
current = `legacy-${n++}`
}
return { ...entry, thread: current }
})
}
/** Merged room view for one group: shared timeline with per-member
@@ -7646,12 +7682,14 @@ function GroupChatWorkspace({ group, members, onBack }) {
// @handle (the roster's name-device form when names collide across
// connections). Naturally every speaker just shows its display name.
const [revealedSpeaker, setRevealedSpeaker] = useState(null)
// Conversation folding: each USER message starts a conversation (the same
// boundary the turn engine's delta scoping uses), and every older
// conversation collapses to a one-line summary so a busy room stays
// readable — the "threads per conversation" ask without changing the log
// model. Keys are the head entry's timestamp; presence = user override.
const [expandedThreads, setExpandedThreads] = useState({})
// Threads, the Slack/Discord shape: entries carry a thread id. The most
// recently active thread renders open; older ones collapse to summary rows.
// `openThreads` is the user's explicit expand/collapse overrides, and
// `replyThread` is the thread whose reply box currently owns the composer
// (null = the main composer, which STARTS a new thread).
const [openThreads, setOpenThreads] = useState({})
const [replyThread, setReplyThread] = useState(null)
const [replyDrafts, setReplyDrafts] = useState({})
const header = jsxs('div', {
className: 'flex items-center gap-2 px-2.5 pt-2.5 pb-2',
@@ -7697,6 +7735,12 @@ function GroupChatWorkspace({ group, members, onBack }) {
]
})
const memberDescriptors = () =>
members.map(b => ({
...b,
title: (b.remoteSource ? '' : allMeta[b.name]?.title) || b.title || ''
}))
const submit = () => {
const text = draft.trim()
@@ -7705,16 +7749,28 @@ function GroupChatWorkspace({ group, members, onBack }) {
}
setDraft('')
// Main composer = START A NEW THREAD with the whole group (Slack shape).
// Full descriptors ride into the turn loop: remote members keep their
// connection fields so their turns route to their own machines.
sendToGroupChat(
group,
members.map(b => ({
...b,
title: (b.remoteSource ? '' : allMeta[b.name]?.title) || b.title || ''
})),
text
)
const minted = sendToGroupChat(group, memberDescriptors(), text)
if (minted) {
setOpenThreads(prev => ({ ...prev, [minted]: true }))
}
}
const submitReply = thread => {
const text = (replyDrafts[thread] || '').trim()
if (!text) {
return
}
setReplyDrafts(prev => ({ ...prev, [thread]: '' }))
// Reply box = CONTINUE this thread; the member turns it triggers are
// scoped to it.
sendToGroupChat(group, memberDescriptors(), text, thread)
setOpenThreads(prev => ({ ...prev, [thread]: true }))
}
// One log entry, rendered exactly as before conversation folding existed.
@@ -7806,64 +7862,121 @@ function GroupChatWorkspace({ group, members, onBack }) {
}, entryKey)
}
// Conversations: the LAST one always renders in full; older ones collapse
// to a one-line summary row (click to expand/collapse). Single-conversation
// rooms render exactly as before.
const conversations = groupChatConversations(room.log)
// Threads: group entries by thread id (hydration assigned legacy ids, but
// guard live pre-thread entries too), ordered by last activity — oldest
// first, so the busiest/newest thread sits at the bottom by the composer.
// The most recently ACTIVE thread renders open; older ones collapse to a
// Slack-style summary row unless explicitly opened. Every open thread gets
// its own reply box, which continues THAT thread.
const threadsById = new Map()
for (let i = 0; i < room.log.length; i++) {
const entry = room.log[i]
const id = groupThreadOf(entry)
let bucket = threadsById.get(id)
if (!bucket) {
bucket = { entries: [], id, startIndex: i }
threadsById.set(id, bucket)
}
bucket.entries.push({ entry, index: i })
}
const threads = [...threadsById.values()].sort(
(a, b) => (a.entries[a.entries.length - 1].entry.at || 0) - (b.entries[b.entries.length - 1].entry.at || 0)
)
const newestThread = threads.length ? threads[threads.length - 1].id : null
const logChildren = []
conversations.forEach((convo, ci) => {
const isLast = ci === conversations.length - 1
const threadKey = `${convo.head?.at ?? 'head'}:${convo.startIndex}`
const expanded = isLast || Boolean(expandedThreads[threadKey])
threads.forEach(threadBucket => {
const { entries, id } = threadBucket
const head = entries.find(({ entry }) => entry.from.kind === 'user')?.entry || entries[0].entry
const isNewest = id === newestThread
const expanded = openThreads[id] ?? isNewest
if (!expanded) {
const replies = convo.entries.length - 1
const headText = stripPreviewMarkdown(convo.head?.text || convo.entries[0]?.text || '').slice(0, 80)
const replies = entries.length - 1
const headText = stripPreviewMarkdown(head?.text || '').slice(0, 80)
logChildren.push(
jsxs('button', {
type: 'button',
className:
'flex w-full items-center gap-2 rounded-md border border-(--ui-stroke-secondary) px-2 py-1.5 text-left text-xs text-(--ui-text-tertiary) transition-colors hover:bg-(--chrome-action-hover)',
title: 'Show this conversation',
onClick: () => setExpandedThreads(prev => ({ ...prev, [threadKey]: true })),
title: 'Open this thread',
onClick: () => setOpenThreads(prev => ({ ...prev, [id]: true })),
children: [
jsx(Codicon, { name: 'chevron-right', className: 'shrink-0 text-[0.65rem]' }),
jsx('span', { className: 'min-w-0 flex-1 truncate', children: headText || 'Earlier conversation' }),
jsx('span', { className: 'min-w-0 flex-1 truncate', children: headText || 'Thread' }),
jsx('span', {
className: 'shrink-0 text-[0.625rem] text-(--ui-text-quaternary)',
children: `${replies} ${replies === 1 ? 'reply' : 'replies'} · ${relativeTime(convo.entries[convo.entries.length - 1].at)}`
children: `${replies} ${replies === 1 ? 'reply' : 'replies'} · ${relativeTime(entries[entries.length - 1].entry.at)}`
})
]
}, `fold:${threadKey}`)
}, `fold:${id}`)
)
return
}
if (!isLast) {
// Expanded older conversation: a subtle collapse affordance above it.
logChildren.push(
// Open thread: a rail-indented block — collapse affordance, its entries,
// and its own reply box (Slack's "reply in thread").
const threadRows = []
if (!isNewest || openThreads[id] !== undefined) {
threadRows.push(
jsxs('button', {
type: 'button',
className:
'flex w-full items-center gap-1.5 px-2 pt-1 text-left text-[0.65rem] text-(--ui-text-quaternary) transition-colors hover:text-foreground',
title: 'Collapse this conversation',
onClick: () =>
setExpandedThreads(prev => {
const next = { ...prev }
delete next[threadKey]
return next
}),
children: [jsx(Codicon, { name: 'chevron-down', className: 'text-[0.6rem]' }), 'Collapse conversation']
}, `unfold:${threadKey}`)
title: 'Collapse this thread',
onClick: () => setOpenThreads(prev => ({ ...prev, [id]: false })),
children: [jsx(Codicon, { name: 'chevron-down', className: 'text-[0.6rem]' }), 'Collapse thread']
}, `unfold:${id}`)
)
}
for (let i = 0; i < convo.entries.length; i++) {
logChildren.push(renderEntry(convo.entries[i], convo.startIndex + i))
for (const { entry, index } of entries) {
threadRows.push(renderEntry(entry, index))
}
// Reply-in-thread: the newest thread's continuation ALSO lives here, so
// the main composer below can stay "new thread" without ambiguity.
threadRows.push(
replyThread === id
? jsxs('form', {
className: 'flex items-center gap-1.5 px-2 pb-1',
onSubmit: event => {
event.preventDefault()
submitReply(id)
},
children: [
jsx(Input, {
'aria-label': 'Reply in thread',
autoFocus: true,
placeholder: 'Reply in thread…',
value: replyDrafts[id] || '',
onChange: event => setReplyDrafts(prev => ({ ...prev, [id]: event.target.value }))
}),
jsx(Button, { type: 'submit', size: 'sm', disabled: !(replyDrafts[id] || '').trim(), children: 'Reply' })
]
}, `replybox:${id}`)
: jsx('button', {
type: 'button',
className:
'w-fit px-2 pb-1 text-left text-[0.65rem] text-(--ui-accent,#4f9cf9) transition-colors hover:underline',
onClick: () => setReplyThread(id),
children: 'Reply in thread'
}, `replylink:${id}`)
)
logChildren.push(
jsx('div', {
className: 'grid gap-1.5 border-l-2 border-(--ui-stroke-secondary) pl-1.5',
children: threadRows
}, `thread:${id}`)
)
})
return jsxs('div', {
@@ -7905,11 +8018,11 @@ function GroupChatWorkspace({ group, members, onBack }) {
children: [
jsx(Input, {
'aria-label': `Message ${group}`,
placeholder: `Message ${group}… (@name to direct, @everyone for all)`,
placeholder: `New thread in ${group}… (@name to direct, @everyone for all)`,
value: draft,
onChange: event => setDraft(event.target.value)
}),
jsx(Button, { type: 'submit', size: 'sm', disabled: !draft.trim(), children: 'Send' })
jsx(Button, { type: 'submit', size: 'sm', disabled: !draft.trim(), children: 'New Thread' })
]
})
}),
@@ -8631,7 +8744,9 @@ export default {
for (const [name, room] of Object.entries(value)) {
if (room && Array.isArray(room.log)) {
rooms[name] = {
log: room.log,
// Pre-thread entries get synthetic thread ids on hydrate so
// every UI/engine path can assume entry.thread exists.
log: assignLegacyThreads(room.log),
watermarks: room.watermarks && typeof room.watermarks === 'object' ? room.watermarks : {},
sessions: room.sessions && typeof room.sessions === 'object' ? room.sessions : {},
stranded: room.stranded && typeof room.stranded === 'object' ? room.stranded : {},
@@ -599,33 +599,87 @@ test('turn prompt: results are full quality — only chatter is asked to stay sh
assert.match(prompt, /Keep chatter short/i)
})
test('conversation folding: user messages split the log; leading member run forms a headless block', () => {
test('threads: room composer mints a new thread; replies land in it', async () => {
const gc = load(() => '(pass)')
// Expose via a fresh vm read: groupChatConversations is module-scoped, so
// assert through a source-shape check plus a direct re-eval.
assert.match(pluginSource, /function groupChatConversations\(log\)/)
const t1 = gc.sendToGroupChat('Rooms', [{ name: 'research', title: '' }], 'first topic')
for (let i = 0; i < 200 && (gc.$groupChats.get().Rooms || {}).running; i++) {
await new Promise(resolve => setImmediate(resolve))
}
const t2 = gc.sendToGroupChat('Rooms', [{ name: 'research', title: '' }], 'second topic')
for (let i = 0; i < 200 && (gc.$groupChats.get().Rooms || {}).running; i++) {
await new Promise(resolve => setImmediate(resolve))
}
assert.ok(t1 && t2 && t1 !== t2, 'each composer send mints a distinct thread')
const log = roomLog(gc, 'Rooms')
assert.equal(log[0].thread, t1)
assert.equal(log[1].thread, t2)
})
test('threads: replying with an explicit thread id continues that thread and scopes the member delta to it', async () => {
const prompts = []
const gc = load((profile, prompt) => {
prompts.push(prompt)
return prompt.includes('billing') ? 'On the billing fix.' : '(pass)'
})
const billing = gc.sendToGroupChat('Scoped', [{ name: 'research', title: '' }], 'fix the billing bug')
for (let i = 0; i < 200 && (gc.$groupChats.get().Scoped || {}).running; i++) {
await new Promise(resolve => setImmediate(resolve))
}
gc.sendToGroupChat('Scoped', [{ name: 'research', title: '' }], 'research pricing')
for (let i = 0; i < 200 && (gc.$groupChats.get().Scoped || {}).running; i++) {
await new Promise(resolve => setImmediate(resolve))
}
// Continue the BILLING thread explicitly.
const again = gc.sendToGroupChat('Scoped', [{ name: 'research', title: '' }], 'billing follow-up: ship it', billing)
for (let i = 0; i < 200 && (gc.$groupChats.get().Scoped || {}).running; i++) {
await new Promise(resolve => setImmediate(resolve))
}
assert.equal(again, billing, 'explicit thread id is reused, not re-minted')
const followUpPrompt = prompts.find(p => p.includes('ship it'))
assert.ok(followUpPrompt, 'follow-up turn ran')
assert.equal(followUpPrompt.includes('research pricing'), false, 'other thread never leaks into the delta')
// Member replies carry the thread of the turn that produced them.
const memberEntries = roomLog(gc, 'Scoped').filter(e => e.from.kind === 'member')
assert.ok(memberEntries.length >= 1)
assert.ok(memberEntries.every(e => e.thread === billing), 'replies land in the thread that triggered them')
})
test('threads: hydration assigns legacy thread ids — lull splits, follow-ups stay together', () => {
const gc = load(() => '(pass)')
assert.match(pluginSource, /function assignLegacyThreads\(log\)/)
const fn = new Function(
`${pluginSource.slice(pluginSource.indexOf('function groupChatConversations'), pluginSource.indexOf('/** Merged room view'))}; return groupChatConversations`
`${pluginSource.slice(pluginSource.indexOf('const GROUP_THREAD_GAP_MS'), pluginSource.indexOf('/** Merged room view'))}; return assignLegacyThreads`
)()
const M = 60000
const u = (text, at) => ({ from: { kind: 'user', name: 'You' }, text, at })
const m = (name, text, at) => ({ from: { kind: 'member', name }, text, at })
const convos = fn([m('a', 'trimmed tail', 1), u('first ask', 2), m('a', 'r1', 3), u('second ask', 4), m('b', 'r2', 5)])
assert.equal(convos.length, 3)
assert.equal(convos[0].head, null)
assert.equal(convos[0].entries.length, 1)
assert.equal(convos[1].head.text, 'first ask')
assert.equal(convos[1].entries.length, 2)
assert.equal(convos[2].head.text, 'second ask')
assert.equal(convos[2].startIndex, 3)
const log = fn([
u('task one', 0),
m('a', 'r1', 1 * M),
u('quick follow-up', 3 * M), // inside the 15-min window: SAME thread
m('a', 'r2', 4 * M),
u('new topic much later', 60 * M), // after the lull: new thread
m('a', 'r3', 61 * M)
])
assert.equal(log[0].thread, log[2].thread, 'follow-up stays in the same thread')
assert.equal(log[2].thread, log[3].thread)
assert.notEqual(log[0].thread, log[4].thread, 'post-lull message starts a new thread')
assert.equal(log[4].thread, log[5].thread)
void gc
})
test('source contract: older conversations fold to a summary row; the last stays expanded', () => {
assert.match(pluginSource, /const conversations = groupChatConversations\(room\.log\)/)
assert.match(pluginSource, /const expanded = isLast \|\| Boolean\(expandedThreads\[threadKey\]\)/)
assert.match(pluginSource, /Show this conversation/)
assert.match(pluginSource, /Collapse conversation/)
assert.match(pluginSource, /\$\{replies\} \$\{replies === 1 \? 'reply' : 'replies'\}/)
test('source contract: thread UI — folded rows, per-thread reply box, new-thread composer', () => {
assert.match(pluginSource, /Open this thread/)
assert.match(pluginSource, /Collapse thread/)
assert.match(pluginSource, /Reply in thread…/)
assert.match(pluginSource, /children: 'New Thread'/)
assert.match(pluginSource, /const markKey = `\$\{thread\}::\$\{memberKey\}`/)
})