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:
Teknium
2026-08-17 16:06:19 -07:00
parent d16413ef43
commit ae5c4c1672
6 changed files with 408 additions and 140 deletions
+278 -102
View File
@@ -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/)
})
+61 -1
View File
@@ -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). */