feat(desktop): Discord-style group-chat rows in Bot Mode, opening in the main chat window
Group chats in the Hermes Bots roster were rendered as a section header
("HERMES, PAPERCLIP MANAGER" + divider + tiny "Open chat" link) with each
member bot listed underneath — reading as separate DMs, not a group — and
opening the room rendered it INSIDE the narrow bots side panel.
Roster (plugin.js):
- Each group chat is now ONE standalone roster row (GroupRow), Discord-style:
stacked member avatars (BotFace composite, org glyph when empty), group
name, member count, latest room line as the preview with markdown syntax
flattened, relative last-activity time, and the "needs you" badge on the
row itself.
- The roster is a flat list: bot rows and group rows interleaved in the SAME
pin+recency ordering (a group's recency = its newest room-log entry).
Bots keep their individual DM rows; the group is an additional
independent row. The groupRoster() sectioning is removed.
- New stripPreviewMarkdown() flattens **bold**, `code`, > quotes, links,
and headings out of BotRow and GroupRow previews (raw ** and > were
leaking into row previews).
Main-window takeover (sdk/index.ts + plugin.js):
- New generic SDK door host.openWorkspace(id, { render, title, minWidth,
onClose }): registers a placement:'main' pane docked center into the
workspace zone (the same shape session tiles and previews use), wires
registerPaneCloser so tab Close / ⌘W tears the registration down, and
reveals it. Re-opening the same id refreshes in place and re-fronts.
Returns a disposer.
- Clicking a group row calls openGroupChat(): on desktops with
host.openWorkspace the room opens as a tab taking over the MAIN chat
area; older desktops keep the exact in-panel GroupChatWorkspace fallback
(feature-detected, established Bot Mode pattern). Disband closes the
main-window tab too; the composer/round-robin logic is untouched and
renders identically in both hosts.
Tests: roster-groups rewritten for the flat model + new helpers;
group-chat/create-group-chat source contracts updated to the
openGroupChat door; profile-prewarm harness stubs stripPreviewMarkdown.
This commit is contained in:
@@ -2988,38 +2988,70 @@ function slugify(value) {
|
||||
.slice(0, 64)
|
||||
}
|
||||
|
||||
/** Partition an already-sorted roster into user-defined groups. Returns
|
||||
* [{ group: null | name, bots }] — ungrouped bots first (no separator),
|
||||
* then each group alphabetically (case-insensitive), preserving the
|
||||
* roster's own ordering (pin + recency) within every section. Groups are
|
||||
* a per-bot `group` string in bot meta, so they ride the existing
|
||||
* ui_meta sync to every machine. Empty sections are dropped, so a group
|
||||
* disappears when its last member leaves — no group registry to manage. */
|
||||
function groupRoster(roster, metaByName) {
|
||||
const ungrouped = []
|
||||
const byGroup = new Map()
|
||||
/** Flatten markdown syntax out of a one-line roster preview so rows read
|
||||
* like Discord's — no raw **bold**, `code`, > quotes, or [link](url)
|
||||
* characters in the preview line. */
|
||||
function stripPreviewMarkdown(text) {
|
||||
return String(text || '')
|
||||
.replace(/```[\s\S]*?```/g, ' ')
|
||||
.replace(/`([^`\n]*)`/g, '$1')
|
||||
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
|
||||
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
|
||||
.replace(/(\*\*|__)(.*?)\1/g, '$2')
|
||||
.replace(/(^|\s)[*_](\S(?:.*?\S)?)[*_](?=\s|$|[.,;:!?])/g, '$1$2')
|
||||
.replace(/~~(.*?)~~/g, '$1')
|
||||
.replace(/^\s{0,3}#{1,6}\s+/gm, '')
|
||||
.replace(/^\s{0,3}>\s?/gm, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
for (const bot of roster) {
|
||||
const group = (botRosterMeta(bot, metaByName)?.group || '').trim()
|
||||
/** Group chats that should hold a roster row: every group named in bot meta
|
||||
* (local members) plus every room record that still has stored members or
|
||||
* log — cross-connection rooms whose members can't ride bot-meta. */
|
||||
function groupChatNames(metaByName, rooms) {
|
||||
const names = new Set(knownGroups(metaByName))
|
||||
|
||||
if (!group) {
|
||||
ungrouped.push(bot)
|
||||
for (const [name, room] of Object.entries(rooms || {})) {
|
||||
if ((Array.isArray(room?.members) && room.members.length) || (Array.isArray(room?.log) && room.log.length)) {
|
||||
names.add(name)
|
||||
}
|
||||
}
|
||||
|
||||
return [...names]
|
||||
}
|
||||
|
||||
/** Millisecond timestamp of a room's newest log entry (0 for a silent room) —
|
||||
* the group's recency key, competing in the same ordering as bot rows. */
|
||||
function groupLastActivity(room) {
|
||||
const log = Array.isArray(room?.log) ? room.log : []
|
||||
|
||||
return log.length ? log[log.length - 1].at || 0 : 0
|
||||
}
|
||||
|
||||
/** Seat a group's member roster: local bots whose meta names the group, plus
|
||||
* the room record's stored descriptors (remote members can't ride bot-meta).
|
||||
* Prefers the LIVE roster row for a stored descriptor when present. */
|
||||
function groupChatMemberBots(group, roster, metaByName) {
|
||||
const local = (roster || []).filter(
|
||||
bot => !bot.remoteSource && (botRosterMeta(bot, metaByName)?.group || '').trim() === group
|
||||
)
|
||||
const stored = ($groupChats.get()[group] || {}).members || []
|
||||
const seated = new Set(local.map(botRosterKey))
|
||||
const remote = []
|
||||
|
||||
for (const descriptor of stored) {
|
||||
const key = botRosterKey(descriptor)
|
||||
|
||||
if (seated.has(key)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (!byGroup.has(group)) {
|
||||
byGroup.set(group, [])
|
||||
}
|
||||
byGroup.get(group).push(bot)
|
||||
seated.add(key)
|
||||
remote.push((roster || []).find(bot => botRosterKey(bot) === key) || descriptor)
|
||||
}
|
||||
|
||||
const sections = ungrouped.length ? [{ group: null, bots: ungrouped }] : []
|
||||
|
||||
for (const group of [...byGroup.keys()].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))) {
|
||||
sections.push({ group, bots: byGroup.get(group) })
|
||||
}
|
||||
|
||||
return sections
|
||||
return [...local, ...remote]
|
||||
}
|
||||
|
||||
/** Existing group names, alphabetical — feeds the Move-to-group dialog. */
|
||||
@@ -3292,6 +3324,9 @@ async function disbandGroupChat(group, memberNames) {
|
||||
$groupChatWorkspace.set(null)
|
||||
}
|
||||
|
||||
// Retire the room's MAIN-window tab too (host.openWorkspace path).
|
||||
closeGroupChatMainTab(group)
|
||||
|
||||
const needs = { ...$groupNeedsYou.get() }
|
||||
|
||||
delete needs[group]
|
||||
@@ -3832,9 +3867,11 @@ function BotRow({ bot, onDelete, onEdit, onGroup }) {
|
||||
const previewSession = bot.preferred_session || last
|
||||
const { fromBot } = previewKind(previewSession?.preview)
|
||||
// DM previews read like DMs: strip the delivery prefix, keep the message.
|
||||
const displayPreview = fromBot
|
||||
? (previewSession?.preview || '').replace(A2A_PREFIX_RE, '').trim() || '…'
|
||||
: previewSession?.preview || bot.description || 'No conversations yet — say hi'
|
||||
const displayPreview = stripPreviewMarkdown(
|
||||
fromBot
|
||||
? (previewSession?.preview || '').replace(A2A_PREFIX_RE, '').trim() || '…'
|
||||
: previewSession?.preview || bot.description || 'No conversations yet — say hi'
|
||||
)
|
||||
|
||||
const warm = () => {
|
||||
// Multi-source row: pre-dial the agent's OWN source (feature-detected).
|
||||
@@ -7178,8 +7215,11 @@ function CreateGroupChatDialog({ open, roster, onClose, onCreated }) {
|
||||
|
||||
/** Merged room view for one group: shared timeline with per-member
|
||||
* attribution, a composer that drives the round-robin, and a working
|
||||
* indicator while member turns run. */
|
||||
function GroupChatWorkspace({ group, members }) {
|
||||
* indicator while member turns run. Renders identically in the MAIN chat
|
||||
* window (host.openWorkspace tile) and in the bots panel (older-desktop
|
||||
* fallback); `onBack` is where the Back button routes — the main tile's
|
||||
* closer, or clearing the in-panel workspace atom. */
|
||||
function GroupChatWorkspace({ group, members, onBack }) {
|
||||
const rooms = useValue($groupChats)
|
||||
const allMeta = useValue($botMeta)
|
||||
const room = rooms[group] || { log: [], running: false }
|
||||
@@ -7196,7 +7236,7 @@ function GroupChatWorkspace({ group, members }) {
|
||||
jsx(Button, {
|
||||
variant: 'ghost',
|
||||
size: 'sm',
|
||||
onClick: () => $groupChatWorkspace.set(null),
|
||||
onClick: () => (onBack ? onBack() : $groupChatWorkspace.set(null)),
|
||||
children: 'Back'
|
||||
}),
|
||||
jsx('div', {
|
||||
@@ -7412,6 +7452,163 @@ function GroupChatWorkspace({ group, members }) {
|
||||
})
|
||||
}
|
||||
|
||||
/** Live closers for group-chat MAIN-window tabs, by group name — so a
|
||||
* disband (or the room view's own Back) can retire the tab it opened. */
|
||||
const groupChatMainTabs = new Map()
|
||||
|
||||
function closeGroupChatMainTab(group) {
|
||||
const close = groupChatMainTabs.get(group)
|
||||
|
||||
groupChatMainTabs.delete(group)
|
||||
|
||||
if (typeof close === 'function') {
|
||||
try {
|
||||
close()
|
||||
} catch {
|
||||
/* tab already gone */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Main-window wrapper: seats the member roster reactively (live roster +
|
||||
* bot meta + the room's stored cross-connection descriptors) so the room
|
||||
* keeps working as members change while the tab is open. */
|
||||
function GroupChatMainView({ group }) {
|
||||
const allMeta = useValue($botMeta)
|
||||
// Subscribe: membership changes ride bot meta AND the room record.
|
||||
useValue($groupChats)
|
||||
const roster = useValue($lastRoster)
|
||||
const members = groupChatMemberBots(group, roster, allMeta)
|
||||
|
||||
return jsx(GroupChatWorkspace, { group, members, onBack: () => closeGroupChatMainTab(group) })
|
||||
}
|
||||
|
||||
/** Open a group chat the Discord way: a tab taking over the MAIN chat window
|
||||
* (host.openWorkspace, newer desktops), falling back to the in-panel room
|
||||
* view on desktops whose SDK predates the main-area door. */
|
||||
function openGroupChat(group) {
|
||||
$groupNeedsYou.set({ ...$groupNeedsYou.get(), [group]: false })
|
||||
|
||||
if (typeof host.openWorkspace === 'function') {
|
||||
try {
|
||||
const close = host.openWorkspace(`${ID}:group:${slugify(group)}`, {
|
||||
title: group,
|
||||
minWidth: '24rem',
|
||||
render: () => jsx(GroupChatMainView, { group }),
|
||||
onClose: () => groupChatMainTabs.delete(group)
|
||||
})
|
||||
|
||||
groupChatMainTabs.set(group, close)
|
||||
|
||||
return
|
||||
} catch {
|
||||
// Fall through to the in-panel room below.
|
||||
}
|
||||
}
|
||||
|
||||
$groupChatWorkspace.set(group)
|
||||
}
|
||||
|
||||
/** One group chat as ONE roster row — the Discord shape: stacked member
|
||||
* avatars, group name, member count, the newest room line as the preview
|
||||
* (markdown flattened), relative time of the last activity, and the
|
||||
* needs-you badge on the row itself. Sorts into the same recency ordering
|
||||
* as bot rows; clicking opens the room in the main chat window. */
|
||||
function GroupRow({ group, members, needsYou, onOpen }) {
|
||||
const rooms = useValue($groupChats)
|
||||
const allMeta = useValue($botMeta)
|
||||
const room = rooms[group] || { log: [] }
|
||||
const log = Array.isArray(room.log) ? room.log : []
|
||||
const last = log.length ? log[log.length - 1] : null
|
||||
const lastAt = groupLastActivity(room)
|
||||
const preview = last
|
||||
? `${last.from?.kind === 'user' ? 'You' : `@${last.from?.name || 'bot'}`}: ${stripPreviewMarkdown(last.text) || '…'}`
|
||||
: 'No messages yet — say hi to the room'
|
||||
const faces = members.slice(0, 3)
|
||||
|
||||
return jsxs('button', {
|
||||
type: 'button',
|
||||
onClick: () => {
|
||||
haptic('tap')
|
||||
onOpen(group)
|
||||
},
|
||||
className: cn(
|
||||
'flex w-full min-w-0 max-w-full items-center gap-2.5 overflow-hidden rounded-md px-2 py-2 text-left transition-colors',
|
||||
'hover:bg-(--chrome-action-hover)'
|
||||
),
|
||||
children: [
|
||||
// Composite avatar: up to three member faces fanned like Discord's
|
||||
// group-DM icon; a bare glyph when the room has no seated members.
|
||||
jsx('div', {
|
||||
className: 'flex w-[34px] shrink-0 items-center justify-center',
|
||||
children: faces.length
|
||||
? jsx('div', {
|
||||
className: 'flex items-center -space-x-2.5',
|
||||
children: faces.map(member => {
|
||||
const meta = member.remoteSource ? null : allMeta[member.name]
|
||||
const { shape, color, image } = botAppearance(member.name, meta)
|
||||
|
||||
return jsx(
|
||||
'div',
|
||||
{
|
||||
className: 'rounded-full ring-2 ring-(--ui-bg-primary,#111)',
|
||||
children: jsx(BotFace, {
|
||||
shape,
|
||||
color,
|
||||
image: image && !isBackfilledFacePng(image) ? image : null,
|
||||
size: 20,
|
||||
name: member.name,
|
||||
mood: 'idle'
|
||||
})
|
||||
},
|
||||
botRosterKey(member)
|
||||
)
|
||||
})
|
||||
})
|
||||
: jsx(Codicon, { name: 'organization', className: 'text-(--ui-text-tertiary)' })
|
||||
}),
|
||||
jsxs('div', {
|
||||
className: 'min-w-0 flex-1',
|
||||
children: [
|
||||
jsxs('div', {
|
||||
className: 'flex items-baseline justify-between gap-2',
|
||||
children: [
|
||||
jsxs('div', {
|
||||
className: 'flex min-w-0 items-baseline gap-1.5 truncate',
|
||||
children: [
|
||||
jsx('span', { className: 'truncate text-[0.8125rem] font-medium', children: group }),
|
||||
jsx('span', {
|
||||
className: 'shrink-0 text-[0.6875rem] text-(--ui-text-quaternary)',
|
||||
children: `${members.length} bots`
|
||||
})
|
||||
]
|
||||
}),
|
||||
needsYou
|
||||
? jsx('span', {
|
||||
className:
|
||||
'shrink-0 rounded-full bg-(--ui-accent,#4f9cf9) px-1.5 text-[0.6rem] font-semibold text-white',
|
||||
title: 'A bot in this room needs your input',
|
||||
children: 'needs you'
|
||||
})
|
||||
: null,
|
||||
lastAt
|
||||
? jsx('span', {
|
||||
className: 'shrink-0 text-[0.6875rem] text-(--ui-text-quaternary)',
|
||||
children: relativeTime(lastAt)
|
||||
})
|
||||
: null
|
||||
]
|
||||
}),
|
||||
jsx('div', {
|
||||
className: 'min-w-0 truncate text-xs text-(--ui-text-tertiary)',
|
||||
children: preview
|
||||
})
|
||||
]
|
||||
})
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
function BotsPane() {
|
||||
const { data, error, isLoading, refetch } = useRoster()
|
||||
const gatewayState = useValue(host.state.gateway)
|
||||
@@ -7427,6 +7624,7 @@ function BotsPane() {
|
||||
const sessionsWorkspaceName = useValue($botSessionsWorkspace)
|
||||
const groupChatName = useValue($groupChatWorkspace)
|
||||
const groupNeedsYou = useValue($groupNeedsYou)
|
||||
const groupRooms = useValue($groupChats)
|
||||
|
||||
// The socket opening (boot, SSH reconnect, sleep/wake) is the signal to
|
||||
// retry immediately instead of waiting out the poll interval.
|
||||
@@ -7468,6 +7666,32 @@ function BotsPane() {
|
||||
})
|
||||
const activeSourceRoster = roster.filter(bot => !bot.remoteSource)
|
||||
const filteredRoster = filterBots(roster, allMeta, query)
|
||||
// Group chats are first-class roster rows (Discord-style): one standalone
|
||||
// row per room, competing in the SAME recency ordering as bot rows — a
|
||||
// group's activity is its newest room-log line. Pinned bots still lead;
|
||||
// groups and unpinned bots interleave by recency below them.
|
||||
const needle = query.trim().toLowerCase()
|
||||
const groupRows = groupChatNames(allMeta, groupRooms)
|
||||
.filter(name => !needle || name.toLowerCase().includes(needle))
|
||||
.map(name => ({
|
||||
kind: 'group',
|
||||
name,
|
||||
members: groupChatMemberBots(name, roster, allMeta),
|
||||
activity: groupLastActivity(groupRooms[name])
|
||||
}))
|
||||
const rosterRows = [
|
||||
...filteredRoster.map(bot => ({ kind: 'bot', bot, pinned: isPinned(bot), activity: activityOf(bot) })),
|
||||
...groupRows
|
||||
].sort((a, b) => {
|
||||
const pa = a.pinned ? 1 : 0
|
||||
const pb = b.pinned ? 1 : 0
|
||||
|
||||
if (pa !== pb) {
|
||||
return pb - pa
|
||||
}
|
||||
|
||||
return b.activity - a.activity
|
||||
})
|
||||
|
||||
if (live) {
|
||||
$lastRoster.set(roster)
|
||||
@@ -7486,32 +7710,7 @@ function BotsPane() {
|
||||
return jsx(ProfileSessionsWorkspace, { bot: sessionsWorkspaceBot })
|
||||
}
|
||||
|
||||
const groupChatMembers = groupChatName
|
||||
? (() => {
|
||||
const local = activeSourceRoster.filter(
|
||||
bot => (botRosterMeta(bot, allMeta)?.group || '').trim() === groupChatName
|
||||
)
|
||||
// Remote members live on the room record (they can't ride bot-meta).
|
||||
// Prefer the LIVE roster row for each descriptor when present —
|
||||
// fresher handle/label — else seat the stored descriptor itself.
|
||||
const stored = ($groupChats.get()[groupChatName] || {}).members || []
|
||||
const seated = new Set(local.map(botRosterKey))
|
||||
const remote = []
|
||||
|
||||
for (const descriptor of stored) {
|
||||
const key = botRosterKey(descriptor)
|
||||
|
||||
if (seated.has(key)) {
|
||||
continue
|
||||
}
|
||||
|
||||
seated.add(key)
|
||||
remote.push(roster.find(bot => botRosterKey(bot) === key) || descriptor)
|
||||
}
|
||||
|
||||
return [...local, ...remote]
|
||||
})()
|
||||
: []
|
||||
const groupChatMembers = groupChatName ? groupChatMemberBots(groupChatName, roster, allMeta) : []
|
||||
|
||||
if (groupChatName && groupChatMembers.length) {
|
||||
return jsx(GroupChatWorkspace, { group: groupChatName, members: groupChatMembers })
|
||||
@@ -7677,7 +7876,7 @@ function BotsPane() {
|
||||
title: 'No agents yet',
|
||||
description: 'Create your first teammate.'
|
||||
})
|
||||
: filteredRoster.length === 0
|
||||
: filteredRoster.length === 0 && rosterRows.length === 0
|
||||
? jsx('div', {
|
||||
'aria-live': 'polite',
|
||||
className:
|
||||
@@ -7689,49 +7888,26 @@ function BotsPane() {
|
||||
className: 'hermes-bots-roster min-h-0 flex-1',
|
||||
children: jsx('div', {
|
||||
className: 'grid w-full min-w-0 gap-0.5 px-1.5 pb-2',
|
||||
children: groupRoster(filteredRoster, allMeta).flatMap(section => [
|
||||
section.group
|
||||
? jsxs('div', {
|
||||
className: 'mt-2 flex items-center gap-2 px-1 pb-0.5 first:mt-0.5',
|
||||
children: [
|
||||
jsx('span', {
|
||||
className:
|
||||
'shrink-0 text-[0.625rem] font-semibold uppercase tracking-wider text-(--ui-text-quaternary)',
|
||||
children: section.group
|
||||
}),
|
||||
jsx('div', { className: 'h-px min-w-0 flex-1 bg-(--ui-stroke-secondary)' }),
|
||||
groupNeedsYou[section.group]
|
||||
? jsx('span', {
|
||||
className:
|
||||
'shrink-0 rounded-full bg-(--ui-accent,#4f9cf9) px-1.5 text-[0.6rem] font-semibold text-white',
|
||||
title: 'A bot in this room needs your input',
|
||||
children: 'needs you'
|
||||
})
|
||||
: null,
|
||||
section.bots.length > 1 && section.bots.length <= GROUP_CHAT_MAX_MEMBERS
|
||||
? jsx('button', {
|
||||
type: 'button',
|
||||
className:
|
||||
'shrink-0 rounded px-1 text-[0.625rem] font-medium text-(--ui-text-tertiary) transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground',
|
||||
title: `Open the ${section.group} group chat`,
|
||||
onClick: () => {
|
||||
$groupNeedsYou.set({ ...$groupNeedsYou.get(), [section.group]: false })
|
||||
$groupChatWorkspace.set(section.group)
|
||||
},
|
||||
children: 'Open chat'
|
||||
})
|
||||
: null
|
||||
]
|
||||
}, `group:${section.group}`)
|
||||
: null,
|
||||
...section.bots.map(bot =>
|
||||
jsx(
|
||||
BotRow,
|
||||
{ bot, onDelete: setDeleting, onEdit: setEditing, onGroup: setGrouping },
|
||||
botRosterKey(bot)
|
||||
)
|
||||
)
|
||||
])
|
||||
// Flat, Discord-style list: bot rows and group rows
|
||||
// interleaved by recency — no section headers.
|
||||
children: rosterRows.map(row =>
|
||||
row.kind === 'group'
|
||||
? jsx(
|
||||
GroupRow,
|
||||
{
|
||||
group: row.name,
|
||||
members: row.members,
|
||||
needsYou: Boolean(groupNeedsYou[row.name]),
|
||||
onOpen: openGroupChat
|
||||
},
|
||||
`group:${row.name}`
|
||||
)
|
||||
: jsx(
|
||||
BotRow,
|
||||
{ bot: row.bot, onDelete: setDeleting, onEdit: setEditing, onGroup: setGrouping },
|
||||
botRosterKey(row.bot)
|
||||
)
|
||||
)
|
||||
})
|
||||
}),
|
||||
jsx('div', {
|
||||
@@ -7757,7 +7933,7 @@ function BotsPane() {
|
||||
// registered connections — their turns route to their own machines.
|
||||
roster,
|
||||
onClose: () => setGroupCreateOpen(false),
|
||||
onCreated: groupName => $groupChatWorkspace.set(groupName)
|
||||
onCreated: groupName => openGroupChat(groupName)
|
||||
}),
|
||||
jsx(EditProfileDialog, {
|
||||
bot: editing,
|
||||
|
||||
@@ -25,8 +25,9 @@ test('source contract: create-group modal has search, checkboxes, name, create',
|
||||
// so the room rides the ui_meta sync path (no new persistence).
|
||||
assert.match(pluginSource, /selected\.length >= 2/)
|
||||
assert.match(pluginSource, /saveBotMeta\(bot\.name, \{ group: groupName \}\)/)
|
||||
// Creating drops the user straight into the room.
|
||||
assert.match(pluginSource, /onCreated: groupName => \$groupChatWorkspace\.set\(groupName\)/)
|
||||
// Creating drops the user straight into the room (main window when the
|
||||
// desktop offers host.openWorkspace, in-panel fallback otherwise).
|
||||
assert.match(pluginSource, /onCreated: groupName => openGroupChat\(groupName\)/)
|
||||
})
|
||||
|
||||
test('source contract: group name falls back to member names, Discord-style', () => {
|
||||
|
||||
@@ -241,9 +241,13 @@ test('log trimming keeps watermarks consistent', () => {
|
||||
assert.equal(watermarks.builder, 0)
|
||||
})
|
||||
|
||||
test('source contract: workspace + header affordance + prompt rules are wired', () => {
|
||||
test('source contract: workspace + main-window door + prompt rules are wired', () => {
|
||||
assert.match(pluginSource, /function GroupChatWorkspace\(/)
|
||||
assert.match(pluginSource, /Open chat/)
|
||||
// Group rows open through the main-window door, feature-detected with the
|
||||
// in-panel room as the older-desktop fallback.
|
||||
assert.match(pluginSource, /function openGroupChat\(/)
|
||||
assert.match(pluginSource, /typeof host\.openWorkspace === 'function'/)
|
||||
assert.match(pluginSource, /\$groupChatWorkspace\.set\(group\)/)
|
||||
assert.match(pluginSource, /reply with exactly "\(pass\)"/i)
|
||||
assert.match(pluginSource, /\[Group chat: "\$\{groupName\}"\]/)
|
||||
})
|
||||
|
||||
@@ -85,6 +85,7 @@ function renderBotRow(input = 'alpha') {
|
||||
relativeTime: () => 'now',
|
||||
saveBotMeta: () => undefined,
|
||||
showsHandle: () => false,
|
||||
stripPreviewMarkdown: text => String(text || ''),
|
||||
useValue: store => store.get()
|
||||
}
|
||||
|
||||
@@ -202,6 +203,7 @@ test('behavior: remote default does not open this-device chat when the source di
|
||||
relativeTime: () => 'now',
|
||||
saveBotMeta: () => undefined,
|
||||
showsHandle: () => false,
|
||||
stripPreviewMarkdown: text => String(text || ''),
|
||||
useValue: store => store.get()
|
||||
}
|
||||
|
||||
|
||||
@@ -29,51 +29,58 @@ function load() {
|
||||
.replace(/^import .* from 'react'\r?\n/m, '')
|
||||
.replace(/^import .* from 'react\/jsx-runtime'\r?\n/m, '')
|
||||
.replace('export default {', 'globalThis.plugin = {')
|
||||
.concat('\nglobalThis.__groups = { groupRoster, knownGroups };\n')
|
||||
.concat(
|
||||
'\nglobalThis.__groups = { groupChatNames, groupLastActivity, groupChatMemberBots, knownGroups, stripPreviewMarkdown, $groupChats };\n'
|
||||
)
|
||||
vm.runInNewContext(source, context, { filename: 'plugin.js' })
|
||||
return context.__groups
|
||||
}
|
||||
|
||||
const ROSTER = [{ name: 'hermes' }, { name: 'researcher' }, { name: 'builder' }, { name: 'pm' }]
|
||||
|
||||
test('groupRoster: ungrouped bots lead, groups follow alphabetically, roster order kept inside', () => {
|
||||
const { groupRoster } = load()
|
||||
const meta = {
|
||||
researcher: { group: 'Research' },
|
||||
pm: { group: 'Ops' },
|
||||
builder: { group: 'Research' }
|
||||
test('groupChatNames: unions bot-meta groups with room records that carry members or log', () => {
|
||||
const { groupChatNames } = load()
|
||||
const meta = { researcher: { group: 'Research' }, pm: { group: 'Ops' } }
|
||||
const rooms = {
|
||||
Research: { log: [], members: [] }, // already known via meta
|
||||
Remote: { log: [], members: [{ name: 'spark', remoteSource: true }] },
|
||||
Chatty: { log: [{ from: { kind: 'user' }, text: 'hi', at: 5 }] },
|
||||
Empty: { log: [], members: [] } // nothing behind it — no row
|
||||
}
|
||||
|
||||
const sections = groupRoster(ROSTER, meta)
|
||||
const names = groupChatNames(meta, rooms)
|
||||
|
||||
// JSON round-trip: vm-realm arrays fail deepEqual on prototype identity.
|
||||
assert.equal(
|
||||
JSON.stringify(sections.map(s => [s.group, s.bots.map(b => b.name)])),
|
||||
JSON.stringify([
|
||||
[null, ['hermes']],
|
||||
['Ops', ['pm']],
|
||||
['Research', ['researcher', 'builder']]
|
||||
])
|
||||
)
|
||||
assert.equal(JSON.stringify([...names].sort()), JSON.stringify(['Chatty', 'Ops', 'Remote', 'Research']))
|
||||
})
|
||||
|
||||
test('groupRoster: no groups means one plain section — zero separators', () => {
|
||||
const { groupRoster } = load()
|
||||
test('groupLastActivity: newest room-log timestamp, 0 for silence', () => {
|
||||
const { groupLastActivity } = load()
|
||||
|
||||
const sections = groupRoster(ROSTER, {})
|
||||
|
||||
assert.equal(sections.length, 1)
|
||||
assert.equal(sections[0].group, null)
|
||||
assert.equal(sections[0].bots.length, 4)
|
||||
assert.equal(groupLastActivity({ log: [{ at: 3 }, { at: 9 }] }), 9)
|
||||
assert.equal(groupLastActivity({ log: [] }), 0)
|
||||
assert.equal(groupLastActivity(undefined), 0)
|
||||
})
|
||||
|
||||
test('groupRoster: blank/whitespace group values count as ungrouped', () => {
|
||||
const { groupRoster } = load()
|
||||
test('groupChatMemberBots: seats local meta members plus stored remote descriptors, preferring live rows', () => {
|
||||
const { groupChatMemberBots, $groupChats } = load()
|
||||
const roster = [
|
||||
{ name: 'researcher' },
|
||||
{ name: 'builder' },
|
||||
{ name: 'spark', remoteSource: true, connectionId: 'c1', sourceScoped: true }
|
||||
]
|
||||
$groupChats.set({
|
||||
Research: {
|
||||
log: [],
|
||||
members: [{ name: 'spark', remoteSource: true, connectionId: 'c1', sourceScoped: true }]
|
||||
}
|
||||
})
|
||||
|
||||
const sections = groupRoster(ROSTER, { pm: { group: ' ' }, builder: { group: '' }, hermes: { group: null } })
|
||||
const members = groupChatMemberBots('Research', roster, {
|
||||
researcher: { group: 'Research' },
|
||||
builder: { group: 'Ops' }
|
||||
})
|
||||
|
||||
assert.equal(sections.length, 1)
|
||||
assert.equal(sections[0].group, null)
|
||||
assert.equal(JSON.stringify(members.map(m => m.name)), JSON.stringify(['researcher', 'spark']))
|
||||
// The LIVE roster row was preferred over the stored descriptor.
|
||||
assert.equal(members[1], roster[2])
|
||||
})
|
||||
|
||||
test('knownGroups: unique, trimmed, alphabetical', () => {
|
||||
@@ -90,8 +97,26 @@ test('knownGroups: unique, trimmed, alphabetical', () => {
|
||||
assert.equal(JSON.stringify(groups), JSON.stringify(['Ops', 'research']))
|
||||
})
|
||||
|
||||
test('source contract: the roster render is sectioned and the row menu offers grouping', () => {
|
||||
assert.match(pluginSource, /groupRoster\(filteredRoster, allMeta\)\.flatMap/)
|
||||
test('stripPreviewMarkdown: flattens bold, quotes, code, and links out of previews', () => {
|
||||
const { stripPreviewMarkdown } = load()
|
||||
|
||||
assert.equal(stripPreviewMarkdown('**Plan**: ship the `thing`'), 'Plan: ship the thing')
|
||||
assert.equal(stripPreviewMarkdown('> quoted wisdom'), 'quoted wisdom')
|
||||
assert.equal(stripPreviewMarkdown('see [the doc](https://x.y/z) now'), 'see the doc now')
|
||||
assert.equal(stripPreviewMarkdown('## Heading\nbody'), 'Heading body')
|
||||
assert.equal(stripPreviewMarkdown(''), '')
|
||||
})
|
||||
|
||||
test('source contract: the roster is a flat list of bot + group rows and the row menu offers grouping', () => {
|
||||
// Flat Discord-style list — the sectioned groupRoster presentation is gone.
|
||||
assert.doesNotMatch(pluginSource, /function groupRoster\(/)
|
||||
assert.match(pluginSource, /rosterRows\.map\(row =>/)
|
||||
assert.match(pluginSource, /function GroupRow\(/)
|
||||
assert.match(pluginSource, /onGroup: setGrouping/)
|
||||
assert.match(pluginSource, /'Move to group…'/)
|
||||
})
|
||||
|
||||
test('source contract: group rows carry the needs-you badge and open via openGroupChat', () => {
|
||||
assert.match(pluginSource, /needsYou: Boolean\(groupNeedsYou\[row\.name\]\)/)
|
||||
assert.match(pluginSource, /onOpen: openGroupChat/)
|
||||
})
|
||||
|
||||
@@ -19,12 +19,19 @@
|
||||
*/
|
||||
|
||||
import { atom, computed, type ReadableAtom } from 'nanostores'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { PRIMARY_SESSION_VIEW } from '@/app/chat/session-view'
|
||||
import { openSession, type OpenSessionIntent } from '@/app/open-session'
|
||||
import type { ClientSessionState } from '@/app/types'
|
||||
import { $narrowViewport } from '@/components/pane-shell/tree/store'
|
||||
import {
|
||||
$narrowViewport,
|
||||
registerPaneCloser,
|
||||
removeTreePane,
|
||||
revealTreePane
|
||||
} from '@/components/pane-shell/tree/store'
|
||||
import { onGatewayEvent } from '@/contrib/events'
|
||||
import { registry } from '@/contrib/registry'
|
||||
import { deleteProfile, getLogs, getStatus, type HermesGateway } from '@/hermes'
|
||||
import {
|
||||
$gateway,
|
||||
@@ -388,6 +395,59 @@ export const host = {
|
||||
)
|
||||
},
|
||||
|
||||
/** Open (or re-front) a plugin-rendered MAIN-AREA workspace tile — the same
|
||||
* surface a session tile or a preview occupies: a closeable tab docked
|
||||
* beside the main workspace, taking over the chat area when active. This is
|
||||
* the generic main-view door for plugins whose surface is not a stored
|
||||
* session (`openSession` stays the door for those). Re-opening the same
|
||||
* `id` refreshes `render`/`title` in place and fronts the existing tab
|
||||
* instead of stacking a duplicate. Returns a disposer that closes the tab;
|
||||
* the tab's own Close (⌘W / strip ✕) routes through the same teardown and
|
||||
* fires `onClose`. Feature-detect on older desktops
|
||||
* (`typeof host.openWorkspace === 'function'`) and keep an in-panel
|
||||
* fallback. */
|
||||
openWorkspace: (
|
||||
id: string,
|
||||
options: { minWidth?: string; onClose?: () => void; render: () => ReactNode; title?: string }
|
||||
): (() => void) => {
|
||||
const key = (id ?? '').trim()
|
||||
|
||||
if (!key || typeof options?.render !== 'function') {
|
||||
throw new Error('openWorkspace: an id and a render function are required')
|
||||
}
|
||||
|
||||
const paneId = `plugin-workspace:${key}`
|
||||
|
||||
const dispose = registry.register({
|
||||
area: 'panes',
|
||||
data: {
|
||||
// The session-tile shape: a full workspace surface docked beside main,
|
||||
// closeable so it keeps its tab when it lands in a zone of its own.
|
||||
dock: { pane: 'workspace', pos: 'center' },
|
||||
minWidth: options.minWidth ?? '22rem',
|
||||
placement: 'main'
|
||||
},
|
||||
id: paneId,
|
||||
render: options.render,
|
||||
title: options.title ?? key
|
||||
})
|
||||
|
||||
const close = () => {
|
||||
registerPaneCloser(paneId)
|
||||
dispose()
|
||||
removeTreePane(paneId)
|
||||
options.onClose?.()
|
||||
}
|
||||
|
||||
// Route the tab's Close through OUR teardown: without a closer, closing a
|
||||
// core-sourced contributed pane only dismisses it and the registration
|
||||
// would leak past the plugin surface that owns it.
|
||||
registerPaneCloser(paneId, close)
|
||||
revealTreePane(paneId)
|
||||
|
||||
return close
|
||||
},
|
||||
|
||||
/** Start a fresh chat draft, optionally pointed at another profile (its
|
||||
* backend spins up in the background — same door the sidebar's per-profile
|
||||
* "+" uses). */
|
||||
|
||||
Reference in New Issue
Block a user