From 207ae2eda89f4644a3f75e717cf0f7fe1ceaeeaf Mon Sep 17 00:00:00 2001 From: Roxy Date: Mon, 7 Sep 2026 14:16:20 -0600 Subject: [PATCH 1/4] feat(channels): bots that talk to each other in one chat Channel members, plus the routing/prompt fixes that made hand-offs work: - channelPrompt(): the roster goes INTO the system prompt, so a bot knows who else is in the room and hands off by @mention instead of spawning a `task` subagent (a blank child of its own context) or hunting for a GitHub user. - resolveRecipient(): only a LEADING @mention addresses a member. A mention mid-sentence ("make the PR and then call @Bobo") is the user talking ABOUT someone, so the turn stays with the host, who works and then hands off. - A member's brief is framed as a standing ROLE, not a work order, so being greeted gets a reply instead of kicking off the whole job on contact. - buildChatMessages(): normalize the TRAILING edge of the window. A hand-off leaves the transcript ending on an assistant turn, which Gemini rejects outright ("Requests ending with a model turn are not supported"). - Members panel: highlight the instructions box and clearer placeholders, so the brief doesn't end up in the one-line role field. --- .claude/settings.json | 5 + package-lock.json | 4 +- src/main/db/migrations.ts | 20 +- src/main/db/repo.ts | 92 ++++- src/main/harness/agent.ts | 23 +- src/main/ipc/index.ts | 4 + src/main/services/compaction.ts | 4 +- src/main/services/session-turn.ts | 1 + src/preload/index.ts | 3 + src/renderer/src/components/BotAvatar.tsx | 105 ++++++ .../src/components/ChannelMembersPanel.tsx | 254 +++++++++++++ src/renderer/src/components/ChatView.tsx | 351 +++++++++++------- src/renderer/src/components/Composer.tsx | 132 ++++++- src/renderer/src/components/MessageBubble.tsx | 74 +++- src/renderer/src/components/MessageParts.tsx | 44 ++- src/renderer/src/components/ToolCall.tsx | 1 + src/renderer/src/lib/store.ts | 202 +++++++++- src/renderer/src/locales/default.json | 1 + src/shared/api.ts | 14 + src/shared/channel-members.ts | 287 ++++++++++++++ src/shared/ipc.ts | 3 + src/shared/types.ts | 68 ++++ test/shared.ts | 216 +++++++++++ 23 files changed, 1704 insertions(+), 204 deletions(-) create mode 100644 .claude/settings.json create mode 100644 src/renderer/src/components/BotAvatar.tsx create mode 100644 src/renderer/src/components/ChannelMembersPanel.tsx create mode 100644 src/shared/channel-members.ts diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..e0e4c18 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,5 @@ +{ + "permissions": { + "defaultMode": "dontAsk" + } +} diff --git a/package-lock.json b/package-lock.json index f3417b8..581a522 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "roxy", - "version": "0.0.93", + "version": "0.0.94", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "roxy", - "version": "0.0.93", + "version": "0.0.94", "license": "MIT", "dependencies": { "@ai-sdk/anthropic": "^2.0.85", diff --git a/src/main/db/migrations.ts b/src/main/db/migrations.ts index 401482d..558bdf7 100644 --- a/src/main/db/migrations.ts +++ b/src/main/db/migrations.ts @@ -493,7 +493,22 @@ export const MIGRATIONS: Migration[] = [ hidden_at INTEGER NOT NULL, PRIMARY KEY (provider_id, model) ); - ` + `, + + // ---- v24: channel members (multi-bot sessions) ---- + // A session is a CHANNEL several bots sit in, not a one-on-one chat with a + // single agent. This column holds the ATTACHED specialists as a JSON + // BotMember[]; Roxy (the host) is implicit and never stored, so she cannot be + // detached by a bad write, and every session that predates this - NULL here - + // is already a valid channel with just her in it. See shared/channel-members.ts. + (db) => { + addColumnIfMissing(db, 'chats', 'channel_members', 'TEXT') + // WHICH member wrote an assistant message. Denormalized onto the row (a + // JSON BotAuthor) rather than joined from the member list, because a bot + // can be detached later and the transcript must still show who spoke. + // NULL = written by Roxy, which every pre-channel message was. + addColumnIfMissing(db, 'messages', 'author', 'TEXT') + } ] /** @@ -527,6 +542,9 @@ export function repairSchema(db: Database): void { addColumnIfMissing(db, 'chats', 'worktree_pending', 'TEXT') // v21's composite (multi-repo) workstream membership. addColumnIfMissing(db, 'chats', 'repos', 'TEXT') + // v22's channel membership and per-message authorship. + addColumnIfMissing(db, 'chats', 'channel_members', 'TEXT') + addColumnIfMissing(db, 'messages', 'author', 'TEXT') // v17's per-session inference config. addColumnIfMissing(db, 'chats', 'agent_id', 'TEXT') addColumnIfMissing(db, 'chats', 'reasoning_effort', 'TEXT') diff --git a/src/main/db/repo.ts b/src/main/db/repo.ts index 0b0f9be..67df214 100644 --- a/src/main/db/repo.ts +++ b/src/main/db/repo.ts @@ -4,9 +4,12 @@ import { normalizeServerConfig, type McpServerConfig, type McpServerRecord } fro import { DEFAULT_BRANCH_PREFIX, normalizeBranchPrefix } from '../../shared/branch' import { DEFAULT_LANGUAGE, normalizeLanguage } from '../../shared/i18n' import type { Language } from '../../shared/i18n' +import { ROXY_HOST_ID } from '../../shared/channel-members' import type { AddMessageInput, AppSettings, + BotAuthor, + BotMember, Chat, ConnectedProvider, ConnectProviderInput, @@ -73,6 +76,7 @@ interface ChatRow { context_summary_at: number | null description: string | null tasks: string | null + channel_members: string | null sort_order: number created_at: number updated_at: number @@ -84,6 +88,7 @@ interface MessageRow { role: string content: string parts: string | null + author: string | null created_at: number } @@ -595,6 +600,28 @@ function parseTasks(raw: string | null): SessionTask[] { } } +/** + * Parse the channel_members JSON column into the session's ATTACHED bots. + * + * The host is not stored (see the v22 migration), so this returns only the + * specialists and every reader goes through `withHost` to get the real member + * list. Malformed rows degrade to an empty list rather than throwing: a session + * with no attached bots is still a working channel. + */ +function parseChannelMembers(raw: string | null): BotMember[] { + if (!raw) return [] + try { + const arr: unknown = JSON.parse(raw) + if (!Array.isArray(arr)) return [] + return arr.filter( + (m): m is BotMember => + !!m && typeof (m as BotMember).id === 'string' && typeof (m as BotMember).name === 'string' + ) + } catch { + return [] + } +} + function rowToChat(row: ChatRow): Chat { return { id: row.id, @@ -616,6 +643,7 @@ function rowToChat(row: ChatRow): Chat { contextSummaryAt: row.context_summary_at, description: row.description, tasks: parseTasks(row.tasks), + channelMembers: parseChannelMembers(row.channel_members), sortOrder: row.sort_order, createdAt: row.created_at, updatedAt: row.updated_at @@ -870,16 +898,16 @@ export function forkChat(sourceId: string, input: { title?: string } = {}): Chat const now = Date.now() const title = input.title?.trim() || `${source.title} (fork)` const messages = db - .prepare('SELECT role, content, parts, created_at FROM messages WHERE chat_id = ?') - .all(sourceId) as Pick[] + .prepare('SELECT role, content, parts, author, created_at FROM messages WHERE chat_id = ?') + .all(sourceId) as Pick[] const insertMessage = db.prepare( - 'INSERT INTO messages(id, chat_id, role, content, parts, created_at) VALUES(?, ?, ?, ?, ?, ?)' + 'INSERT INTO messages(id, chat_id, role, content, parts, author, created_at) VALUES(?, ?, ?, ?, ?, ?, ?)' ) db.transaction(() => { db.prepare( - `INSERT INTO chats(id, title, kind, provider_id, model, agent_id, reasoning_effort, context_limit, workspace_path, parent_id, context_summary, context_summary_at, description, sort_order, created_at, updated_at) - VALUES(?, ?, 'main', ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?)` + `INSERT INTO chats(id, title, kind, provider_id, model, agent_id, reasoning_effort, context_limit, workspace_path, parent_id, context_summary, context_summary_at, description, channel_members, sort_order, created_at, updated_at) + VALUES(?, ?, 'main', ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?)` ).run( id, title, @@ -892,12 +920,13 @@ export function forkChat(sourceId: string, input: { title?: string } = {}): Chat source.contextSummary, source.contextSummaryAt, source.description, + source.channelMembers.length ? JSON.stringify(source.channelMembers) : null, now, // sort_order: the fork lands at the top of its project, like any new session now, now ) for (const m of messages) { - insertMessage.run(randomUUID(), id, m.role, m.content, m.parts, m.created_at) + insertMessage.run(randomUUID(), id, m.role, m.content, m.parts, m.author, m.created_at) } })() @@ -1030,6 +1059,24 @@ export function setChatConfig(chatId: string, patch: SessionConfigPatch): Chat { return chat } +/** + * Replace a session's ATTACHED channel members (the host is never stored). + * + * Attaching or detaching a bot is a change to who is in the room, not agent + * activity, so this deliberately leaves `updated_at` alone - bumping it would + * float the session to the top of the sidebar just for opening the members + * panel, the same reason `setChatConfig` above skips it. + */ +export function setChannelMembers(chatId: string, members: BotMember[]): Chat { + const attached = members.filter((m) => m.id !== ROXY_HOST_ID) + getDb() + .prepare('UPDATE chats SET channel_members = ? WHERE id = ?') + .run(attached.length ? JSON.stringify(attached) : null, chatId) + const chat = getChat(chatId) + if (!chat) throw new Error('Chat not found') + return chat +} + /** Update agent-settable session metadata (any subset of name / description / tasks). */ export function setChatMetadata( chatId: string, @@ -1161,6 +1208,18 @@ function parseParts(raw: string | null, content: string): MessagePart[] { return [{ type: 'text', text: content }] } +/** Parse the author JSON column, tolerating malformed data. */ +function parseAuthor(raw: string | null): BotAuthor | undefined { + if (!raw) return undefined + try { + const a: unknown = JSON.parse(raw) + if (!a || typeof (a as BotAuthor).name !== 'string') return undefined + return a as BotAuthor + } catch { + return undefined + } +} + function rowToMessage(row: MessageRow): Message { return { id: row.id, @@ -1168,7 +1227,8 @@ function rowToMessage(row: MessageRow): Message { role: row.role as MessageRole, content: row.content, parts: parseParts(row.parts, row.content), - createdAt: row.created_at + createdAt: row.created_at, + author: parseAuthor(row.author) } } @@ -1184,17 +1244,18 @@ export function addMessage(input: AddMessageInput): Message { const now = Date.now() const parts: MessagePart[] = input.parts ?? [{ type: 'text', text: input.content }] const partsJson = JSON.stringify(parts) + const authorJson = input.author ? JSON.stringify(input.author) : null const db = getDb() const tx = db.transaction(() => { db.prepare( - 'INSERT INTO messages(id, chat_id, role, content, parts, created_at) VALUES(?, ?, ?, ?, ?, ?)' - ).run(id, input.chatId, input.role, input.content, partsJson, now) + 'INSERT INTO messages(id, chat_id, role, content, parts, author, created_at) VALUES(?, ?, ?, ?, ?, ?, ?)' + ).run(id, input.chatId, input.role, input.content, partsJson, authorJson, now) db.prepare('UPDATE chats SET updated_at = ? WHERE id = ?').run(now, input.chatId) - // One assistant message = one agent turn. Credited to the durable ledger in - // the SAME transaction as the message, so the graph can never disagree with - // what was actually persisted - and, unlike the message, the credit stays - // when the session is later deleted. Counts sub and loop sessions too, which - // is what the previous message-counting query did. + // One assistant message = one agent turn. Credited to the durable ledger + // in the SAME transaction as the message, so the graph can never disagree + // with what was actually persisted - and, unlike the message, the credit + // stays when the session is later deleted. Counts sub and loop sessions + // too, which is what the previous message-counting query did. if (input.role === 'assistant') recordActivityTurn(localDay(now)) }) tx() @@ -1204,7 +1265,8 @@ export function addMessage(input: AddMessageInput): Message { role: input.role, content: input.content, parts, - createdAt: now + createdAt: now, + author: input.author } } diff --git a/src/main/harness/agent.ts b/src/main/harness/agent.ts index 5fb2083..4ede6c2 100644 --- a/src/main/harness/agent.ts +++ b/src/main/harness/agent.ts @@ -456,7 +456,8 @@ function buildSystemMessage( chatId?: string, agent?: AgentDef, mcpInfo?: string, - skillInfo?: string + skillInfo?: string, + memberPrompt?: string ): string { const base = promptText[selectPromptName(model)] || promptText.default || FALLBACK_PROMPT const gitRoot = cwd ? findGitRoot(cwd) : undefined @@ -479,7 +480,13 @@ function buildSystemMessage( ...instructions, ...(skillInfo ? [skillInfo] : []), ...(mcpInfo ? [mcpInfo] : []), - ...(agentPrompt ? [agentPrompt] : []) + ...(agentPrompt ? [agentPrompt] : []), + // LAST, so the channel block (roster + the member's brief) is the most + // specific instruction in the prompt. It is APPENDED to Roxy's base rather + // than replacing it: a member is Roxy with a specialty, so it inherits the + // workspace, the tool rules, and the house style instead of starting as a + // blank model that has to be told who it is first. + ...(memberPrompt ? [memberPrompt] : []) ] const contextSummary = chatId ? (repo.getChat(chatId)?.contextSummary ?? undefined) : undefined return assembleSystemPrompt({ @@ -785,7 +792,7 @@ type ToolSchema = ReturnType /** The delegation tool — lets a primary agent spawn a focused subagent. */ const TASK_SCHEMA = fn( 'task', - 'Delegate a focused, self-contained sub-task to a specialized subagent that runs on its own and reports back. Use this to parallelize or offload work (e.g. research the codebase, build a page). The subagent has NO memory of this conversation, so put ALL the context it needs into `prompt`. It returns a single report. Call task multiple times IN ONE turn to batch independent work. CONCURRENCY: read-only "explore" subagents run in PARALLEL (bounded) - that is what subagents are for, and you should fan them out freely. Write-capable "general" subagents are SERIALIZED one at a time, because they share this session\'s working directory and would otherwise overwrite each other\'s edits; several of them in one turn is correct but no faster than doing the work yourself. To get genuinely parallel WRITES, the user should open separate sessions - each gets its own git worktree and therefore its own filesystem.', + 'Delegate a focused, self-contained sub-task to a specialized subagent that runs on its own and reports back. Use this to parallelize or offload work (e.g. research the codebase, build a page). The subagent has NO memory of this conversation, so put ALL the context it needs into `prompt`. It returns a single report. Call task multiple times IN ONE turn to batch independent work. CONCURRENCY: read-only "explore" subagents run in PARALLEL (bounded) - that is what subagents are for, and you should fan them out freely. Write-capable "general" subagents are SERIALIZED one at a time, because they share this session\'s working directory and would otherwise overwrite each other\'s edits; several of them in one turn is correct but no faster than doing the work yourself. To get genuinely parallel WRITES, the user should open separate sessions - each gets its own git worktree and therefore its own filesystem. This tool is NOT how you reach another bot in this channel: a subagent is a blank child of your own context, while a channel member is a peer with its own brief - to reach one, @mention them at the end of your reply.', { description: str('A short (3-5 word) label for the task.'), prompt: str('The complete task for the subagent, including every bit of context it needs.'), @@ -886,6 +893,12 @@ export interface RunTurnOptions { chatId?: string /** Which primary agent to run (e.g. "build" or "plan"). Defaults to build. */ agentId?: string + /** + * The channel block for the answering member - the roster plus that member's + * own brief - appended to the base system prompt. Absent in a solo channel, + * which is Roxy's base prompt unmodified. See shared/channel-members.ts. + */ + memberPrompt?: string signal: AbortSignal emit: (event: LlmEvent) => void /** Whether the model supports reasoning (gates the reasoning params). */ @@ -955,6 +968,7 @@ export async function runAgentTurn(opts: RunTurnOptions): Promise { cwd, chatId, agentId, + memberPrompt, signal, emit, reasoning, @@ -1027,7 +1041,8 @@ export async function runAgentTurn(opts: RunTurnOptions): Promise { chatId, agent, mcpInfo, - parentSkillInfo + parentSkillInfo, + memberPrompt ) const systemMessage: ChatMessage = { role: 'system', content: systemText } diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index 4116a02..047356a 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -26,6 +26,7 @@ import type { import type { AddMessageInput, ConnectProviderInput, + SetChannelMembersInput, QueueImage, ReasoningEffort } from '../../shared/types' @@ -344,6 +345,9 @@ export function registerIpc(): void { // ---- messages ---- ipcMain.handle(CHANNELS.messagesList, (_e, chatId: string) => repo.listMessages(chatId)) ipcMain.handle(CHANNELS.messagesAdd, (_e, input: AddMessageInput) => repo.addMessage(input)) + ipcMain.handle(CHANNELS.channelSetMembers, (_e, input: SetChannelMembersInput) => + repo.setChannelMembers(input.chatId, input.members) + ) // ---- integrations ---- ipcMain.handle(CHANNELS.integrationsList, () => repo.listIntegrations()) diff --git a/src/main/services/compaction.ts b/src/main/services/compaction.ts index 1d9a5b2..0e8947c 100644 --- a/src/main/services/compaction.ts +++ b/src/main/services/compaction.ts @@ -41,7 +41,9 @@ function flatten(m: Message): string { : `[tool:${p.tool}]` : p.type === 'image' ? '[image]' - : p.text + : p.type === 'text' || p.type === 'reasoning' + ? p.text + : '' ) .join('') .trim() diff --git a/src/main/services/session-turn.ts b/src/main/services/session-turn.ts index 79df555..221fa18 100644 --- a/src/main/services/session-turn.ts +++ b/src/main/services/session-turn.ts @@ -190,6 +190,7 @@ async function runTurn( model: input.model, messages: input.messages, agentId: input.agentId, + memberPrompt: input.memberPrompt, reasoning: input.reasoning, reasoningEffort: input.reasoningEffort, contextLimit: input.contextLimit, diff --git a/src/preload/index.ts b/src/preload/index.ts index c671b0b..80be09f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -65,6 +65,9 @@ const roxy: RoxyApi = { list: (chatId) => ipcRenderer.invoke(CHANNELS.messagesList, chatId), add: (input) => ipcRenderer.invoke(CHANNELS.messagesAdd, input) }, + channel: { + setMembers: (input) => ipcRenderer.invoke(CHANNELS.channelSetMembers, input) + }, integrations: { list: () => ipcRenderer.invoke(CHANNELS.integrationsList), setEnabled: (id, enabled) => ipcRenderer.invoke(CHANNELS.integrationsSetEnabled, id, enabled) diff --git a/src/renderer/src/components/BotAvatar.tsx b/src/renderer/src/components/BotAvatar.tsx new file mode 100644 index 0000000..f8de60d --- /dev/null +++ b/src/renderer/src/components/BotAvatar.tsx @@ -0,0 +1,105 @@ +import { Bot, Hammer, Scale, Search, ShieldCheck, TestTube } from 'lucide-react' +import type { BotAuthor, BotMember } from '@shared/types' +import { ROXY_HOST_ID } from '@shared/channel-members' +import roxy from '../assets/roxy.png' +import { cn } from '../lib/cn' + +/** + * Per-member accent, keyed by `BotMember.color`. + * + * A palette rather than free-form classes so a member added at runtime can only + * pick a color that actually reads against the surface — and so the avatar, the + * name in the transcript, and the `@mention` chip all tint from one place + * instead of three lists that drift apart. + */ +const ACCENTS = { + accent: { text: 'text-accent', chip: 'bg-accent/15 text-accent border-accent/30' }, + blue: { text: 'text-blue-400', chip: 'bg-blue-500/15 text-blue-300 border-blue-500/30' }, + purple: { + text: 'text-purple-400', + chip: 'bg-purple-500/15 text-purple-300 border-purple-500/30' + }, + emerald: { + text: 'text-emerald-400', + chip: 'bg-emerald-500/15 text-emerald-300 border-emerald-500/30' + }, + amber: { text: 'text-amber-400', chip: 'bg-amber-500/15 text-amber-300 border-amber-500/30' }, + cyan: { text: 'text-cyan-400', chip: 'bg-cyan-500/15 text-cyan-300 border-cyan-500/30' } +} as const + +export type AccentKey = keyof typeof ACCENTS + +export function accentOf(color?: string): (typeof ACCENTS)[AccentKey] { + return ACCENTS[(color ?? '') as AccentKey] ?? ACCENTS.accent +} + +/** Every icon a member can carry. `Bot` is the fallback for an unknown key. */ +const ICONS = { + builder: Hammer, + reviewer: Search, + security: ShieldCheck, + architect: Scale, + tester: TestTube +} as const + +const SIZES = { + sm: { box: 'h-6 w-6', glyph: 'h-3 w-3' }, + md: { box: 'h-7 w-7', glyph: 'h-4 w-4' }, + lg: { box: 'h-9 w-9', glyph: 'h-5 w-5' } +} as const + +/** + * A channel member's avatar. + * + * Takes either a live `BotMember` (the roster, the `@` menu) or the `BotAuthor` + * denormalized onto a message (the transcript), because a message's author may + * have been detached from the channel since it was written and must still + * render. No author at all means Roxy — every message that predates channels. + */ +export function BotAvatar({ + member, + author, + size = 'md', + className +}: { + member?: BotMember + author?: BotAuthor + size?: keyof typeof SIZES + className?: string +}): JSX.Element { + const id = member?.id + const name = member?.name ?? author?.name + const icon = member?.icon ?? author?.icon + const color = member?.color ?? author?.color + const { box, glyph } = SIZES[size] + + // The host wears the app's own face — it IS Roxy, not a bot standing in for her. + if (!name || id === ROXY_HOST_ID || icon === 'roxy') { + return ( + Roxy + ) + } + + const Icon = ICONS[(icon ?? '') as keyof typeof ICONS] ?? Bot + return ( +
+ +
+ ) +} diff --git a/src/renderer/src/components/ChannelMembersPanel.tsx b/src/renderer/src/components/ChannelMembersPanel.tsx new file mode 100644 index 0000000..36b6dc5 --- /dev/null +++ b/src/renderer/src/components/ChannelMembersPanel.tsx @@ -0,0 +1,254 @@ +import { useMemo, useState } from 'react' +import { Check, Plus, Trash2, UserPlus, X } from 'lucide-react' +import type { BotMember } from '@shared/types' +import { ROXY_HOST_ID, SUGGESTED_MEMBERS } from '@shared/channel-members' +import { BotAvatar, accentOf } from './BotAvatar' +import { Button, Input, Textarea } from './ui' +import { cn } from '../lib/cn' + +/** Icon/accent pairs a custom member can pick from (mirrors BotAvatar's maps). */ +const LOOKS = [ + { icon: 'builder', color: 'blue', label: 'Build' }, + { icon: 'reviewer', color: 'purple', label: 'Review' }, + { icon: 'security', color: 'emerald', label: 'Secure' }, + { icon: 'architect', color: 'amber', label: 'Design' }, + { icon: 'tester', color: 'cyan', label: 'Test' } +] as const + +/** + * The channel roster: who is in this session, and the controls to change it. + * + * Attaching is a one-click pick from a suggested list (or a custom bot), and + * detaching is available on every member except the host — the point being that + * membership is edited DURING the conversation, the way you add someone to a + * group chat, rather than declared up front when the session is created. + */ +export function ChannelMembersPanel({ + members, + onChange, + onMention, + onClose +}: { + /** Full membership, host first. */ + members: BotMember[] + /** Persist a new membership list (host included; it's filtered on the way in). */ + onChange: (members: BotMember[]) => void + /** Prefill the composer with `@Name`. */ + onMention: (name: string) => void + onClose: () => void +}): JSX.Element { + const [adding, setAdding] = useState(false) + const [custom, setCustom] = useState<{ + name: string + role: string + prompt: string + look: number + }>({ name: '', role: '', prompt: '', look: 0 }) + + const present = useMemo(() => new Set(members.map((m) => m.id)), [members]) + const available = SUGGESTED_MEMBERS.filter((m) => !present.has(m.id)) + + const attach = (member: BotMember): void => { + onChange([...members, member]) + setAdding(false) + } + + const detach = (id: string): void => onChange(members.filter((m) => m.id !== id)) + + const addCustom = (): void => { + const name = custom.name.trim() + if (!name) return + // Slug from the name, so `@Name` addressing and the id agree. Suffixed on + // collision rather than rejected — two bots called "QA" is the user's call, + // but two bots with one id would make the roster ambiguous. + let id = + name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') || 'bot' + if (id === ROXY_HOST_ID || present.has(id)) id = `${id}-${members.length}` + const look = LOOKS[custom.look] + attach({ + id, + name, + role: custom.role.trim() || 'Specialist', + icon: look.icon, + color: look.color, + systemPrompt: custom.prompt.trim() || undefined + }) + setCustom({ name: '', role: '', prompt: '', look: 0 }) + } + + return ( +