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:
@@ -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\}`/)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user