Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
2558fb7
fix(bot-mode): keep room replicas passive until takeover is fenced
dokterdok Aug 31, 2026
22b26d0
fix(desktop): enable cross-source group chat creation
jugol Aug 27, 2026
52f8001
feat(bot-mode): add automatic Group Chat continuity
dokterdok Aug 31, 2026
13115dd
fix(bot-mode): reconnect Group Chat peers safely
dokterdok Aug 31, 2026
2e2ecc0
feat(bot-mode): bring Group Chats to mobile gateways
andrexibiza Sep 1, 2026
240f42f
fix(bot-mode): source-qualify reconnect on current main
dokterdok Sep 2, 2026
1066a83
feat(bot-mode): control Group Chats from messaging
dokterdok Sep 2, 2026
208ccdb
refactor(bot-mode): split messaging ownership boundaries
dokterdok Sep 2, 2026
59ad855
fix(bot-mode): harden Group Chat messaging controls
dokterdok Sep 2, 2026
6fb588d
Merge current green main
dokterdok Sep 2, 2026
c84e7ea
test(bot-mode): pin non-interrupting mobile group commands
andrexibiza Sep 1, 2026
6a74634
fix(gateway): stop lease-wait refreshes flooding non-editing chat ada…
liuhao1024 Aug 18, 2026
d0945ca
docs(bot-mode): explain mobile Group Chat control
andrexibiza Sep 1, 2026
9b1fc35
test(bot-mode): skip POSIX mailbox mode check on Windows
andrexibiza Sep 1, 2026
0b5404f
test(bot-mode): gate POSIX room secret mode on Windows
andrexibiza Sep 1, 2026
b6440c3
test(gateway): prove group control bypasses active sessions
andrexibiza Sep 1, 2026
e3ec744
refactor(gateway): shard lease-wait status ownership
andrexibiza Sep 2, 2026
5a467ef
feat(bot-mode): restore source-authored carrier history
andrexibiza Sep 2, 2026
4e1051d
chore(bot-mode): reconcile current-main landing edge
andrexibiza Sep 2, 2026
63fd45b
test(tui): make change watcher fixtures encoding-explicit
andrexibiza Sep 2, 2026
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
259 changes: 221 additions & 38 deletions apps/desktop/src/plugins/hermes-bots/create-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,16 @@ import { AvatarPicker } from './avatar-picker'
import { $selectedBot } from './bot-state'
import { createCanonicalChat } from './canonical-chat'
import { $botMeta, botHandle, botRosterKey, filterBots, ROSTER_KEY, saveBotMeta } from './data'
import { prepareDesktopRoomAuthority } from './desktop-room-command-runtime'
import { labeled, ResizableFrame } from './dialog-parts'
import { GROUP_CHAT_MAX_MEMBERS, mintGroupRoomId, uniqueGroupChatName, updateGroupChat } from './group-chat'
import {
$groupChats,
GROUP_CHAT_MAX_MEMBERS,
groupChatHostedGateway,
mintGroupRoomId,
uniqueGroupChatName,
updateGroupChat
} from './group-chat'
import type { GroupChatRoom } from './group-chat'
import { GroupImageControls } from './group-chat-parts'
import {
Expand All @@ -55,6 +63,13 @@ import {
knownGroups,
liveGroupChatNames
} from './group-membership'
import {
createAutonomousHostedGroupChat,
describeHostedRoomCreationError,
markHostedRoomLocallyDeleted,
probeHostedRoomMembers
} from './hosted-room-runtime'
import type { HostedRoomProbe } from './hosted-room-runtime'
import { useBots } from './i18n'
import { displayName, slugify } from './labels'
import { McpSetupButton } from './mcp-setup'
Expand Down Expand Up @@ -1026,11 +1041,23 @@ interface GroupDialogProps {
export function GroupDialog({ bot, onClose }: GroupDialogProps) {
const b = useBots()
const meta = useValue($botMeta)
const rooms = useValue($groupChats)
const [name, setName] = useState('')
const current = botGroups(botRosterMeta(bot, meta))
const groups = knownGroups(meta)
const hostedCurrent = current.filter(group => groupChatHostedGateway(rooms[group]))
const removableCurrent = current.filter(group => !groupChatHostedGateway(rooms[group]))

const setMembership = (group: string, enabled: boolean) => {
if (groupChatHostedGateway(rooms[group])) {
host.notify({
kind: 'info',
message: b.group.hostedMembersFixed
})

return
}

void saveBotMeta(bot, groupMembershipPatch(botRosterMeta(bot, meta), group, enabled))
host.notify({
kind: 'info',
Expand Down Expand Up @@ -1058,13 +1085,21 @@ export function GroupDialog({ bot, onClose }: GroupDialogProps) {
<div className="grid gap-1.5">
{groups.map(group => {
const enabled = current.includes(group)
const hosted = Boolean(groupChatHostedGateway(rooms[group]))

return (
<label
className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-(--chrome-action-hover)"
className={cn(
'flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-(--chrome-action-hover)',
hosted && 'cursor-not-allowed opacity-60'
)}
key={group}
>
<Checkbox checked={enabled} onCheckedChange={checked => setMembership(group, checked === true)} />
<Checkbox
checked={enabled}
disabled={hosted}
onCheckedChange={checked => setMembership(group, checked === true)}
/>
<span>{group}</span>
</label>
)
Expand Down Expand Up @@ -1093,19 +1128,19 @@ export function GroupDialog({ bot, onClose }: GroupDialogProps) {
Create & join
</Button>
</form>
{current.length ? (
{removableCurrent.length ? (
<Button
className="justify-self-start"
onClick={() =>
void saveBotMeta(bot, {
groups: [],
group: null
groups: hostedCurrent,
group: hostedCurrent[0] || null
})
}
size="sm"
variant="ghost"
>
{b.bot.removeFromAllGroups}
{hostedCurrent.length ? b.bot.removeFromOtherGroups : b.bot.removeFromAllGroups}
</Button>
) : null}
</DialogContent>
Expand All @@ -1132,6 +1167,9 @@ export function CreateGroupChatDialog({ open, roster, onClose, onCreated }: Crea
const [checked, setChecked] = useState<Record<string, boolean>>({})
const [name, setName] = useState('')
const [image, setImage] = useState<null | string>(null)
const [hostProbe, setHostProbe] = useState<null | { key: string; probe: HostedRoomProbe | null }>(null)
const [createPending, setCreatePending] = useState(false)
const [createError, setCreateError] = useState('')

// Reset per open so a cancelled draft doesn't leak into the next one.
useEffect(() => {
Expand All @@ -1140,6 +1178,9 @@ export function CreateGroupChatDialog({ open, roster, onClose, onCreated }: Crea
setChecked({})
setName('')
setImage(null)
setHostProbe(null)
setCreatePending(false)
setCreateError('')
}
}, [open])

Expand All @@ -1155,8 +1196,49 @@ export function CreateGroupChatDialog({ open, roster, onClose, onCreated }: Crea
: b.group.nameLabel

const canCreate = selected.length >= 2 && Boolean(name.trim() || selected.length)
const selectedRouteKey = selected.map(botRosterKey).sort().join('|')
const resolvedProbe = hostProbe?.key === selectedRouteKey ? hostProbe.probe : null
const hostProbePending = selected.length >= 2 && hostProbe?.key !== selectedRouteKey

useEffect(() => {
let cancelled = false

if (!open || selected.length < 2) {
setHostProbe(null)

return () => {
cancelled = true
}
}

void probeHostedRoomMembers(durableGroupChatMembers(selected))
.then(probe => {
if (cancelled) {
return
}

setHostProbe({
key: selectedRouteKey,
probe
})
})
.catch(() => {
if (!cancelled) {
setHostProbe({
key: selectedRouteKey,
probe: null
})
}
})

const create = () => {
return () => {
cancelled = true
}
// Stable member ownership, not object identity, is the probe boundary.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, selectedRouteKey])

const create = async () => {
const base = (name.trim() || placeholder).slice(0, 64)

if (selected.length < 2 || !base) {
Expand All @@ -1180,32 +1262,134 @@ export function CreateGroupChatDialog({ open, roster, onClose, onCreated }: Crea
}

const groupName = uniqueGroupChatName(base, taken)
const roomId = mintGroupRoomId()
let roomId = mintGroupRoomId()

for (const bot of selected) {
void saveBotMeta(bot, groupMembershipPatch(botRosterMeta(bot, allMeta), groupName, true))
}
setCreatePending(true)
setCreateError('')

try {
if (hostProbePending) {
return
}

// RPCs below can refresh or switch the live roster before they settle.
// Capture immutable member ownership now so the local projection matches
// the exact source routes used to create the hosted room.
const roomMembers = durableGroupChatMembers(selected)

const metadataOwners = selected.map(bot => ({
...bot,
...(bot.route
? {
route: { ...bot.route }
}
: {})
}))

const autonomousMembers = roomMembers.map((member, index) => {
const bot = selected[index]
const label = displayName(bot, botRosterMeta(bot, allMeta))

return {
member,
profile: member.targetProfile || member.name,
handle: botHandle(member.name, member),
...(label
? {
displayName: label
}
: {})
}
})

const hostName = selected[0]?.connectionLabel || b.group.thisHost
let hosted: Awaited<ReturnType<typeof createAutonomousHostedGroupChat>> | null = null

if (resolvedProbe?.eligible) {
try {
hosted = await createAutonomousHostedGroupChat({
probe: resolvedProbe,
roomId,
name: groupName,
members: autonomousMembers
})
} catch (error) {
if ((error as { fallbackSafe?: boolean })?.fallbackSafe === false) {
setCreateError(describeHostedRoomCreationError(error) || b.group.createFailed)

return
}

// Persist every machine identity, including today's active source. That
// member becomes remote after a source switch and cannot rely on the new
// gateway's name-keyed bot metadata to remain seated in this room.
const roomMembers = durableGroupChatMembers(selected)
updateGroupChat(groupName, (room: GroupChatRoom) => {
room.members = roomMembers
room.roomId = roomId
const retiredRoomId = roomId

if (image) {
room.image = image
markHostedRoomLocallyDeleted(retiredRoomId)

const rooms = $groupChats.get()
const withoutRetiredProjection = Object.fromEntries(
Object.entries(rooms).filter(([, room]) => room.roomId !== retiredRoomId)
)

if (Object.keys(withoutRetiredProjection).length !== Object.keys(rooms).length) {
$groupChats.set(withoutRetiredProjection)
}

roomId = mintGroupRoomId()

host.notify({
kind: 'info',
message: b.group.hostedFallbackToDesktop(hostName)
})
}
}

return room
})
host.notify({
kind: 'info',
message: `“${groupName}” created with ${selected.length} bots`
})
onClose()
onCreated?.(groupName)
const desktopAuthority = hosted ? null : await prepareDesktopRoomAuthority()

for (const owner of metadataOwners) {
await saveBotMeta(owner, groupMembershipPatch(botRosterMeta(owner, allMeta), groupName, true))
}

// Persist every machine identity, including today's active source. That
// member becomes remote after a source switch and cannot rely on the new
// gateway's name-keyed bot metadata to remain seated in this room.
updateGroupChat(groupName, (room: GroupChatRoom) => {
room.members = roomMembers
room.roomId = roomId
room.continuityMode = hosted?.continuityMode || 'desktop'

if (desktopAuthority) {
room.desktopAuthorityHash = desktopAuthority.desktopAuthorityHash
room.desktopAuthorityToken = desktopAuthority.desktopAuthorityToken
room.desktopCoordinatorId = desktopAuthority.desktopCoordinatorId
}

if (hosted) {
room.hosted = hosted.authorityId
room.hostedEpoch = hosted.authorityEpoch
room.hostedConnectionId = hosted.connectionId
room.hostedSeq = 0
room.hostedStatus = {
state: 'ready',
label: b.roster.ready
}
}

if (image) {
room.image = image
}

return room
})
host.notify({
kind: 'info',
message: b.group.created(groupName, selected.length)
})
onClose()
onCreated?.(groupName)
} catch {
setCreateError(b.group.createFailed)
} finally {
setCreatePending(false)
}
}

return (
Expand All @@ -1220,7 +1404,7 @@ export function CreateGroupChatDialog({ open, roster, onClose, onCreated }: Crea
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{b.group.newTitle}</DialogTitle>
<DialogDescription>{`Pick 2–${GROUP_CHAT_MAX_MEMBERS} bots. Local memberships sync through each Bot profile; cross-machine members stay scoped to this room.`}</DialogDescription>
<DialogDescription>{b.group.newDesc}</DialogDescription>
</DialogHeader>
{/* TODO(bot-mode-types): this search box never takes focus when the dialog
opens — SearchField accepts no `autoFocus` prop and forwards no extra
Expand All @@ -1243,13 +1427,13 @@ export function CreateGroupChatDialog({ open, roster, onClose, onCreated }: Crea
variant="muted"
>
<RowButton
aria-label={b.group.removeFromSelection}
onClick={() =>
setChecked(prev => ({
...prev,
[botRosterKey(bot)]: false
}))
}
title={b.group.removeFromSelection}
>
{displayName(bot, botRosterMeta(bot, allMeta))}
<Codicon className="text-[0.6rem]" name="close" />
Expand Down Expand Up @@ -1309,7 +1493,7 @@ export function CreateGroupChatDialog({ open, roster, onClose, onCreated }: Crea
})
) : (
<div className="px-1.5 py-3 text-center text-xs text-(--ui-text-tertiary)">
{query.trim() ? `No bots match “${query.trim()}”` : 'No bots yet — create one first.'}
{query.trim() ? b.roster.noMatchQuery(query.trim()) : b.group.noBots}
</div>
)}
</div>
Expand All @@ -1324,7 +1508,7 @@ export function CreateGroupChatDialog({ open, roster, onClose, onCreated }: Crea
<form
onSubmit={event => {
event.preventDefault()
create()
void create()
}}
>
<Input
Expand All @@ -1336,15 +1520,14 @@ export function CreateGroupChatDialog({ open, roster, onClose, onCreated }: Crea
/>
</form>
</div>
{createError ? <div className="text-xs text-destructive">{createError}</div> : null}
<DialogFooter>
<Button onClick={onClose} variant="secondary">
{t.common.cancel}
</Button>
<Button
disabled={!canCreate}
onClick={create}
title={selected.length < 2 ? 'Pick at least 2 bots' : undefined}
>{`Create Group${selected.length ? ` (${selected.length})` : ''}`}</Button>
<Button disabled={!canCreate || createPending || hostProbePending} onClick={() => void create()}>
{createPending ? b.group.creating : b.group.createAction(selected.length)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
Expand Down
Loading
Loading