feat(desktop): retain Bot group drafts by room

This commit is contained in:
David Dudok de Wit
2026-08-23 01:07:41 +02:00
committed by Teknium
parent 9b36c2d43c
commit b42d8279ed
3 changed files with 264 additions and 16 deletions

View File

@@ -6203,6 +6203,10 @@ async function renameGroupChat(oldName, newName, members) {
const all = { ...$groupChats.get() }
const room = all[oldName]
if (room) {
migrateGroupComposerDraft(groupComposerDraftKey(oldName, room), groupComposerDraftKey(next, room))
}
delete all[oldName]
if (room) {
@@ -11301,11 +11305,131 @@ function GroupClarifyCard({ entry, members }) {
})
}
// Group composer drafts are window-local UI state. They must survive pane
// parking/re-registration and owner switches, but must never enter shared room
// metadata (where another Desktop would see half-typed text or attachment
// bytes). Current rooms key by immutable roomId; legacy rooms fall back to the
// display name until they are upgraded.
const groupComposerDrafts = new Map()
function emptyGroupComposerDraft() {
return { activeReplyThread: null, main: '', pendingAttachments: {}, replies: {}, revision: 0 }
}
function groupComposerDraftKey(group, room) {
return groupChatRoomKey(group, room)
}
function groupComposerDraftSnapshot(key) {
return groupComposerDrafts.get(key) || emptyGroupComposerDraft()
}
function updateGroupComposerDraft(key, mutate) {
const current = groupComposerDraftSnapshot(key)
const next = mutate({
...current,
pendingAttachments: Object.fromEntries(
Object.entries(current.pendingAttachments || {}).map(([thread, attachments]) => [
thread,
[...(attachments || [])]
])
),
replies: { ...(current.replies || {}) }
})
next.revision = current.revision + 1
groupComposerDrafts.delete(key)
groupComposerDrafts.set(key, next)
return next
}
function restoreGroupComposerDraft(key, expectedRevision, snapshot) {
const current = groupComposerDraftSnapshot(key)
if (current.revision !== expectedRevision) {
return null
}
const restored = {
...snapshot,
pendingAttachments: Object.fromEntries(
Object.entries(snapshot.pendingAttachments || {}).map(([thread, attachments]) => [
thread,
[...(attachments || [])]
])
),
replies: { ...(snapshot.replies || {}) },
revision: current.revision + 1
}
groupComposerDrafts.set(key, restored)
return restored
}
function clearGroupComposerDraft(key) {
groupComposerDrafts.delete(key)
}
function migrateGroupComposerDraft(oldKey, newKey) {
if (oldKey === newKey || !groupComposerDrafts.has(oldKey)) {
return
}
if (!groupComposerDrafts.has(newKey)) {
groupComposerDrafts.set(newKey, groupComposerDrafts.get(oldKey))
}
groupComposerDrafts.delete(oldKey)
}
function GroupChatWorkspace({ group, members, onBack, visible = true }) {
const rooms = useValue($groupChats)
const allMeta = useValue($botMeta)
const room = rooms[group] || { log: [], running: false }
const [draft, setDraft] = useState('')
const composerKey = groupComposerDraftKey(group, room)
const composerKeyRef = useRef(composerKey)
const [composerDraft, setComposerDraft] = useState(() => groupComposerDraftSnapshot(composerKey))
if (composerKeyRef.current !== composerKey) {
migrateGroupComposerDraft(composerKeyRef.current, composerKey)
composerKeyRef.current = composerKey
}
const updateComposerDraft = mutate => {
const next = updateGroupComposerDraft(composerKeyRef.current, mutate)
setComposerDraft(next)
return next
}
const draft = composerDraft.main || ''
const replyDrafts = composerDraft.replies || {}
const replyThread = composerDraft.activeReplyThread || null
const pendingImages = composerDraft.pendingAttachments || {}
const setDraft = value =>
updateComposerDraft(current => ({
...current,
main: typeof value === 'function' ? value(current.main || '') : value
}))
const setReplyDrafts = value =>
updateComposerDraft(current => ({
...current,
replies: typeof value === 'function' ? value(current.replies || {}) : value
}))
const setReplyThread = value =>
updateComposerDraft(current => ({
...current,
activeReplyThread:
typeof value === 'function' ? value(current.activeReplyThread || null) : value
}))
const setPendingImages = value =>
updateComposerDraft(current => ({
...current,
pendingAttachments:
typeof value === 'function' ? value(current.pendingAttachments || {}) : value
}))
const [confirmDisband, setConfirmDisband] = useState(false)
const [settingsOpen, setSettingsOpen] = useState(false)
// Click-to-disambiguate: which log entry is showing its speaker's full
@@ -11318,12 +11442,9 @@ function GroupChatWorkspace({ group, members, onBack, visible = true }) {
// `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({})
// Pending image attachments per composer: `null` thread key = the main
// composer, otherwise the reply box of that thread. Data URLs, already
// downscaled — they ride the send into every responding member's session.
const [pendingImages, setPendingImages] = useState({})
// Scroll anchoring (#89835): rooms used to open at scroll position 0 and
// stay there while replies streamed in. Scroll the bottom sentinel into
@@ -11385,11 +11506,6 @@ function GroupChatWorkspace({ group, members, onBack, visible = true }) {
setPendingImages(prev => ({ ...prev, [key]: [...(prev[key] || []), ...picked] }))
}
const clearImages = thread => {
const key = thread ?? 'main'
setPendingImages(prev => ({ ...prev, [key]: [] }))
}
const removeImage = (thread, index) => {
const key = thread ?? 'main'
setPendingImages(prev => ({ ...prev, [key]: (prev[key] || []).filter((_, i) => i !== index) }))
@@ -11579,8 +11695,12 @@ function GroupChatWorkspace({ group, members, onBack, visible = true }) {
return
}
setDraft('')
clearImages(null)
const before = groupComposerDraftSnapshot(composerKeyRef.current)
const cleared = updateComposerDraft(current => ({
...current,
main: '',
pendingAttachments: { ...(current.pendingAttachments || {}), main: [] }
}))
// 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.
@@ -11588,6 +11708,12 @@ function GroupChatWorkspace({ group, members, onBack, visible = true }) {
if (minted) {
setOpenThreads(prev => ({ ...prev, [minted]: true }))
} else {
const restored = restoreGroupComposerDraft(composerKeyRef.current, cleared.revision, before)
if (restored) {
setComposerDraft(restored)
}
}
}
@@ -11599,12 +11725,25 @@ function GroupChatWorkspace({ group, members, onBack, visible = true }) {
return
}
setReplyDrafts(prev => ({ ...prev, [thread]: '' }))
clearImages(thread)
const before = groupComposerDraftSnapshot(composerKeyRef.current)
const cleared = updateComposerDraft(current => ({
...current,
pendingAttachments: { ...(current.pendingAttachments || {}), [thread]: [] },
replies: { ...(current.replies || {}), [thread]: '' }
}))
// Reply box = CONTINUE this thread; the member turns it triggers are
// scoped to it.
sendToGroupChat(group, memberDescriptors(), text, thread, images)
setOpenThreads(prev => ({ ...prev, [thread]: true }))
const sent = sendToGroupChat(group, memberDescriptors(), text, thread, images)
if (sent) {
setOpenThreads(prev => ({ ...prev, [thread]: true }))
} else {
const restored = restoreGroupComposerDraft(composerKeyRef.current, cleared.revision, before)
if (restored) {
setComposerDraft(restored)
}
}
}
/** Pending-attachment chips + the picker for one composer (thread = null →
@@ -12040,6 +12179,7 @@ function GroupChatWorkspace({ group, members, onBack, visible = true }) {
doneLabel: 'Disbanded',
onClose: () => setConfirmDisband(false),
onConfirm: async () => {
clearGroupComposerDraft(composerKeyRef.current)
await disbandGroupChat(group, members)
host.notify({ kind: 'success', message: `Disbanded “${group}”` })
}

View File

@@ -26,7 +26,9 @@ test('source contract: settings dialog edits name and picture after creation', (
test('source contract: rename re-keys the room AND local memberships, keeps sessions', () => {
// The room record moves wholesale under the new key (sessions included, so
// members keep resuming their per-group sessions by stored sid).
assert.match(pluginSource, /const room = all\[oldName\]\s*\n\s*\n?\s*delete all\[oldName\]/)
assert.match(pluginSource, /const room = all\[oldName\][\s\S]{0,320}?delete all\[oldName\]/)
// Window-local composer drafts follow the immutable room identity too.
assert.match(pluginSource, /migrateGroupComposerDraft\(groupComposerDraftKey\(oldName, room\), groupComposerDraftKey\(next, room\)\)/)
// Local members' canonical groups lists swap old → new via ui_meta.
assert.match(pluginSource, /botGroups\(meta\)\.map\(g => \(g === oldName \? next : g\)\)/)
// Collisions are rejected, not silently suffixed — rename is explicit intent.

View File

@@ -0,0 +1,106 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import vm from 'node:vm'
const source = readFileSync(new URL('../plugin.js', import.meta.url), 'utf8')
function between(start, end) {
const from = source.indexOf(start)
const to = source.indexOf(end, from)
assert.notEqual(from, -1, `missing ${start}`)
assert.notEqual(to, -1, `missing ${end}`)
return source.slice(from, to)
}
function load() {
const context = { Map, Object }
const roomKey = between('function groupChatRoomKey(', '/** Lift any historical projection shape')
const drafts = between('const groupComposerDrafts = new Map()', 'function GroupChatWorkspace(')
vm.runInNewContext(
`${roomKey}\n${drafts}\nglobalThis.drafts = {
clearGroupComposerDraft,
groupComposerDraftKey,
groupComposerDraftSnapshot,
migrateGroupComposerDraft,
restoreGroupComposerDraft,
updateGroupComposerDraft
}`,
context
)
return context.drafts
}
test('workspace retirement and re-registration restore the exact room draft', () => {
const drafts = load()
const key = drafts.groupComposerDraftKey('Launch room', { roomId: 'room-1' })
const attachment = { data: 'data:image/png;base64,abc', kind: 'image', name: 'plan.png' }
drafts.updateGroupComposerDraft(key, state => ({
...state,
activeReplyThread: 'thread-1',
main: 'main draft',
pendingAttachments: { main: [attachment], 'thread-1': [attachment] },
replies: { 'thread-1': 'reply draft' }
}))
// Dropping the component reference simulates pane retirement. A fresh
// registration reads the same module-scope, roomId-qualified snapshot.
const remounted = drafts.groupComposerDraftSnapshot(key)
assert.equal(remounted.main, 'main draft')
assert.equal(remounted.replies['thread-1'], 'reply draft')
assert.equal(remounted.activeReplyThread, 'thread-1')
assert.equal(remounted.pendingAttachments.main[0].name, 'plan.png')
})
test('legacy name-keyed drafts migrate when an immutable room id appears', () => {
const drafts = load()
const legacy = drafts.groupComposerDraftKey('Launch room', {})
const current = drafts.groupComposerDraftKey('Renamed room', { roomId: 'room-1' })
drafts.updateGroupComposerDraft(legacy, state => ({ ...state, main: 'keep me' }))
drafts.migrateGroupComposerDraft(legacy, current)
assert.equal(drafts.groupComposerDraftSnapshot(current).main, 'keep me')
assert.equal(drafts.groupComposerDraftSnapshot(legacy).main, '')
})
test('a failed send cannot overwrite text entered after the optimistic clear', () => {
const drafts = load()
const key = 'id:room-1'
drafts.updateGroupComposerDraft(key, state => ({ ...state, main: 'send this' }))
const before = drafts.groupComposerDraftSnapshot(key)
const cleared = drafts.updateGroupComposerDraft(key, state => ({ ...state, main: '' }))
drafts.updateGroupComposerDraft(key, state => ({ ...state, main: 'newer typing' }))
assert.equal(drafts.restoreGroupComposerDraft(key, cleared.revision, before), null)
assert.equal(drafts.groupComposerDraftSnapshot(key).main, 'newer typing')
})
test('disband removes only that room draft', () => {
const drafts = load()
drafts.updateGroupComposerDraft('id:a', state => ({ ...state, main: 'a' }))
drafts.updateGroupComposerDraft('id:b', state => ({ ...state, main: 'b' }))
drafts.clearGroupComposerDraft('id:a')
assert.equal(drafts.groupComposerDraftSnapshot('id:a').main, '')
assert.equal(drafts.groupComposerDraftSnapshot('id:b').main, 'b')
})
test('GroupChatWorkspace owns composer state through the room draft store', () => {
const workspace = between('function GroupChatWorkspace(', '/** Live closers for group-chat MAIN-window tabs')
assert.match(workspace, /groupComposerDraftKey\(group, room\)/)
assert.match(workspace, /restoreGroupComposerDraft\(composerKeyRef\.current, cleared\.revision, before\)/)
assert.match(workspace, /clearGroupComposerDraft\(composerKeyRef\.current\)/)
assert.doesNotMatch(workspace, /useState\(''\).*?draft/)
assert.doesNotMatch(workspace, /useState\(\{\}\).*?replyDrafts/)
})