diff --git a/apps/desktop/src/plugins/hermes-bots/plugin.js b/apps/desktop/src/plugins/hermes-bots/plugin.js index 8f4b6d65ba..df8b4cdbd7 100644 --- a/apps/desktop/src/plugins/hermes-bots/plugin.js +++ b/apps/desktop/src/plugins/hermes-bots/plugin.js @@ -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}”` }) } diff --git a/apps/desktop/src/plugins/hermes-bots/tests/group-chat-identity-edit.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/group-chat-identity-edit.test.mjs index c96b4ada0e..775c4ae536 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/group-chat-identity-edit.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/group-chat-identity-edit.test.mjs @@ -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. diff --git a/apps/desktop/src/plugins/hermes-bots/tests/group-composer-drafts.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/group-composer-drafts.test.mjs new file mode 100644 index 0000000000..6c4f0d5706 --- /dev/null +++ b/apps/desktop/src/plugins/hermes-bots/tests/group-composer-drafts.test.mjs @@ -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/) +})