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/ROXY-PLUS.md b/ROXY-PLUS.md
new file mode 100644
index 0000000..414aa95
--- /dev/null
+++ b/ROXY-PLUS.md
@@ -0,0 +1,90 @@
+# Roxy+ - channels
+
+Fork de Roxy con **canales**: varios bots en un mismo chat, que se pasan el
+turno entre ellos. Guardado aparte del repo de Desktop, que sigue limpio en
+`main`.
+
+- Rama: `roxy-plus/channels`
+- Base: `cd9f244` (main de FreddyJD/roxy cuando se hizo el trabajo)
+- Todo el trabajo esta en el commit `feat(channels): bots that talk to each other in one chat`
+
+## Arrancarlo
+
+```powershell
+cd "$env:USERPROFILE\Documents\Roxy+"
+npm install # node_modules NO se copio (888 MB)
+npm run dev
+```
+
+`npm run typecheck` y `npm run smoke:shared` (1020 checks) pasan.
+
+## Que hace
+
+Una sesion es un **canal**, no un chat uno-a-uno. Roxy es el host y siempre
+esta; los especialistas se agregan al lado, estilo WhatsApp. Se les habla con
+`@Nombre`, y un mensaje sin mencion va a Roxy - asi el canal nunca queda sin
+nadie escuchando.
+
+Un miembro es **Roxy con una especialidad**: su brief se _anade_ al prompt base,
+no lo reemplaza, asi hereda el workspace, las reglas de tools, las skills y el
+estilo de la casa en vez de arrancar como un modelo en blanco.
+
+## Los cuatro bugs que costaron encontrar
+
+Todos eran la misma raiz vista de distintos angulos: **el modelo no sabia que
+el canal existia.**
+
+1. **El roster no entraba al prompt.** Solo se inyectaba el brief del miembro
+ que hablaba. Al pedirle "llama a @Bobo", Roxy buscaba un usuario de GitHub
+ llamado Bobo, no lo encontraba, y caia al tool `task` - que es la forma
+ equivocada: un subagente es un hijo en blanco de _su propio_ contexto, que
+ hereda sus errores y reporta solo de vuelta a el, y nunca aparece en el canal
+ como par. Lo arregla `channelPrompt()` en `src/shared/channel-members.ts`.
+
+2. **El enrutado le daba el trabajo entero al mencionado.** Se tomaba la
+ _primera_ mencion en cualquier posicion, asi que "crea el comando, crea el PR
+ y **despues** llama a @Bobo" se enrutaba completo a Bobo. Ahora solo cuenta
+ como dirigido si el mensaje **abre** con la mencion; una mencion a mitad de
+ frase es hablar _sobre_ alguien, y el turno se queda con el host, que hace el
+ trabajo y recien entonces pasa el turno.
+
+3. **El brief se leia como la tarea.** Pegado crudo, un bot con "revisas PRs y
+ dejas un roadmap" se ponia a clonar y diffear al recibir un "hola". Ahora el
+ brief va enmarcado como _standing identity_, y lo que decide que hacer es el
+ ultimo mensaje del canal.
+
+4. **Gemini rechazaba el hand-off con 400.** En un relevo nadie escribe un
+ mensaje nuevo, asi que el transcript _terminaba_ en un turno de assistant:
+ `Requests ending with a model turn are not supported`. Ya existia un `while`
+ que normalizaba el **inicio** de la ventana, pero nada el **final**. Ahora el
+ hand-off se replantea como linea de rol `user`, atribuida (`[Roxy]: @bobo el
+PR esta listo`), lo que ademas arregla un fallo silencioso en otros
+ providers, que lo interpretaban como "segui escribiendo esa respuesta" en vez
+ de "responde a esto".
+
+## Los dos campos del panel (la confusion que quedo documentada)
+
+- **One-line role** -> `role`. Etiqueta corta. Se muestra junto al nombre y en
+ el roster que ven los otros bots. _Tambien va al prompt_, en la linea de
+ identidad - por eso poner el brief aca "medio funciona" y nada te avisa.
+- **Its full instructions** -> `systemPrompt`. El brief completo, al final del
+ prompt como la instruccion mas especifica.
+
+El textarea ahora se ilumina cuando hay nombre pero no instrucciones.
+
+## Donde mirar
+
+| Que | Donde |
+| --------------------------------------------------- | -------------------------------------------------------------------- |
+| Roster, enrutado, hand-off, aislamiento de contexto | `src/shared/channel-members.ts` |
+| Panel de miembros | `src/renderer/src/components/ChannelMembersPanel.tsx` |
+| Avatares/acentos | `src/renderer/src/components/BotAvatar.tsx` |
+| Relevo y armado de la ventana | `src/renderer/src/lib/store.ts` (`sendMessage`, `buildChatMessages`) |
+| Inyeccion del bloque de canal | `src/main/harness/agent.ts` (`memberPrompt`) |
+| Tests | `test/shared.ts` (buscar `channel:`) |
+
+## Pendiente
+
+- Las skills son por _agente_ y workspace, no por miembro: todos los bots ven
+ las mismas que Roxy. Un allowlist por miembro no existe todavia.
+- `MAX_HANDOFF_HOPS` acota la cadena de relevos; cada hop es un turno completo.
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/script/.es.mjs b/script/.es.mjs
new file mode 100644
index 0000000..3aa8664
--- /dev/null
+++ b/script/.es.mjs
@@ -0,0 +1,48 @@
+import { readFileSync, writeFileSync } from 'node:fs'
+
+const path = 'src/renderer/src/locales/es.json'
+const raw = readFileSync(path, 'utf8')
+const crlf = raw.includes('\r\n')
+const json = JSON.parse(raw)
+
+json.bots = {
+ attach: 'Añadir',
+ chatEmptyBody:
+ 'Aquí solo responde {{name}} — úsalo para enseñarle y para preguntarle directamente. En un proyecto, llega al mismo bot con @{{name}}.',
+ chatEmptyNoBrief:
+ '{{name}} todavía no tiene instrucciones, así que responderá como Roxy a secas. Edita el bot para decirle cómo trabajar.',
+ chatEmptyTitle: 'Este es tu chat con {{name}}',
+ chatPlaceholder: 'Escríbele a {{name}}…',
+ create: 'Crear bot',
+ delete: 'Eliminar bot',
+ descriptionHint:
+ 'Una línea sobre para qué sirve. La ves tú y también los demás bots con los que trabaja.',
+ descriptionLabel: 'Descripción',
+ descriptionPlaceholder: 'p. ej. Revisa los diffs antes de publicarlos',
+ edit: 'Editar bot',
+ editTitle: 'Editar {{name}}',
+ instructionsHint:
+ 'Cómo debe trabajar: qué hacer siempre, qué no hacer nunca, cómo se ve un buen resultado. Esto es lo que lo diferencia de Roxy.',
+ instructionsLabel: 'Instrucciones',
+ instructionsPlaceholder:
+ 'Revisas cambios buscando errores y riesgos.\n\nLee el diff completo antes de comentar. Señala pruebas que faltan y errores sin manejar. Sé específico: nombra el archivo y la línea. No reescribas el código tú mismo.',
+ lookLabel: 'Apariencia',
+ nameHint: 'Así lo llamarás en un proyecto: @Nombre.',
+ nameLabel: 'Nombre',
+ namePlaceholder: 'p. ej. Revisor',
+ new: 'Nuevo bot',
+ newFooter: 'Puedes cambiar todo esto más adelante.',
+ newShort: 'Nuevo',
+ newTitle: 'Nuevo bot',
+ openChat: 'Abrir chat',
+ save: 'Guardar',
+ savedGroup: 'Tus bots',
+ subtitle:
+ 'Un especialista con el que puedes chatear aparte y mencionar con @ en cualquier proyecto.'
+}
+
+// Re-sort so the catalog keeps the alphabetical shape the sync script writes.
+const sorted = Object.fromEntries(Object.entries(json).sort(([a], [b]) => a.localeCompare(b)))
+const out = JSON.stringify(sorted, null, 2) + '\n'
+writeFileSync(path, crlf ? out.replace(/\n/g, '\r\n') : out)
+console.log('es.json bots translated')
diff --git a/script/.p.json b/script/.p.json
new file mode 100644
index 0000000..6263e67
--- /dev/null
+++ b/script/.p.json
@@ -0,0 +1,6 @@
+[
+ {
+ "find": " 'no-scrollbar flex gap-2',\n railed ? 'flex-col items-center' : 'items-start overflow-x-auto pb-0.5'",
+ "replace": " 'flex gap-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden',\n railed ? 'flex-col items-center' : 'items-start overflow-x-auto pb-0.5'"
+ }
+]
diff --git a/script/.p2.json b/script/.p2.json
new file mode 100644
index 0000000..ff62d6d
--- /dev/null
+++ b/script/.p2.json
@@ -0,0 +1,6 @@
+[
+ {
+ "find": "
",
+ "replace": "
"
+ }
+]
diff --git a/script/.patch.mjs b/script/.patch.mjs
new file mode 100644
index 0000000..16d1a16
--- /dev/null
+++ b/script/.patch.mjs
@@ -0,0 +1,27 @@
+/**
+ * CRLF-safe single-replacement patcher: node script/.patch.mjs
+ * patch.json = [{ "find": "...", "replace": "..." }, ...] (LF in the JSON).
+ */
+import { readFileSync, writeFileSync } from 'node:fs'
+
+const [file, patchFile] = process.argv.slice(2)
+const raw = readFileSync(file, 'utf8')
+const crlf = raw.includes('\r\n')
+let src = raw.replace(/\r\n/g, '\n')
+const patches = JSON.parse(readFileSync(patchFile, 'utf8'))
+
+for (const { find, replace } of patches) {
+ const first = src.indexOf(find)
+ if (first === -1) {
+ console.error(`NOT FOUND in ${file}:\n${find.slice(0, 200)}`)
+ process.exit(1)
+ }
+ if (src.indexOf(find, first + 1) !== -1) {
+ console.error(`NOT UNIQUE in ${file}:\n${find.slice(0, 200)}`)
+ process.exit(1)
+ }
+ src = src.slice(0, first) + replace + src.slice(first + find.length)
+}
+
+writeFileSync(file, crlf ? src.replace(/\n/g, '\r\n') : src)
+console.log(`patched ${file} (${patches.length})`)
diff --git a/src/main/db/migrations.ts b/src/main/db/migrations.ts
index 401482d..8a986c6 100644
--- a/src/main/db/migrations.ts
+++ b/src/main/db/migrations.ts
@@ -493,6 +493,47 @@ 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')
+ },
+
+ // ---- v25: saved bots (a bot library, each with its own chat) ----
+ // Until now a bot existed only INSIDE the session it was attached to: its
+ // brief lived in that row's `channel_members` JSON, so the same specialist
+ // had to be retyped per project and could never be talked to on its own.
+ //
+ // This table makes a bot a first-class thing the user owns. `chat_id` is its
+ // private one-on-one conversation (a `bot`-kind session), created with the bot
+ // and cascaded away with it, which is where the bot is actually taught. The
+ // channel path is unchanged - attaching one still copies its identity into
+ // `chats.channel_members` - so every existing session keeps working and a bot
+ // detached from the library does not silently vanish from a live transcript.
+ /* sql */ `
+ CREATE TABLE IF NOT EXISTS bots (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ description TEXT NOT NULL DEFAULT '',
+ icon TEXT NOT NULL DEFAULT 'builder',
+ color TEXT NOT NULL DEFAULT 'blue',
+ instructions TEXT NOT NULL DEFAULT '',
+ chat_id TEXT NOT NULL REFERENCES chats(id) ON DELETE CASCADE,
+ sort_order INTEGER NOT NULL DEFAULT 0,
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL
+ );
`
]
@@ -527,6 +568,24 @@ 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')
+ // v25's saved-bot library.
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS bots (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ description TEXT NOT NULL DEFAULT '',
+ icon TEXT NOT NULL DEFAULT 'builder',
+ color TEXT NOT NULL DEFAULT 'blue',
+ instructions TEXT NOT NULL DEFAULT '',
+ chat_id TEXT NOT NULL REFERENCES chats(id) ON DELETE CASCADE,
+ sort_order INTEGER NOT NULL DEFAULT 0,
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL
+ );
+ `)
// 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..16c4573 100644
--- a/src/main/db/repo.ts
+++ b/src/main/db/repo.ts
@@ -4,10 +4,15 @@ 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 { BOT_LOOKS, ROXY_HOST_ID, botId, botMember } from '../../shared/channel-members'
import type {
AddMessageInput,
AppSettings,
+ Bot,
+ BotAuthor,
+ BotMember,
Chat,
+ CreateBotInput,
ConnectedProvider,
ConnectProviderInput,
IntegrationConnection,
@@ -24,6 +29,7 @@ import type {
SessionStatus,
SessionTask,
TokenUsage,
+ UpdateBotInput,
UsageRecord,
WorktreeIntent
} from '../../shared/types'
@@ -73,6 +79,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 +91,7 @@ interface MessageRow {
role: string
content: string
parts: string | null
+ author: string | null
created_at: number
}
@@ -595,6 +603,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 +646,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 +901,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 +923,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 +1062,204 @@ 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
+}
+
+// ---- Saved bots --------------------------------------------------------------
+
+interface BotRow {
+ id: string
+ name: string
+ description: string
+ icon: string
+ color: string
+ instructions: string
+ chat_id: string
+ sort_order: number
+ created_at: number
+ updated_at: number
+}
+
+function rowToBot(row: BotRow): Bot {
+ return {
+ id: row.id,
+ name: row.name,
+ description: row.description,
+ icon: row.icon,
+ color: row.color,
+ instructions: row.instructions,
+ chatId: row.chat_id,
+ sortOrder: row.sort_order,
+ createdAt: row.created_at,
+ updatedAt: row.updated_at
+ }
+}
+
+/** Every saved bot, in carousel order (newest first). */
+export function listBots(): Bot[] {
+ const rows = getDb()
+ .prepare('SELECT * FROM bots ORDER BY sort_order DESC, created_at DESC')
+ .all() as BotRow[]
+ return rows.map(rowToBot)
+}
+
+export function getBot(id: string): Bot | undefined {
+ const row = getDb().prepare('SELECT * FROM bots WHERE id = ?').get(id) as BotRow | undefined
+ return row ? rowToBot(row) : undefined
+}
+
+/** The bot that owns a chat, when that chat is a bot's own conversation. */
+export function getBotByChat(chatId: string): Bot | undefined {
+ const row = getDb().prepare('SELECT * FROM bots WHERE chat_id = ?').get(chatId) as
+ | BotRow
+ | undefined
+ return row ? rowToBot(row) : undefined
+}
+
+/**
+ * Create a bot AND the chat it lives in, in one transaction.
+ *
+ * The chat is not optional and not lazy: a bot with no conversation is a
+ * preset, and the whole point of saving one is that you can open it and talk to
+ * it. Creating them together means the carousel can never hold a bot whose
+ * avatar opens nothing.
+ *
+ * The bot is ATTACHED to its own chat as a channel member, which is what makes
+ * it the one answering there - the turn path resolves the speaker from the
+ * session's membership, so a bot chat with an empty roster would be Roxy
+ * wearing the bot's name in the header. It carries no workspace: this chat is
+ * for teaching and asking, and a bot needs a project channel to touch files.
+ */
+export function createBot(input: CreateBotInput): Bot {
+ const db = getDb()
+ const now = Date.now()
+ const name = input.name.trim() || 'New bot'
+ const taken = (db.prepare('SELECT id FROM bots').all() as { id: string }[]).map((r) => r.id)
+ const id = botId(name, taken)
+ const bot: Bot = {
+ id,
+ name,
+ description: input.description?.trim() ?? '',
+ icon: input.icon ?? BOT_LOOKS[0].icon,
+ color: input.color ?? BOT_LOOKS[0].color,
+ instructions: input.instructions?.trim() ?? '',
+ chatId: '',
+ sortOrder: now,
+ createdAt: now,
+ updatedAt: now
+ }
+ const chat = createChat({ title: name, kind: 'bot' })
+ const write = db.transaction(() => {
+ db.prepare(
+ `INSERT INTO bots(id, name, description, icon, color, instructions, chat_id, sort_order, created_at, updated_at)
+ VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
+ ).run(
+ bot.id,
+ bot.name,
+ bot.description,
+ bot.icon,
+ bot.color,
+ bot.instructions,
+ chat.id,
+ bot.sortOrder,
+ now,
+ now
+ )
+ db.prepare('UPDATE chats SET channel_members = ? WHERE id = ?').run(
+ JSON.stringify([botMember({ ...bot, chatId: chat.id })]),
+ chat.id
+ )
+ })
+ write()
+ const created = getBot(id)
+ if (!created) throw new Error('Failed to create bot')
+ return created
+}
+
+/**
+ * Edit a bot, and re-stamp its identity onto the chat it owns.
+ *
+ * The membership rewrite is the part that is easy to forget and impossible to
+ * notice: a channel stores a COPY of the member, so renaming a bot without
+ * updating its own chat leaves that chat addressing `@OldName` with the old
+ * brief, in the one place the user went specifically to change it.
+ *
+ * PROJECT channels are deliberately left alone. A session's roster is a record
+ * of who was in that room, and rewriting every transcript's membership from
+ * here would retroactively rename bots in conversations that already happened.
+ */
+export function updateBot(id: string, patch: UpdateBotInput): Bot {
+ const current = getBot(id)
+ if (!current) throw new Error('Bot not found')
+ const next: Bot = {
+ ...current,
+ name: patch.name?.trim() || current.name,
+ description: patch.description?.trim() ?? current.description,
+ icon: patch.icon ?? current.icon,
+ color: patch.color ?? current.color,
+ instructions: patch.instructions?.trim() ?? current.instructions,
+ updatedAt: Date.now()
+ }
+ const db = getDb()
+ const write = db.transaction(() => {
+ db.prepare(
+ 'UPDATE bots SET name = ?, description = ?, icon = ?, color = ?, instructions = ?, updated_at = ? WHERE id = ?'
+ ).run(next.name, next.description, next.icon, next.color, next.instructions, next.updatedAt, id)
+ db.prepare('UPDATE chats SET title = ?, channel_members = ? WHERE id = ?').run(
+ next.name,
+ JSON.stringify([botMember(next)]),
+ next.chatId
+ )
+ })
+ write()
+ return next
+}
+
+/**
+ * Delete a bot and its conversation.
+ *
+ * The chat goes with it: it belongs to the bot and nothing else can reach it
+ * once the carousel entry is gone, so leaving it behind would strand a session
+ * in the database with no way in. Sessions the bot was ATTACHED to keep their
+ * copy of it, so their transcripts still say who spoke.
+ */
+export function deleteBot(id: string): void {
+ const bot = getBot(id)
+ if (!bot) return
+ const db = getDb()
+ const write = db.transaction(() => {
+ db.prepare('DELETE FROM bots WHERE id = ?').run(id)
+ db.prepare('DELETE FROM chats WHERE id = ?').run(bot.chatId)
+ })
+ write()
+}
+
+/** Persist the carousel order; `ids` is the full list, first to last. */
+export function reorderBots(ids: string[]): void {
+ const db = getDb()
+ const stmt = db.prepare('UPDATE bots SET sort_order = ? WHERE id = ?')
+ const write = db.transaction(() => {
+ // Descending, matching `listBots` — first in the list gets the highest key.
+ ids.forEach((id, i) => stmt.run(ids.length - i, id))
+ })
+ write()
+}
+
/** Update agent-settable session metadata (any subset of name / description / tasks). */
export function setChatMetadata(
chatId: string,
@@ -1161,6 +1391,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 +1410,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 +1427,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 +1448,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..c55f048 100644
--- a/src/main/ipc/index.ts
+++ b/src/main/ipc/index.ts
@@ -26,8 +26,11 @@ import type {
import type {
AddMessageInput,
ConnectProviderInput,
+ CreateBotInput,
+ SetChannelMembersInput,
QueueImage,
- ReasoningEffort
+ ReasoningEffort,
+ UpdateBotInput
} from '../../shared/types'
import * as repo from '../db/repo'
import * as copilot from '../services/copilot'
@@ -344,6 +347,31 @@ 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)
+ )
+
+ // ---- saved bots (the bot library) ----
+ ipcMain.handle(CHANNELS.botsList, () => repo.listBots())
+ ipcMain.handle(CHANNELS.botsCreate, (_e, input: CreateBotInput) => repo.createBot(input))
+ ipcMain.handle(CHANNELS.botsUpdate, (_e, id: string, patch: UpdateBotInput) =>
+ repo.updateBot(id, patch)
+ )
+ ipcMain.handle(CHANNELS.botsRemove, (_e, id: string) => {
+ // A bot's chat is a real session, so it gets the same teardown any deleted
+ // session does before the row goes. It owns no worktree (a bot chat has no
+ // workspace) but it CAN have opened a browser or a background process from
+ // a tool call, and those outlive the row unless they are stopped here.
+ const bot = repo.getBot(id)
+ if (bot) {
+ cancelSessionBackgroundJobs(bot.chatId)
+ endSubagentRuns(bot.chatId)
+ killSessionBackground(bot.chatId)
+ browser.disposeSession(bot.chatId)
+ }
+ return repo.deleteBot(id)
+ })
+ ipcMain.handle(CHANNELS.botsReorder, (_e, ids: string[]) => repo.reorderBots(ids))
// ---- 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..0b2377b 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -65,6 +65,16 @@ 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)
+ },
+ bots: {
+ list: () => ipcRenderer.invoke(CHANNELS.botsList),
+ create: (input) => ipcRenderer.invoke(CHANNELS.botsCreate, input),
+ update: (id, patch) => ipcRenderer.invoke(CHANNELS.botsUpdate, id, patch),
+ remove: (id) => ipcRenderer.invoke(CHANNELS.botsRemove, id),
+ reorder: (ids) => ipcRenderer.invoke(CHANNELS.botsReorder, ids)
+ },
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 (
+
+ )
+ }
+
+ const Icon = ICONS[(icon ?? '') as keyof typeof ICONS] ?? Bot
+ return (
+
+
+
+ )
+}
diff --git a/src/renderer/src/components/BotCarousel.tsx b/src/renderer/src/components/BotCarousel.tsx
new file mode 100644
index 0000000..4513b8f
--- /dev/null
+++ b/src/renderer/src/components/BotCarousel.tsx
@@ -0,0 +1,152 @@
+import { useTranslation } from 'react-i18next'
+import { Plus } from 'lucide-react'
+import type { Bot } from '@shared/types'
+import { BotAvatar } from './BotAvatar'
+import { cn } from '../lib/cn'
+
+/**
+ * The saved bots, as a row of faces above the session list — Instagram-stories
+ * shaped, and for the same reason: a roster is browsed by recognition, not read
+ * as a list.
+ *
+ * It sits between the two buttons and the sessions on purpose. Bots are not
+ * sessions, so listing them among the chats would make them look like more
+ * conversations to scroll past; and a bot you cannot see in one glance is a bot
+ * you forget you made. One tap opens its own chat, so the strip is also the
+ * only navigation a bot needs.
+ *
+ * Squircles, not circles, even though the reference is round: `BotAvatar` is
+ * masked to the app's superellipse, so a `rounded-full` wrapper would draw a
+ * circle around a visibly non-circular face and the mismatch shows at the
+ * corners. The ring follows the avatar's shape one size up instead.
+ *
+ * Empty means empty: with no bots this renders nothing at all rather than a
+ * placeholder rail. The "New bot" button above is already the empty state (see
+ * the diagram), and an empty strip would just be a second, quieter one.
+ */
+export function BotCarousel({
+ bots,
+ activeChatId,
+ busyChatIds,
+ onOpen,
+ onEdit,
+ onNew,
+ railed
+}: {
+ bots: Bot[]
+ activeChatId: string | null
+ /** Bot chats with a turn in flight — pulses the ring, as stories do for unseen. */
+ busyChatIds: Set
+ onOpen: (bot: Bot) => void
+ onEdit: (bot: Bot) => void
+ onNew: () => void
+ /** Collapsed sidebar: stack vertically instead of scrolling sideways. */
+ railed?: boolean
+}): JSX.Element | null {
+ const { t } = useTranslation()
+
+ if (bots.length === 0) return null
+
+ return (
+
+
{
+ if (railed || Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return
+ e.currentTarget.scrollLeft += e.deltaX
+ e.preventDefault()
+ }}
+ className={cn(
+ 'flex gap-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
+ railed ? 'flex-col items-center' : 'items-start overflow-x-auto pb-0.5'
+ )}
+ >
+ {bots.map((bot) => {
+ const active = bot.chatId === activeChatId
+ const busy = busyChatIds.has(bot.chatId)
+ return (
+
+ )
+ })}
+
+ {/* The trailing "+" from the diagram — once bots exist, adding another
+ is a move within the roster, so it lives at the end of the row. */}
+
+
+
+ )
+}
diff --git a/src/renderer/src/components/BotDialog.tsx b/src/renderer/src/components/BotDialog.tsx
new file mode 100644
index 0000000..9ffa135
--- /dev/null
+++ b/src/renderer/src/components/BotDialog.tsx
@@ -0,0 +1,233 @@
+import { useEffect, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { Bot as BotIcon, Trash2, X } from 'lucide-react'
+import type { Bot, CreateBotInput } from '@shared/types'
+import { BOT_LOOKS } from '@shared/channel-members'
+import { BotAvatar, accentOf } from './BotAvatar'
+import { Button, Input, Textarea } from './ui'
+import { cn } from '../lib/cn'
+
+/**
+ * The form you get right after clicking "New bot" — and the one you get back
+ * when you edit it.
+ *
+ * It is deliberately a blocking step rather than a bot that appears fully
+ * formed with a default brief. The three fields ARE the bot: what to call it,
+ * what it is for, and how it should work. Filling them in is the moment the
+ * user learns that a bot is something they define, not a preset they pick — so
+ * skipping straight to a chat with "New Bot" would teach exactly the wrong
+ * model of the feature (and produce a roster of identical bots).
+ *
+ * The same component covers create and edit, because the thing being described
+ * does not change between the two, and having two dialogs is how the wording of
+ * the two fields drifts apart.
+ */
+export function BotDialog({
+ bot,
+ onSave,
+ onDelete,
+ onClose
+}: {
+ /** The bot being edited, or undefined to create a new one. */
+ bot?: Bot
+ onSave: (input: CreateBotInput) => Promise
+ /** Offered only when editing. */
+ onDelete?: () => Promise
+ onClose: () => void
+}): JSX.Element {
+ const { t } = useTranslation()
+ const [name, setName] = useState(bot?.name ?? '')
+ const [description, setDescription] = useState(bot?.description ?? '')
+ const [instructions, setInstructions] = useState(bot?.instructions ?? '')
+ const [look, setLook] = useState(() => {
+ const i = BOT_LOOKS.findIndex((l) => l.icon === bot?.icon)
+ return i === -1 ? 0 : i
+ })
+ const [saving, setSaving] = useState(false)
+
+ useEffect(() => {
+ const onKey = (e: KeyboardEvent): void => {
+ if (e.key === 'Escape') onClose()
+ }
+ window.addEventListener('keydown', onKey)
+ return () => window.removeEventListener('keydown', onKey)
+ }, [onClose])
+
+ const canSave = !!name.trim() && !saving
+
+ const save = async (): Promise => {
+ if (!canSave) return
+ setSaving(true)
+ try {
+ await onSave({
+ name: name.trim(),
+ description: description.trim(),
+ instructions: instructions.trim(),
+ icon: BOT_LOOKS[look].icon,
+ color: BOT_LOOKS[look].color
+ })
+ onClose()
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const remove = async (): Promise => {
+ if (!onDelete) return
+ setSaving(true)
+ try {
+ await onDelete()
+ onClose()
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ return (
+
+
e.stopPropagation()}
+ >
+
+ {/* The live avatar, not a generic dialog icon: the picked face is part
+ of what is being authored, so it belongs where the title is. */}
+
+
+
+ {/* The one field that actually decides how the bot behaves, and the
+ one people put a one-liner in. Highlighted while empty once a name
+ is in - the mistake is silent otherwise, because a bot with no
+ brief still answers (as plain Roxy) and nothing says why. */}
+
+
+
+ )
+}
+
+function Field({
+ label,
+ hint,
+ children
+}: {
+ label: string
+ hint?: string
+ children: React.ReactNode
+}): JSX.Element {
+ return (
+
+ )
+}
diff --git a/src/renderer/src/components/ChannelMembersPanel.tsx b/src/renderer/src/components/ChannelMembersPanel.tsx
new file mode 100644
index 0000000..f54b50c
--- /dev/null
+++ b/src/renderer/src/components/ChannelMembersPanel.tsx
@@ -0,0 +1,293 @@
+import { useMemo, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { Check, Plus, Trash2, UserPlus, X } from 'lucide-react'
+import type { BotMember } from '@shared/types'
+import { ROXY_HOST_ID, SUGGESTED_MEMBERS, botMember } from '@shared/channel-members'
+import { useRoxyStore } from '../lib/store'
+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 { t } = useTranslation()
+ // The user's SAVED bots, offered before the presets.
+ //
+ // This is what makes the dedicated chats complement `@` rather than compete
+ // with it: attaching from here copies the identity of the very bot you have
+ // been training in its own chat (same id, so `@Name` resolves to it), instead
+ // of a same-named copy whose brief you would then have to retype and keep in
+ // sync by hand.
+ const savedBots = useRoxyStore((s) => s.bots)
+ 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 availableSaved = useMemo(
+ () => savedBots.filter((b) => !present.has(b.id)).map(botMember),
+ [savedBots, present]
+ )
+
+ 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 (
+
+ )
+}
diff --git a/src/renderer/src/components/ChatView.tsx b/src/renderer/src/components/ChatView.tsx
index 1085b36..9e98f54 100644
--- a/src/renderer/src/components/ChatView.tsx
+++ b/src/renderer/src/components/ChatView.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
+import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import {
Check,
ChevronRight,
@@ -10,7 +10,8 @@ import {
Repeat,
RotateCw,
Settings,
- Square
+ Square,
+ Users
} from 'lucide-react'
import type { Chat } from '@shared/types'
import { useRoxyStore } from '../lib/store'
@@ -19,7 +20,11 @@ import { formatInterval } from '@shared/format'
import { cn } from '../lib/cn'
import { MessageBubble } from './MessageBubble'
import { Composer } from './Composer'
+import { ChannelMembersPanel } from './ChannelMembersPanel'
+import { withHost } from '@shared/channel-members'
import { LoopDetailsPane } from './LoopDetailsPane'
+import { BotAvatar } from './BotAvatar'
+import { BotDialog } from './BotDialog'
import { SessionInfo } from './SessionInfo'
import { WorkstreamStrip } from './WorkstreamStrip'
import { QueuedMessage } from './QueuedMessage'
@@ -44,7 +49,7 @@ import roxy from '../assets/roxy.png'
* output, and the whole transcript is markdown re-parsed by Streamdown on every
* render. A few hundred of those is a visibly janky pane.
*
- * 8 was too aggressive though — it is fewer turns than fit on a 1440p screen, so
+ * 8 was too aggressive though — it is fewer turns than fit on a 1440p screen, so
* an ordinary session showed the "showing the last 8 of N" notice while its own
* content did not even fill the viewport, and any scroll up immediately paged.
* 30 still bounds the worst case while covering essentially every session you
@@ -70,6 +75,9 @@ export function ChatView(): JSX.Element {
const activeChatId = useRoxyStore((s) => s.activeChatId)
const chats = useRoxyStore((s) => s.chats)
const loops = useRoxyStore((s) => s.loops)
+ const bots = useRoxyStore((s) => s.bots)
+ const updateBot = useRoxyStore((s) => s.updateBot)
+ const removeBot = useRoxyStore((s) => s.removeBot)
// Subscribe to the STORED array, not a defaulted copy. A selector returning
// `?? []` builds a new array every call, so zustand's Object.is check never
// matches and the component re-renders forever ("getSnapshot should be
@@ -80,15 +88,28 @@ export function ChatView(): JSX.Element {
)
const backgroundTaskCount = runningTasks?.length ?? 0
// A subagent working in ITS OWN session. Tracked separately from `sending`
- // (which is per-chat local-send state): nobody "sent" this turn from the UI —
- // the parent agent delegated it — so the only signal is the live run itself.
+ // (which is per-chat local-send state): nobody "sent" this turn from the UI —
+ // the parent agent delegated it — so the only signal is the live run itself.
const subagentRunning = useRoxyStore((s) =>
s.activeChatId ? !!s.runningSubagents[s.activeChatId] : false
)
const cancelSubagent = useRoxyStore((s) => s.cancelSubagent)
const cancelBackgroundTask = useRoxyStore((s) => s.cancelBackgroundTask)
+ const setChannelMembers = useRoxyStore((s) => s.setChannelMembers)
+ const activeChannelMembers = useRoxyStore((s) =>
+ s.activeChatId ? s.chats.find((c) => c.id === s.activeChatId)?.channelMembers : undefined
+ )
+ // Who is mid-reply, so the live bubble is attributed to the member actually
+ // speaking rather than always to the host.
+ const streamingAuthor = useRoxyStore((s) =>
+ s.activeChatId ? s.speakingChats[s.activeChatId] : undefined
+ )
const hasContent = messages.length > 0 || (streaming !== null && streaming.length > 0)
+ // `withHost` allocates, so it is memoized here rather than called inside the
+ // selector above - a selector returning a fresh array defeats zustand's
+ // identity check and re-renders the transcript on every unrelated write.
+ const members = useMemo(() => withHost(activeChannelMembers), [activeChannelMembers])
// `messages` is cleared the instant you click a session and refilled only after
// the round trip, so an empty array on its own says nothing about whether the
// session HAS messages. Trusting it painted the empty state over every switch.
@@ -102,7 +123,7 @@ export function ChatView(): JSX.Element {
const scrollRef = useRef(null)
// Follow the conversation only while you're already at the bottom. If you've
// scrolled up to read history, new messages/stream chunks must NOT yank you
- // back down — resume following once you scroll back to the end.
+ // back down — resume following once you scroll back to the end.
const stickToBottom = useRef(true)
// The chat we have already jumped to the end of. Cleared on every switch, so
// until it matches `activeChatId` the pane is still "arriving" and the tail
@@ -112,13 +133,19 @@ export function ChatView(): JSX.Element {
// mistaken for the user scrolling away (see `onScroll`).
const pinnedTop = useRef(-1)
const [loopPaneOpen, setLoopPaneOpen] = useState(false)
+ const [botEditOpen, setBotEditOpen] = useState(false)
const [infoOpen, setInfoOpen] = useState(false)
// Show only the latest N; scrolling up loads older ones a page at a time.
const [visibleCount, setVisibleCount] = useState(VISIBLE_MESSAGES)
const restoreHeight = useRef(null)
- // Which chat `restoreHeight` was measured in — a height from another session
+ // Which chat `restoreHeight` was measured in — a height from another session
// is meaningless and must not be applied.
const restoreChatId = useRef(null)
+ // Closed by default: a session with only Roxy in it has no roster worth a
+ // permanent column, and the header count is enough to discover it.
+ const [membersOpen, setMembersOpen] = useState(false)
+ // Text pushed into the composer by clicking a member ('@Name ').
+ const [draft, setDraft] = useState('')
/** Jump to the newest message, recording the offset as ours. */
const pinToBottom = useCallback((): void => {
@@ -135,7 +162,7 @@ export function ChatView(): JSX.Element {
// Before the arrival pin lands, every scroll event here is our own doing:
// the outgoing transcript unmounting collapses scrollHeight and the browser
// clamps scrollTop to 0. Reading that back as "the user scrolled to the top"
- // is what left switches parked at the top — it cleared `stickToBottom` for a
+ // is what left switches parked at the top — it cleared `stickToBottom` for a
// session you had not even seen yet, so nothing ever pinned it, and at
// scrollTop 0 it also paged in another 30 messages on the way past.
if (arrivedChatId.current !== activeChatId) return
@@ -145,7 +172,7 @@ export function ChatView(): JSX.Element {
// gap the user had opened by hand. The ResizeObserver re-pins that growth.
stickToBottom.current =
el.scrollTop === pinnedTop.current || el.scrollHeight - el.scrollTop - el.clientHeight < 80
- // Near the top with more history → reveal another page, preserving position.
+ // Near the top with more history → reveal another page, preserving position.
if (el.scrollTop < 80 && visibleCount < messages.length) {
restoreHeight.current = el.scrollHeight
restoreChatId.current = activeChatId
@@ -159,7 +186,7 @@ export function ChatView(): JSX.Element {
// do both after paint (`useEffect` + `scrollTop = 0`), which is a race it lost
// every time: the new transcript arrives a commit LATER than the switch, so
// the reset ran after the tail effect had already pinned and stomped the pin
- // back to zero — and the scroll event that write produced then cleared
+ // back to zero — and the scroll event that write produced then cleared
// `stickToBottom`, so nothing pinned again. That is the whole "switching to a
// session lands at the top" bug. Marking the session "not arrived" instead
// hands the offset to the tail effect below, which lands it whenever the
@@ -178,7 +205,7 @@ export function ChatView(): JSX.Element {
}, [activeChatId])
// Keep the scroll anchored when older messages prepend (no jump to the top).
- // Guarded on the chat the measurement was taken in — `visibleCount` also
+ // Guarded on the chat the measurement was taken in — `visibleCount` also
// changes on a session switch, which would otherwise replay a stale delta.
useEffect(() => {
const el = scrollRef.current
@@ -194,8 +221,8 @@ export function ChatView(): JSX.Element {
//
// Runs on layout (not after paint) so the jump is never a visible frame, and
// re-pins on the next frame as well: a turn's content keeps growing AFTER this
- // commit — images decode, `lazy()` diff/file views resolve, Streamdown re-lays
- // out — and a single scrollTo lands short of the real bottom every time.
+ // commit — images decode, `lazy()` diff/file views resolve, Streamdown re-lays
+ // out — and a single scrollTo lands short of the real bottom every time.
//
// The first pin after a switch is unconditional. `stickToBottom` cannot be
// trusted yet at that point: the outgoing transcript unmounting fires a scroll
@@ -203,7 +230,7 @@ export function ChatView(): JSX.Element {
useLayoutEffect(() => {
if (!scrollRef.current) return
if (arrivedChatId.current !== activeChatId) {
- // Still waiting on this session's transcript — nothing to land on yet.
+ // Still waiting on this session's transcript — nothing to land on yet.
// This effect re-runs when it arrives.
if (!transcriptReady) return
arrivedChatId.current = activeChatId
@@ -220,8 +247,8 @@ export function ChatView(): JSX.Element {
// guessing a delay.
//
// Attached by ref callback and kept for the column's lifetime. This used to be
- // built inside the tail effect above, whose deps include `streaming` — a fresh
- // array on EVERY streamed delta — so a live turn tore down and rebuilt a
+ // built inside the tail effect above, whose deps include `streaming` — a fresh
+ // array on EVERY streamed delta — so a live turn tore down and rebuilt a
// ResizeObserver on each one, every rebuild firing an immediate observation
// and forcing layout. That was a large share of the streaming jank.
const resizeObserver = useRef(null)
@@ -247,10 +274,10 @@ export function ChatView(): JSX.Element {
// auto-growing as you type, the workstream strip. The browser does not adjust
// `scrollTop` for that, and both directions are visibly wrong:
//
- // shrinking (queue opens) — the max scroll offset grows, so an offset that
+ // shrinking (queue opens) — the max scroll offset grows, so an offset that
// WAS the bottom is now short of it and the last messages slide out of
// view. Reads as the queue shoving the transcript upward.
- // growing (queue collapses) — the reclaimed height appears BELOW the last
+ // growing (queue collapses) — the reclaimed height appears BELOW the last
// message as dead space, because the offset never moves back down. Reads
// as the collapsed queue still holding its space.
//
@@ -269,6 +296,10 @@ export function ChatView(): JSX.Element {
const activeChat = chats.find((c) => c.id === activeChatId)
const isSub = activeChat?.kind === 'sub'
+ // The bot whose private chat this is, if any. Drives the header identity, the
+ // empty state, and the composer placeholder - everything that says WHO you
+ // are talking to, which in this view is the whole point.
+ const activeBot = useMemo(() => bots.find((b) => b.chatId === activeChatId), [bots, activeChatId])
const parentChat = activeChat?.parentId
? chats.find((c) => c.id === activeChat.parentId)
: undefined
@@ -280,7 +311,7 @@ export function ChatView(): JSX.Element {
// read. Gate on having something to show, not on the session's kind.
const hasSessionInfo = !!activeChat?.description?.trim() || sessionTasks.length > 0
- // No workspace open — prompt to open a folder to start a session.
+ // No workspace open — prompt to open a folder to start a session.
if (!activeChat) {
return (
@@ -315,15 +346,30 @@ export function ChatView(): JSX.Element {
) : (
- {isSub ? (
+ {activeBot ? (
+
+ ) : isSub ? (
) : (
)}
{activeChat.title}
- {/* A delegate's session is only legible in context — who sent it, and
+ {/* A delegate's session is only legible in context — who sent it, and
a way back. The folder path is the parent's business. */}
- {isSub ? (
+ {activeBot ? (
+ // A bot chat has no folder, so the slot shows the bot's role
+ // instead - the one line the user wrote to say what it is for.
+ {activeBot.description}
+ ) : isSub ? (
parentChat && (
)}
+ {/* Edit, not "members": there is one bot in here and its brief is the
+ only thing worth changing from this view. */}
+ {activeBot && (
+
+ )}
{activeLoop && (
)}
+ {/* Hidden in a bot chat: its roster is fixed at one bot, so a panel
+ for attaching others would invite building a channel in the one
+ place that exists to be a one-on-one. */}
+
{infoOpen && }
-
- {messagesError ? (
- // A failed load used to be indistinguishable from an empty session:
- // silent, blank, and with no way back other than clicking away and
- // returning. Name it and make it recoverable.
-
-
{t('chat.loadFailed')}
-
-
- ) : loading ? (
- // Deliberately blank: a transcript read is a local SQLite query, so it
- // resolves within a frame or two and a spinner would be a flash of
- // chrome rather than information. This branch exists to stop the EMPTY
- // state (and its loop copy) from claiming the session has no messages
- // before we know that.
-
- ) : isEmpty ? (
-
- {activeLoop ? (
-
- }}
- />
-
+
+
+
+ {messagesError ? (
+ // A failed load used to be indistinguishable from an empty session:
+ // silent, blank, and with no way back other than clicking away and
+ // returning. Name it and make it recoverable.
+
+
{t('chat.loadFailed')}
+
+
+ ) : loading ? (
+ // Deliberately blank: a transcript read is a local SQLite query, so it
+ // resolves within a frame or two and a spinner would be a flash of
+ // chrome rather than information. This branch exists to stop the EMPTY
+ // state (and its loop copy) from claiming the session has no messages
+ // before we know that.
+
+ ) : isEmpty ? (
+
+ {activeBot ? (
+ // Says the two things a fresh bot chat has to: that only this
+ // bot answers here, and that `@Name` in a project reaches the
+ // SAME bot - the relationship between the two doors is not
+ // guessable, and getting it wrong means maintaining the bot
+ // twice.
+
- ) : (
- // mt-auto bottom-aligns a SHORT transcript.
- //
- // A new or brief session does not fill the pane, and a top-aligned
- // column left everything below the last message as empty background --
- // measured at 488px on a two-message session, which reads as a broken
- // layout rather than breathing room. Pinning to the bottom cannot fix
- // it: with nothing to scroll, scrollTop is already 0.
- //
- // mt-auto absorbs that slack while the column is shorter than the
- // scrollport and resolves to 0 the moment it overflows, so long
- // transcripts are untouched. Deliberately NOT justify-end on the
- // parent: that clips overflow at the TOP in Chromium, which would put
- // paged-in history out of reach. w-full because a flex child would
- // otherwise shrink-to-fit and mx-auto would no longer center it.
- //
- // pb clears the fade below: at max scroll the last line has to end
- // ABOVE the gradient, otherwise the final message always looks dimmed.
-
- {messages.length > visibleCount && (
-
- Scroll up to load older — showing the last {visibleCount} of {messages.length}
-
+ // mt-auto bottom-aligns a SHORT transcript.
+ //
+ // A new or brief session does not fill the pane, and a top-aligned
+ // column left everything below the last message as empty background --
+ // measured at 488px on a two-message session, which reads as a broken
+ // layout rather than breathing room. Pinning to the bottom cannot fix
+ // it: with nothing to scroll, scrollTop is already 0.
+ //
+ // mt-auto absorbs that slack while the column is shorter than the
+ // scrollport and resolves to 0 the moment it overflows, so long
+ // transcripts are untouched. Deliberately NOT justify-end on the
+ // parent: that clips overflow at the TOP in Chromium, which would put
+ // paged-in history out of reach. w-full because a flex child would
+ // otherwise shrink-to-fit and mx-auto would no longer center it.
+ //
+ // pb clears the fade below: at max scroll the last line has to end
+ // ABOVE the gradient, otherwise the final message always looks dimmed.
+
+ {messages.length > visibleCount && (
+
+ Scroll up to load older — showing the last {visibleCount} of {messages.length}
+
- {/* The transcript used to end on a hard clip: the scrollport edge sliced
- text mid-glyph, straight into the composer’s flat gutter, and the two
+ {/* The transcript used to end on a hard clip: the scrollport edge sliced
+ text mid-glyph, straight into the composer’s flat gutter, and the two
together read as a black bar cutting the pane in half. This is a
- gradient of the pane’s own background laid over the last 24px of the
+ gradient of the pane’s own background laid over the last 24px of the
scroller, so lines dissolve into the composer instead of being cut.
- Pulled back up by its own height (-mt-6) so it costs no layout — the
- scroller keeps every pixel of flex-1 — and inert to the pointer, so
+ Pulled back up by its own height (-mt-6) so it costs no layout — the
+ scroller keeps every pixel of flex-1 — and inert to the pointer, so
scrolling and text selection still work underneath it. The matching
pb-6 on the message column is what keeps the last line legible: at
max scroll it ends above the gradient instead of under it.
The mr-2.5 is the scrollbar gutter (10px, see ::-webkit-scrollbar
- in main.css). The bar occupies the scroller’s right edge, so a
+ in main.css). The bar occupies the scroller’s right edge, so a
full-width fade would paint over its last 24px and wash out the
thumb exactly when you drag it to the end. */}
-
+
- {queue.length > 0 && (
-
+ )}
- {/* A subagent's session can now be stopped from its own composer: the Stop
+ {/* A subagent's session can now be stopped from its own composer: the Stop
cancels the DELEGATE (there is no local request here to abort), which
is what the button visibly means in this view. */}
- void cancelSubagent(activeChatId) : () => stop()
- }
- />
+ setDraft('')}
+ onStop={
+ subagentRunning && activeChatId
+ ? () => void cancelSubagent(activeChatId)
+ : () => stop()
+ }
+ />
-
+
+
)
}
@@ -579,7 +730,7 @@ export function ChatView(): JSX.Element {
* Shows `worktreePath` in preference to `workspacePath`. Those differ for every
* workstream: the project folder is the repo you opened, but the agent's cwd is
* an isolated checkout under `worktrees/`. Showing the project path meant the
- * header named a directory the session was NOT editing — actively misleading
+ * header named a directory the session was NOT editing — actively misleading
* when several workstreams are open and you are trying to work out which
* checkout a dev server or an editor tab belongs to.
*
diff --git a/src/renderer/src/components/Composer.tsx b/src/renderer/src/components/Composer.tsx
index ec131c7..3fe2515 100644
--- a/src/renderer/src/components/Composer.tsx
+++ b/src/renderer/src/components/Composer.tsx
@@ -1,27 +1,72 @@
-import { useRef, useState, type ClipboardEvent, type DragEvent, type KeyboardEvent } from 'react'
+import {
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type ClipboardEvent,
+ type DragEvent,
+ type KeyboardEvent
+} from 'react'
import { useTranslation } from 'react-i18next'
import { ArrowUp, Plus, Square, X } from 'lucide-react'
+import type { BotMember } from '@shared/types'
import { ModelPicker } from './ModelPicker'
import { ContextMeter, ContextPicker, ThinkingPicker, AgentPicker } from './InferenceControls'
import { imageFilesFrom, readImageFile, type ComposerImage } from '../lib/images'
import { ImagePreview } from './ImagePreview'
+import { BotAvatar } from './BotAvatar'
export function Composer({
onSend,
sending,
- onStop
+ onStop,
+ members = [],
+ soloName,
+ draft,
+ onDraftConsumed
}: {
onSend: (text: string, images?: ComposerImage[]) => void
sending?: boolean
onStop?: () => void
+ /** Channel members offered by the `@` autocomplete (host first). */
+ members?: BotMember[]
+ /**
+ * Set in a bot's own chat: the one bot listening. Turns the placeholder into
+ * "Message " and suppresses the `@` menu, because in a one-on-one the
+ * only member to complete is the one already reading, and a mention there
+ * both looks like a channel and invites the model to hand off to nobody.
+ */
+ soloName?: string
+ /** Text pushed in from outside (clicking a member in the roster). */
+ draft?: string
+ onDraftConsumed?: () => void
}): JSX.Element {
const { t } = useTranslation()
const [value, setValue] = useState('')
const [images, setImages] = useState([])
const [dragging, setDragging] = useState(false)
+ // Null = the `@` menu is closed. '' is a real state (just typed `@`), which
+ // is why this can't be a plain empty-string check.
+ const [mentionQuery, setMentionQuery] = useState(null)
+ const [mentionIndex, setMentionIndex] = useState(0)
const ref = useRef(null)
const fileRef = useRef(null)
+ // Clicking a member in the roster prefills `@Name `, appending rather than
+ // replacing so it can be used mid-sentence without eating the draft.
+ useEffect(() => {
+ if (!draft) return
+ setValue((prev) => (prev ? `${prev.replace(/\s*$/, '')} ${draft}` : draft))
+ onDraftConsumed?.()
+ ref.current?.focus()
+ }, [draft, onDraftConsumed])
+
+ const matches = useMemo(() => {
+ if (soloName || mentionQuery === null) return []
+ const q = mentionQuery.toLowerCase()
+ return members.filter((m) => m.name.toLowerCase().startsWith(q))
+ }, [mentionQuery, members])
+
const addFiles = async (files: File[]): Promise => {
if (files.length === 0) return
const read = await Promise.all(files.map(readImageFile))
@@ -31,16 +76,66 @@ export function Composer({
const removeImage = (id: string): void => setImages((prev) => prev.filter((i) => i.id !== id))
+ /** Open/close the `@` menu based on the word the caret is sitting in. */
+ const syncMention = (text: string, caret: number): void => {
+ // Only a `@` that starts a word opens the menu, so typing an email address
+ // doesn't turn the composer into a member picker.
+ const match = text.slice(0, caret).match(/(?:^|[^\w@])@([\w-]*)$/)
+ setMentionQuery(match ? match[1] : null)
+ if (match) setMentionIndex(0)
+ }
+
+ const insertMention = (member: BotMember): void => {
+ const el = ref.current
+ if (!el) return
+ const caret = el.selectionStart
+ const head = value.slice(0, caret).replace(/@[\w-]*$/, `@${member.name} `)
+ setValue(head + value.slice(caret))
+ setMentionQuery(null)
+ // The caret has to be restored after React commits the new value, or the
+ // browser parks it at the end of the whole textarea.
+ requestAnimationFrame(() => {
+ el.focus()
+ el.setSelectionRange(head.length, head.length)
+ })
+ }
+
const submit = (): void => {
const text = value.trim()
if (!text && images.length === 0) return
onSend(text, images.length ? images : undefined)
setValue('')
setImages([])
+ setMentionQuery(null)
if (ref.current) ref.current.style.height = 'auto'
}
const onKeyDown = (event: KeyboardEvent): void => {
+ // The `@` menu owns the arrows, Enter/Tab, and Escape while it's open —
+ // checked first so Enter completes the member instead of sending a
+ // half-typed `@Rev`.
+ if (matches.length > 0) {
+ if (event.key === 'ArrowDown') {
+ event.preventDefault()
+ setMentionIndex((i) => (i + 1) % matches.length)
+ return
+ }
+ if (event.key === 'ArrowUp') {
+ event.preventDefault()
+ setMentionIndex((i) => (i - 1 + matches.length) % matches.length)
+ return
+ }
+ if (event.key === 'Enter' || event.key === 'Tab') {
+ event.preventDefault()
+ insertMention(matches[mentionIndex] ?? matches[0])
+ return
+ }
+ if (event.key === 'Escape') {
+ event.preventDefault()
+ setMentionQuery(null)
+ return
+ }
+ }
// Escape stops the turn. The button alone was not enough: it hides as soon
// as you type (the composer switches to "add to queue"), so drafting a
// follow-up while a turn ran left no visible way to stop it — you had to
@@ -87,7 +182,35 @@ export function Composer({
const canSend = !!value.trim() || images.length > 0
return (
-
+
+ {matches.length > 0 && (
+
+
+ Mention a member
+ ↑↓ · Enter
+
+
+ {matches.map((m, i) => (
+
+ ))}
+
+
+ )}
+
{
if (e.dataTransfer.types.includes('Files')) {
@@ -159,11 +282,22 @@ export function Composer({
? onStop
? t('composer.queuePlaceholderStop')
: t('composer.queuePlaceholder')
- : t('composer.placeholder')
+ : soloName
+ ? t('bots.chatPlaceholder', { name: soloName })
+ : members.length > 1
+ ? t('composer.channelPlaceholder')
+ : t('composer.placeholder')
}
onChange={(e) => {
setValue(e.target.value)
autoGrow()
+ syncMention(e.target.value, e.target.selectionStart)
+ }}
+ // The caret can also move without the value changing (arrows, a
+ // click), which opens or closes the menu just the same.
+ onSelect={(e) => {
+ const el = e.currentTarget
+ syncMention(el.value, el.selectionStart)
}}
onKeyDown={onKeyDown}
onPaste={onPaste}
diff --git a/src/renderer/src/components/MessageBubble.tsx b/src/renderer/src/components/MessageBubble.tsx
index ac32420..07f9d6c 100644
--- a/src/renderer/src/components/MessageBubble.tsx
+++ b/src/renderer/src/components/MessageBubble.tsx
@@ -1,9 +1,9 @@
import { memo } from 'react'
import { User } from 'lucide-react'
-import roxy from '../assets/roxy.png'
-import type { MessagePart, MessageRole } from '@shared/types'
+import type { BotAuthor, BotMember, MessagePart, MessageRole } from '@shared/types'
import { MessageParts } from './MessageParts'
import { HOVERABLE_THUMB, ImagePreview } from './ImagePreview'
+import { BotAvatar, accentOf } from './BotAvatar'
import { cn } from '../lib/cn'
/** Flatten a turn's text parts down to plain text (for user messages). */
@@ -11,15 +11,57 @@ function partsToText(parts: MessagePart[]): string {
return parts.map((p) => (p.type === 'text' || p.type === 'reasoning' ? p.text : '')).join('')
}
+/**
+ * A user message with its `@mentions` tinted in their member's accent, so who
+ * the message was addressed to is visible at a glance in a busy channel.
+ *
+ * Only names that are actually IN the channel are highlighted — a stray
+ * `@something` stays plain text rather than implying a member that can't answer.
+ */
+function UserText({ text, members }: { text: string; members: BotMember[] }): JSX.Element {
+ const byName = new Map(members.map((m) => [m.name.toLowerCase(), m]))
+ return (
+
+ )
+}
+
function MessageBubbleImpl({
role,
parts,
+ author,
+ members = [],
streaming = false
}: {
role: MessageRole
parts: MessagePart[]
+ /** Which member wrote this turn. Absent = Roxy (and every pre-channel turn). */
+ author?: BotAuthor
+ /** Current channel membership, for tinting mentions. */
+ members?: BotMember[]
streaming?: boolean
}): JSX.Element {
+ // A channel notice is not something anyone SAID — it is a line in the room's
+ // history — so it renders as a bare centered divider with no avatar, no name,
+ // and no bubble. Handled here rather than in the transcript loop so a notice
+ // stays a normal persisted message everywhere else.
+ if (role === 'system') return
+
const isUser = role === 'user'
const imageParts = parts.filter(
(p): p is Extract => p.type === 'image'
@@ -33,15 +75,21 @@ function MessageBubbleImpl({
+ )
+}
+
/**
* One markdown block.
*
diff --git a/src/renderer/src/components/Sidebar.tsx b/src/renderer/src/components/Sidebar.tsx
index c57dc3b..44d826c 100644
--- a/src/renderer/src/components/Sidebar.tsx
+++ b/src/renderer/src/components/Sidebar.tsx
@@ -10,6 +10,7 @@ import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import type { TFunction } from 'i18next'
import {
+ Bot as BotIcon,
FolderOpen,
GitBranch,
GitFork,
@@ -27,7 +28,7 @@ import {
Trash2
} from 'lucide-react'
import { MorphIcon } from 'morphicons/react'
-import type { Chat, Loop } from '@shared/types'
+import type { Bot, Chat, Loop } from '@shared/types'
import type { LifecycleView } from '@shared/forge'
import { isPullRequestPhase } from '@shared/forge'
import { statusKeyForSession } from '@shared/workstream'
@@ -40,6 +41,8 @@ import { ContextMenuRow, ContextMenuSurface, CONTEXT_MENU_PAD, CONTEXT_ROW_H } f
import { TONE_BG, TONE_TEXT_STATIC } from '../lib/lifecycle'
import { HeartbeatDot } from './LoopsSection'
import { RemoteWorkspaceDialog } from './RemoteWorkspaceDialog'
+import { BotCarousel } from './BotCarousel'
+import { BotDialog } from './BotDialog'
import { BrailleSpinner } from './ThinkingIndicator'
import { UpdateCard } from './UpdateCard'
import roxy from '../assets/roxy.png'
@@ -172,11 +175,18 @@ export function Sidebar(): JSX.Element {
const cancelSubagent = useRoxyStore((s) => s.cancelSubagent)
const loops = useRoxyStore((s) => s.loops)
const removeLoop = useRoxyStore((s) => s.removeLoop)
+ const bots = useRoxyStore((s) => s.bots)
+ const createBot = useRoxyStore((s) => s.createBot)
+ const updateBot = useRoxyStore((s) => s.updateBot)
+ const removeBot = useRoxyStore((s) => s.removeBot)
const reorderSessions = useRoxyStore((s) => s.reorderSessions)
const reorderProjects = useRoxyStore((s) => s.reorderProjects)
const projectOrder = useRoxyStore((s) => s.projectOrder)
const [collapsed, setCollapsed] = useState>(new Set())
const [expandedSubs, setExpandedSubs] = useState>(new Set())
+ // `undefined` = closed, `null` = creating, a Bot = editing that one. One bit
+ // of state because the dialog is one component for both (see BotDialog).
+ const [botDialog, setBotDialog] = useState(undefined)
const [width, setWidth] = useState(() => {
const v = Number(localStorage.getItem(WIDTH_KEY))
return Number.isFinite(v) && v >= MIN_WIDTH && v <= MAX_WIDTH ? v : DEFAULT_WIDTH
@@ -497,6 +507,15 @@ export function Sidebar(): JSX.Element {
return next
})
+ // Which bot chats have a turn in flight, for the carousel's pulsing ring. A
+ // Set keyed by CHAT id because that is what `sendingChats` is keyed by, and
+ // the strip is the only place a running bot is visible while its chat is
+ // closed (its session row is not in the project list at all).
+ const botBusyChatIds = useMemo(
+ () => new Set(bots.map((b) => b.chatId).filter((id) => !!sendingChats[id])),
+ [bots, sendingChats]
+ )
+
const toggleProject = (path: string): void =>
setCollapsed((prev) => {
const next = new Set(prev)
@@ -524,6 +543,27 @@ export function Sidebar(): JSX.Element {
>
+
+
+ {/* Faces only, stacked - the rail has no room for names, and a bot IS
+ its face here. Scrolls so a long roster cannot push the footer nav
+ off the bottom. */}
+
{remoteOpen && setRemoteOpen(false)} />}
+ {botDialog !== undefined && (
+ {
+ if (botDialog) await updateBot(botDialog.id, input)
+ else await createBot(input)
+ }}
+ onDelete={botDialog ? () => removeBot(botDialog.id) : undefined}
+ onClose={() => setBotDialog(undefined)}
+ />
+ )}
)
}
@@ -1061,6 +1112,18 @@ export function Sidebar(): JSX.Element {
{remoteOpen && setRemoteOpen(false)} />}
+ {botDialog !== undefined && (
+ {
+ if (botDialog) await updateBot(botDialog.id, input)
+ else await createBot(input)
+ }}
+ onDelete={botDialog ? () => removeBot(botDialog.id) : undefined}
+ onClose={() => setBotDialog(undefined)}
+ />
+ )}
+
setRemoteOpen(true)} remoteDot={remoteDot} />
diff --git a/src/renderer/src/components/ToolCall.tsx b/src/renderer/src/components/ToolCall.tsx
index 2ab640a..aa7370d 100644
--- a/src/renderer/src/components/ToolCall.tsx
+++ b/src/renderer/src/components/ToolCall.tsx
@@ -121,6 +121,7 @@ function activity(parts: MessagePart[]): { label: string; step: number } {
if (last.type === 'image') return { label: 'captured an image', step }
// Prose: the delegate is writing its report — show its last line so you can
// watch the conclusion form rather than a static "writing…".
+ if (last.type !== 'text') return { label: 'working…', step }
const line = last.text.trim().split('\n').filter(Boolean).pop()
return { label: line ? line.slice(0, 120) : 'writing…', step }
}
diff --git a/src/renderer/src/lib/store.ts b/src/renderer/src/lib/store.ts
index fb263ff..4d9ddb3 100644
--- a/src/renderer/src/lib/store.ts
+++ b/src/renderer/src/lib/store.ts
@@ -2,8 +2,19 @@ import { create } from 'zustand'
import { DEFAULT_AGENT_ID, getAgent } from '@shared/agents'
import type { Language } from '@shared/i18n'
import { applyLanguage } from '../i18n'
+import {
+ MAX_HANDOFF_HOPS,
+ channelPrompt,
+ resolveHandoff,
+ resolveRecipient,
+ soloSpeaker,
+ visibleMessages,
+ withHost
+} from '@shared/channel-members'
+import type { BotAuthor, BotMember, CreateBotInput, UpdateBotInput } from '@shared/types'
import type {
AppSettings,
+ Bot,
Chat,
ConnectedProvider,
Loop,
@@ -88,6 +99,14 @@ interface RoxyStore {
*/
hiddenModels: Set
chats: Chat[]
+ /**
+ * The saved-bot library, in carousel order.
+ *
+ * Top-level state rather than derived from `chats`, because a bot is not a
+ * session: it has an identity (name, brief, face) that outlives the chat it
+ * owns and is offered to every project channel. The chat is a thing it HAS.
+ */
+ bots: Bot[]
activeChatId: string | null
messages: Message[]
/**
@@ -111,6 +130,14 @@ interface RoxyStore {
sendingChats: Record
/** In-progress assistant parts per chat while a reply streams in. */
streamingChats: Record
+ /**
+ * Which channel member is mid-reply, per chat. Kept beside the streamed
+ * parts so the live bubble is attributed to whoever is actually speaking:
+ * during a hand-off chain the answering member changes between turns, and a
+ * bubble labelled "Roxy" while Reviewer types is simply wrong. Absent means
+ * the host is answering.
+ */
+ speakingChats: Record
/**
* The open session's mode, mirrored from its `chat.agentId` for synchronous
* reads (the composer + the context meter re-render on every keystroke, and
@@ -183,6 +210,14 @@ interface RoxyStore {
bootstrap: () => Promise
refreshChats: () => Promise
+ refreshBots: () => Promise
+ /** Create a bot and open its own chat, so the next thing you do is talk to it. */
+ createBot: (input: CreateBotInput) => Promise
+ updateBot: (id: string, patch: UpdateBotInput) => Promise
+ /** Delete a bot and its conversation, leaving the sessions it joined intact. */
+ removeBot: (id: string) => Promise
+ /** Persist the carousel order (optimistic). `ids` = full list, first to last. */
+ reorderBots: (ids: string[]) => Promise
refreshLoops: () => Promise
refreshQueue: () => Promise
refreshProviders: () => Promise
@@ -251,7 +286,21 @@ interface RoxyStore {
/** Persist the project (workspace) order (optimistic). `paths` = full list, top → bottom. */
reorderProjects: (paths: string[]) => Promise
submit: (content: string, images?: ComposerImage[]) => Promise
- sendMessage: (content: string, chatId?: string, images?: ComposerImage[]) => Promise
+ sendMessage: (
+ content: string,
+ chatId?: string,
+ images?: ComposerImage[],
+ /**
+ * Which channel member answers, overriding what the text addresses. Set
+ * only by a hand-off, where the next speaker is decided by the previous
+ * bot's reply rather than by the user's message.
+ */
+ speaker?: BotMember,
+ /** Hand-offs already spent on this user message (see MAX_HANDOFF_HOPS). */
+ hop?: number
+ ) => Promise
+ /** Attach/detach this channel's bots, posting a join/leave notice. */
+ setChannelMembers: (chatId: string, members: BotMember[]) => Promise
drainQueue: (chatId: string) => Promise
removeQueued: (id: string) => Promise
moveQueued: (id: string, direction: 'up' | 'down') => Promise
@@ -948,12 +997,14 @@ export const useRoxyStore = create((set, get) => ({
pinnedModels: [],
hiddenModels: new Set(),
chats: [],
+ bots: [],
activeChatId: null,
messages: [],
messagesChatId: null,
messagesError: false,
sendingChats: {},
streamingChats: {},
+ speakingChats: {},
activeAgentId: DEFAULT_AGENT_ID,
projectInstructions: {},
projectOrder: [],
@@ -975,14 +1026,16 @@ export const useRoxyStore = create((set, get) => ({
usageStats: null,
bootstrap: async () => {
- const [settings, providers, chats, loops, projectOrder, telemetryEnabled] = await Promise.all([
- api.settings.getAll(),
- api.providers.listConnected(),
- api.chats.list(),
- api.loops.list(),
- api.projects.listOrder(),
- api.settings.getTelemetry()
- ])
+ const [settings, providers, chats, bots, loops, projectOrder, telemetryEnabled] =
+ await Promise.all([
+ api.settings.getAll(),
+ api.providers.listConnected(),
+ api.chats.list(),
+ api.bots.list(),
+ api.loops.list(),
+ api.projects.listOrder(),
+ api.settings.getTelemetry()
+ ])
// A factory reset truncates these tables and re-bootstraps, so the load
// guards have to fall with them or the picker keeps filtering on a
// deny-list the database no longer has.
@@ -996,6 +1049,7 @@ export const useRoxyStore = create((set, get) => ({
settings,
providers,
chats,
+ bots,
loops,
projectOrder,
telemetryEnabled,
@@ -1141,6 +1195,45 @@ export const useRoxyStore = create((set, get) => ({
set({ chats, projectOrder })
},
+ refreshBots: async () => {
+ set({ bots: await api.bots.list() })
+ },
+
+ createBot: async (input) => {
+ const bot = await api.bots.create(input)
+ // The chat came with the bot, so both lists are stale.
+ await Promise.all([get().refreshBots(), get().refreshChats()])
+ // Open it immediately: a bot you cannot see is indistinguishable from one
+ // that failed to save, and its own chat is where it gets taught.
+ await get().selectChat(bot.chatId)
+ return bot
+ },
+
+ updateBot: async (id, patch) => {
+ await api.bots.update(id, patch)
+ // `refreshChats` too: renaming a bot retitles its chat and rewrites that
+ // chat's membership, so the sidebar row and the composer's `@` menu would
+ // otherwise keep showing the old name until something else refetched.
+ await Promise.all([get().refreshBots(), get().refreshChats()])
+ },
+
+ removeBot: async (id) => {
+ const bot = get().bots.find((b) => b.id === id)
+ await api.bots.remove(id)
+ await Promise.all([get().refreshBots(), get().refreshChats()])
+ // Its chat is gone with it - if that was what you were reading, the pane
+ // would otherwise sit on a transcript belonging to no session.
+ if (bot && get().activeChatId === bot.chatId) get().clearActive()
+ },
+
+ reorderBots: async (ids) => {
+ const by = new Map(get().bots.map((b) => [b.id, b]))
+ const next = ids.map((id) => by.get(id)).filter((b): b is Bot => !!b)
+ if (next.length === ids.length) set({ bots: next })
+ await api.bots.reorder(ids)
+ await get().refreshBots()
+ },
+
refreshLoops: async () => {
set({ loops: await api.loops.list() })
},
@@ -1721,11 +1814,13 @@ export const useRoxyStore = create((set, get) => ({
set((s) => {
const sendingChats = { ...s.sendingChats }
const streamingChats = { ...s.streamingChats }
+ const speakingChats = { ...s.speakingChats }
const stopChats = { ...s.stopChats }
delete sendingChats[id]
delete streamingChats[id]
+ delete speakingChats[id]
delete stopChats[id]
- return { sendingChats, streamingChats, stopChats }
+ return { sendingChats, streamingChats, speakingChats, stopChats }
})
if (get().activeChatId === id) get().clearActive()
},
@@ -1806,13 +1901,27 @@ export const useRoxyStore = create((set, get) => ({
await get().sendMessage(text, undefined, images)
},
- sendMessage: async (content, targetChatId, images) => {
+ sendMessage: async (content, targetChatId, images, speaker, hop = 0) => {
const chatId = targetChatId ?? get().activeChatId
if (!chatId) return
if (get().sendingChats[chatId]) return
if (content.startsWith('!') && !content.slice(1).trim()) return
const { settings } = get()
+ // Who answers: the member a hand-off named, else the member the message is
+ // ADDRESSED to (it opens with their @mention), else the host. A mention
+ // later in the text is the user talking ABOUT a member - ".. and then call
+ // @Reviewer" is the host's job, ending in a hand-off - so it stays with the
+ // host, who is in every channel and keeps the room from having nobody
+ // listening. See resolveRecipient.
+ const chatKind = get().chats.find((c) => c.id === chatId)?.kind
+ const members = withHost(get().chats.find((c) => c.id === chatId)?.channelMembers)
+ // A BOT chat is a one-on-one, so its bot answers everything - see
+ // `soloSpeaker`. Routing it like a channel would give every unaddressed
+ // message to the host, i.e. talking to your bot would be talking to Roxy.
+ const author =
+ speaker ?? (chatKind === 'bot' ? soloSpeaker(members) : resolveRecipient(content, members))
+
// Make sure the workspace's instruction files are cached before we size the
// window cut (the main process reads them fresh when it builds the prompt).
const workspacePath = get().chats.find((c) => c.id === chatId)?.workspacePath
@@ -1886,13 +1995,24 @@ export const useRoxyStore = create((set, get) => ({
chatId,
role: 'assistant',
content: partsToContent(parts),
- parts
+ parts,
+ author: {
+ name: author.name,
+ role: author.role,
+ icon: author.icon,
+ color: author.color
+ }
})
appendIfActive(assistantMessage)
}
setStreaming(null)
setSending(false)
clearStop()
+ set((s) => {
+ const next = { ...s.speakingChats }
+ delete next[chatId]
+ return { speakingChats: next }
+ })
// If a remote (phone) turn landed while this local send was streaming, we
// deferred the mirror to avoid clobbering the stream — reconcile it now.
if (remoteMirror.deferred && get().remote.sessionId === chatId) {
@@ -1909,12 +2029,49 @@ export const useRoxyStore = create((set, get) => ({
if (active && !get().chats.some((c) => c.id === active)) {
await get().selectChat(chatId)
}
+ // A bot that ends its turn by @mentioning another member has handed the
+ // work over, so run that member next instead of returning to the user.
+ // This is what makes the channel a conversation: Builder finishes and
+ // says "@Reviewer ready", and the review happens without being asked.
+ //
+ // Capped at MAX_HANDOFF_HOPS: each hop is a full model turn of work the
+ // user did not directly ask for, so the chain is bounded rather than
+ // trusted to wind down on its own. A stopped turn hands off to nobody -
+ // stopping means stop, not "stop and start something else".
+ const next = wasStopped ? undefined : resolveHandoff(partsToContent(parts), author, members)
+ if (next && hop < MAX_HANDOFF_HOPS) {
+ const notice = await api.messages.add({
+ chatId,
+ role: 'system',
+ content: `${author.name} handed off to ${next.name}`,
+ parts: [{ type: 'notice', kind: 'handoff', member: author.name, to: next.name }]
+ })
+ appendIfActive(notice)
+ // The next member reads the hand-off from the transcript it can already
+ // see, so it needs no synthesized prompt telling it what it was asked -
+ // an invented user message would put words in the user's mouth and show
+ // up in the history as though they had typed it.
+ await get().sendMessage('', chatId, undefined, next, hop + 1)
+ return
+ }
+
// Don't auto-run the next queued prompt when the user stopped this turn.
if (!wasStopped) await get().drainQueue(chatId)
}
clearStop()
setSending(true)
+ set((s) => ({
+ speakingChats: {
+ ...s.speakingChats,
+ [chatId]: {
+ name: author.name,
+ role: author.role,
+ icon: author.icon,
+ color: author.color
+ }
+ }
+ }))
// The user turn carries any pasted/dropped images as image parts ahead of
// the text, so they persist, render as thumbnails, and reach the model.
@@ -1927,13 +2084,19 @@ export const useRoxyStore = create((set, get) => ({
})),
...(content ? [{ type: 'text' as const, text: content }] : [])
]
- const userMessage = await api.messages.add({
- chatId,
- role: 'user',
- content,
- parts: userParts.length ? userParts : undefined
- })
- appendIfActive(userMessage)
+ // A hand-off has no user turn behind it - the previous bot asked, not the
+ // user - so it writes nothing here. Persisting an empty user message would
+ // both show a blank bubble and, worse, make the model's own request look
+ // like something the user typed.
+ if (userParts.length) {
+ const userMessage = await api.messages.add({
+ chatId,
+ role: 'user',
+ content,
+ parts: userParts
+ })
+ appendIfActive(userMessage)
+ }
// Reveal the assistant bubble right away (empty → a cute "thinking"
// indicator) so there's no empty gap while we wait for the first token.
setStreaming(parts)
@@ -2008,7 +2171,8 @@ export const useRoxyStore = create((set, get) => ({
contextBudget,
info?.outputLimit ?? 4096,
model,
- agentId
+ agentId,
+ author
)
// Build parts live from the agent's event stream through the shared fold:
// text grows the current text part, each tool call adds a card that flips
@@ -2086,6 +2250,10 @@ export const useRoxyStore = create((set, get) => ({
model,
messages: chatMessages,
agentId,
+ // The whole roster, not just this member's brief: a bot that isn't
+ // told who else is in the room answers "call @Reviewer" by spawning a
+ // subagent (or hunting for a GitHub user) instead of handing off.
+ memberPrompt: channelPrompt(members, author, chatKind === 'bot'),
reasoning: info?.reasoning ?? false,
// Clamp to what THIS model accepts. A session's effort is sticky
// across model switches, so "Max" set on one model would otherwise
@@ -2119,6 +2287,34 @@ export const useRoxyStore = create((set, get) => ({
await finishTurn()
},
+ setChannelMembers: async (chatId, members) => {
+ const before = withHost(get().chats.find((c) => c.id === chatId)?.channelMembers)
+ const updated = await api.channel.setMembers({ chatId, members })
+ set({ chats: get().chats.map((c) => (c.id === chatId ? updated : c)) })
+
+ // Post a notice for what actually changed, so the transcript records who
+ // was in the room when each message was written. Without it, scrolling back
+ // through a channel whose membership moved reads as though today's roster
+ // had always been there.
+ const beforeIds = new Set(before.map((m) => m.id))
+ const afterIds = new Set(withHost(updated.channelMembers).map((m) => m.id))
+ const notices = [
+ ...before.filter((m) => !afterIds.has(m.id)).map((m) => ['leave', m.name] as const),
+ ...withHost(updated.channelMembers)
+ .filter((m) => !beforeIds.has(m.id))
+ .map((m) => ['join', m.name] as const)
+ ]
+ for (const [kind, name] of notices) {
+ const message = await api.messages.add({
+ chatId,
+ role: 'system',
+ content: `${name} ${kind === 'join' ? 'joined' : 'left'} the channel`,
+ parts: [{ type: 'notice', kind, member: name }]
+ })
+ if (get().activeChatId === chatId) set({ messages: [...get().messages, message] })
+ }
+ },
+
drainQueue: async (chatId) => {
const items = await api.queue.list(chatId)
if (items.length === 0) {
@@ -2300,8 +2496,17 @@ export const useRoxyStore = create((set, get) => ({
export function buildSystemPrompt(
chat: Chat | undefined,
modelId?: string,
- agentId?: string
+ agentId?: string,
+ /**
+ * The answering channel member, whose roster block + brief main APPENDS to
+ * the base prompt (see harness/agent.ts). Included here so the window reserve
+ * accounts for its length instead of under-reserving by that much.
+ */
+ member?: BotMember
): string {
+ const memberBlock = member
+ ? channelPrompt(chat?.channelMembers ?? [], member, chat?.kind === 'bot')
+ : undefined
const base = PROMPT_TEXT[selectPromptName(modelId)] ?? PROMPT_TEXT.default
const environment = buildEnvironment({
cwd: chat?.workspacePath || undefined,
@@ -2317,7 +2522,11 @@ export function buildSystemPrompt(
const instructions = workspace
? (useRoxyStore.getState().projectInstructions[workspace] ?? [])
: []
- const extra = [...instructions, ...(agentPrompt ? [agentPrompt] : [])]
+ const extra = [
+ ...instructions,
+ ...(agentPrompt ? [agentPrompt] : []),
+ ...(memberBlock ? [memberBlock] : [])
+ ]
return assembleSystemPrompt({
base,
environment,
@@ -2335,15 +2544,24 @@ async function buildChatMessages(
contextBudget = 128_000,
outputReserve = 4096,
modelId?: string,
- agentId?: string
+ agentId?: string,
+ member?: BotMember
): Promise {
const chat = useRoxyStore.getState().chats.find((c) => c.id === chatId)
- const systemText = buildSystemPrompt(chat, modelId, agentId)
+ const systemText = buildSystemPrompt(chat, modelId, agentId, member)
const since = chat?.contextSummaryAt ?? 0
+ const history = await api.messages.list(chatId)
+ // Narrow the transcript to what THIS member should see before anything else
+ // reads it. This is the whole reason a channel has separate bots rather than
+ // one agent wearing hats: a specialist that carries the entire project's
+ // cross-talk starts treating its own earlier conclusions as fresh evidence,
+ // and unrelated work crowds out the code it was actually asked about. The
+ // host is exempt - Roxy has to follow the whole room. See visibleMessages.
+ const scoped = member ? visibleMessages(history, member) : history
// Each turn rebuilds into one or more chat messages; keeping them grouped means
// the window cut below can never split an assistant's tool_calls from the
// matching role:'tool' results (which would orphan them → provider 400s).
- const groups = (await api.messages.list(chatId))
+ const groups = scoped
.filter((m) => (m.role === 'user' || m.role === 'assistant') && m.createdAt > since)
.map(reconstructTurn)
.filter((g) => g.length > 0)
@@ -2381,6 +2599,22 @@ async function buildChatMessages(
// from its tool_result. The current user turn is always at the tail, so this
// only ever trims stale boundary turns, never real recent context.
while (flat.length && flat[0].role !== 'user') flat.shift()
+
+ // Normalize the TRAILING edge too: a hand-off starts the next member's turn
+ // with no new user message, so the transcript it sees ends on the previous
+ // member's reply. Gemini rejects that outright ("Requests ending with a model
+ // turn are not supported"), and other providers quietly treat it as a request
+ // to continue that reply rather than to respond to it. Restating the hand-off
+ // as a user-role line is what makes it a question addressed TO this member -
+ // and it is attributed, so it reads as the room talking, not as the user.
+ if (flat.length && flat[flat.length - 1].role === 'assistant') {
+ const last = flat[flat.length - 1]
+ const lastAuthor = scoped.findLast((m) => m.role === 'assistant')?.author?.name
+ flat.push({
+ role: 'user',
+ content: `[${lastAuthor ?? 'the channel'}]: ${last.content}`.trim()
+ })
+ }
return flat
}
@@ -2401,7 +2635,7 @@ async function estimateUsedTokens(
chars += Math.min((p.output ?? '').length, REPLAY_OUTPUT_CAP)
if (p.input) chars += JSON.stringify(p.input).length
} else if (p.type === 'image') images += 1
- else chars += p.text.length
+ else if (p.type === 'text' || p.type === 'reasoning') chars += p.text.length
}
return Math.ceil(chars / 4) + images * 800
}
diff --git a/src/renderer/src/locales/ar.json b/src/renderer/src/locales/ar.json
index 82f350f..ef099ac 100644
--- a/src/renderer/src/locales/ar.json
+++ b/src/renderer/src/locales/ar.json
@@ -1,4 +1,33 @@
{
+ "bots": {
+ "attach": "Attach",
+ "chatEmptyBody": "Only {{name}} answers here — use it to teach it and to ask it things directly. In a project, reach the same bot with @{{name}}.",
+ "chatEmptyNoBrief": "{{name}} has no instructions yet, so it'll answer as plain Roxy. Edit the bot to tell it how to work.",
+ "chatEmptyTitle": "This is your chat with {{name}}",
+ "chatPlaceholder": "Message {{name}}…",
+ "create": "Create bot",
+ "delete": "Delete bot",
+ "descriptionHint": "One line on what it's for. Shown to you and to the other bots it works with.",
+ "descriptionLabel": "Description",
+ "descriptionPlaceholder": "e.g. Reviews diffs before they ship",
+ "edit": "Edit bot",
+ "editTitle": "Edit {{name}}",
+ "instructionsHint": "How it should work: what to always do, what to never do, what good output looks like. This is what makes it different from Roxy.",
+ "instructionsLabel": "Instructions",
+ "instructionsPlaceholder": "You review changes for correctness and risk.\n\nAlways read the full diff before commenting. Flag missing tests and unhandled errors. Be specific: name the file and line. Don't rewrite the code yourself.",
+ "lookLabel": "Look",
+ "nameHint": "How you'll call it in a project: @Name.",
+ "nameLabel": "Name",
+ "namePlaceholder": "e.g. Reviewer",
+ "new": "New bot",
+ "newFooter": "You can change all of this later.",
+ "newShort": "New",
+ "newTitle": "New bot",
+ "openChat": "Open chat",
+ "save": "Save",
+ "savedGroup": "Your bots",
+ "subtitle": "A specialist you can chat with on its own, and @mention in any project."
+ },
"browserChrome": {
"back": "رجوع",
"closeCookies": "إغلاق ملفات تعريف الارتباط",
@@ -69,6 +98,7 @@
},
"composer": {
"attachImages": "إرفاق الصور",
+ "channelPlaceholder": "Message the channel, or @mention a member.",
"placeholder": "اسأل Roxy عن أي شيء… (الصق الصور أو أفلتها)",
"queuePlaceholder": "ضع متابعة في قائمة الانتظار…",
"queuePlaceholderStop": "ضع متابعة في قائمة الانتظار… (Esc للإيقاف)",
diff --git a/src/renderer/src/locales/de.json b/src/renderer/src/locales/de.json
index 1184c8a..3895550 100644
--- a/src/renderer/src/locales/de.json
+++ b/src/renderer/src/locales/de.json
@@ -1,4 +1,33 @@
{
+ "bots": {
+ "attach": "Attach",
+ "chatEmptyBody": "Only {{name}} answers here — use it to teach it and to ask it things directly. In a project, reach the same bot with @{{name}}.",
+ "chatEmptyNoBrief": "{{name}} has no instructions yet, so it'll answer as plain Roxy. Edit the bot to tell it how to work.",
+ "chatEmptyTitle": "This is your chat with {{name}}",
+ "chatPlaceholder": "Message {{name}}…",
+ "create": "Create bot",
+ "delete": "Delete bot",
+ "descriptionHint": "One line on what it's for. Shown to you and to the other bots it works with.",
+ "descriptionLabel": "Description",
+ "descriptionPlaceholder": "e.g. Reviews diffs before they ship",
+ "edit": "Edit bot",
+ "editTitle": "Edit {{name}}",
+ "instructionsHint": "How it should work: what to always do, what to never do, what good output looks like. This is what makes it different from Roxy.",
+ "instructionsLabel": "Instructions",
+ "instructionsPlaceholder": "You review changes for correctness and risk.\n\nAlways read the full diff before commenting. Flag missing tests and unhandled errors. Be specific: name the file and line. Don't rewrite the code yourself.",
+ "lookLabel": "Look",
+ "nameHint": "How you'll call it in a project: @Name.",
+ "nameLabel": "Name",
+ "namePlaceholder": "e.g. Reviewer",
+ "new": "New bot",
+ "newFooter": "You can change all of this later.",
+ "newShort": "New",
+ "newTitle": "New bot",
+ "openChat": "Open chat",
+ "save": "Save",
+ "savedGroup": "Your bots",
+ "subtitle": "A specialist you can chat with on its own, and @mention in any project."
+ },
"browserChrome": {
"back": "Zurück",
"closeCookies": "Cookies schließen",
@@ -69,6 +98,7 @@
},
"composer": {
"attachImages": "Bilder anhängen",
+ "channelPlaceholder": "Message the channel, or @mention a member.",
"placeholder": "Frag Roxy alles… (Bilder einfügen oder ablegen)",
"queuePlaceholder": "Eine Folgeanfrage einreihen…",
"queuePlaceholderStop": "Eine Folgeanfrage einreihen… (Esc zum Stoppen)",
diff --git a/src/renderer/src/locales/default.json b/src/renderer/src/locales/default.json
index eb3dd1d..bbbd74e 100644
--- a/src/renderer/src/locales/default.json
+++ b/src/renderer/src/locales/default.json
@@ -8,6 +8,35 @@
"cookies": "Cookies",
"closeCookies": "Close cookies"
},
+ "bots": {
+ "new": "New bot",
+ "newShort": "New",
+ "newTitle": "New bot",
+ "editTitle": "Edit {{name}}",
+ "subtitle": "A specialist you can chat with on its own, and @mention in any project.",
+ "nameLabel": "Name",
+ "nameHint": "How you'll call it in a project: @Name.",
+ "namePlaceholder": "e.g. Reviewer",
+ "descriptionLabel": "Description",
+ "descriptionHint": "One line on what it's for. Shown to you and to the other bots it works with.",
+ "descriptionPlaceholder": "e.g. Reviews diffs before they ship",
+ "lookLabel": "Look",
+ "instructionsLabel": "Instructions",
+ "instructionsHint": "How it should work: what to always do, what to never do, what good output looks like. This is what makes it different from Roxy.",
+ "instructionsPlaceholder": "You review changes for correctness and risk.\n\nAlways read the full diff before commenting. Flag missing tests and unhandled errors. Be specific: name the file and line. Don't rewrite the code yourself.",
+ "create": "Create bot",
+ "save": "Save",
+ "delete": "Delete bot",
+ "newFooter": "You can change all of this later.",
+ "chatPlaceholder": "Message {{name}}\u2026",
+ "chatEmptyTitle": "This is your chat with {{name}}",
+ "chatEmptyBody": "Only {{name}} answers here \u2014 use it to teach it and to ask it things directly. In a project, reach the same bot with @{{name}}.",
+ "chatEmptyNoBrief": "{{name}} has no instructions yet, so it'll answer as plain Roxy. Edit the bot to tell it how to work.",
+ "edit": "Edit bot",
+ "openChat": "Open chat",
+ "savedGroup": "Your bots",
+ "attach": "Attach"
+ },
"chat": {
"backTo": "Back to {{title}}",
"cancel": "cancel",
@@ -73,6 +102,7 @@
"placeholder": "Ask Roxy anything… (paste or drop images)",
"queuePlaceholder": "Queue a follow-up…",
"queuePlaceholderStop": "Queue a follow-up… (Esc to stop)",
+ "channelPlaceholder": "Message the channel, or @mention a member.",
"stop": "Stop (Esc)"
},
"configBackup": {
diff --git a/src/renderer/src/locales/es.json b/src/renderer/src/locales/es.json
index 6f58af7..3288d49 100644
--- a/src/renderer/src/locales/es.json
+++ b/src/renderer/src/locales/es.json
@@ -1,4 +1,33 @@
{
+ "bots": {
+ "attach": "Añadir",
+ "chatEmptyBody": "Aquí solo responde {{name}} — úsalo para enseñarle y para preguntarle directamente. En un proyecto, llega al mismo bot con @{{name}}.",
+ "chatEmptyNoBrief": "{{name}} todavía no tiene instrucciones, así que responderá como Roxy a secas. Edita el bot para decirle cómo trabajar.",
+ "chatEmptyTitle": "Este es tu chat con {{name}}",
+ "chatPlaceholder": "Escríbele a {{name}}…",
+ "create": "Crear bot",
+ "delete": "Eliminar bot",
+ "descriptionHint": "Una línea sobre para qué sirve. La ves tú y también los demás bots con los que trabaja.",
+ "descriptionLabel": "Descripción",
+ "descriptionPlaceholder": "p. ej. Revisa los diffs antes de publicarlos",
+ "edit": "Editar bot",
+ "editTitle": "Editar {{name}}",
+ "instructionsHint": "Cómo debe trabajar: qué hacer siempre, qué no hacer nunca, cómo se ve un buen resultado. Esto es lo que lo diferencia de Roxy.",
+ "instructionsLabel": "Instrucciones",
+ "instructionsPlaceholder": "Revisas cambios buscando errores y riesgos.\n\nLee el diff completo antes de comentar. Señala pruebas que faltan y errores sin manejar. Sé específico: nombra el archivo y la línea. No reescribas el código tú mismo.",
+ "lookLabel": "Apariencia",
+ "nameHint": "Así lo llamarás en un proyecto: @Nombre.",
+ "nameLabel": "Nombre",
+ "namePlaceholder": "p. ej. Revisor",
+ "new": "Nuevo bot",
+ "newFooter": "Puedes cambiar todo esto más adelante.",
+ "newShort": "Nuevo",
+ "newTitle": "Nuevo bot",
+ "openChat": "Abrir chat",
+ "save": "Guardar",
+ "savedGroup": "Tus bots",
+ "subtitle": "Un especialista con el que puedes chatear aparte y mencionar con @ en cualquier proyecto."
+ },
"browserChrome": {
"back": "Atrás",
"closeCookies": "Cerrar las cookies",
@@ -69,6 +98,7 @@
},
"composer": {
"attachImages": "Adjuntar imágenes",
+ "channelPlaceholder": "Message the channel, or @mention a member.",
"placeholder": "Pregúntale lo que quieras a Roxy… (pega o suelta imágenes)",
"queuePlaceholder": "Pon un mensaje de seguimiento en cola…",
"queuePlaceholderStop": "Pon un mensaje de seguimiento en cola… (Esc para detener)",
diff --git a/src/renderer/src/locales/fr.json b/src/renderer/src/locales/fr.json
index f8b9e3d..fdb6ceb 100644
--- a/src/renderer/src/locales/fr.json
+++ b/src/renderer/src/locales/fr.json
@@ -1,4 +1,33 @@
{
+ "bots": {
+ "attach": "Attach",
+ "chatEmptyBody": "Only {{name}} answers here — use it to teach it and to ask it things directly. In a project, reach the same bot with @{{name}}.",
+ "chatEmptyNoBrief": "{{name}} has no instructions yet, so it'll answer as plain Roxy. Edit the bot to tell it how to work.",
+ "chatEmptyTitle": "This is your chat with {{name}}",
+ "chatPlaceholder": "Message {{name}}…",
+ "create": "Create bot",
+ "delete": "Delete bot",
+ "descriptionHint": "One line on what it's for. Shown to you and to the other bots it works with.",
+ "descriptionLabel": "Description",
+ "descriptionPlaceholder": "e.g. Reviews diffs before they ship",
+ "edit": "Edit bot",
+ "editTitle": "Edit {{name}}",
+ "instructionsHint": "How it should work: what to always do, what to never do, what good output looks like. This is what makes it different from Roxy.",
+ "instructionsLabel": "Instructions",
+ "instructionsPlaceholder": "You review changes for correctness and risk.\n\nAlways read the full diff before commenting. Flag missing tests and unhandled errors. Be specific: name the file and line. Don't rewrite the code yourself.",
+ "lookLabel": "Look",
+ "nameHint": "How you'll call it in a project: @Name.",
+ "nameLabel": "Name",
+ "namePlaceholder": "e.g. Reviewer",
+ "new": "New bot",
+ "newFooter": "You can change all of this later.",
+ "newShort": "New",
+ "newTitle": "New bot",
+ "openChat": "Open chat",
+ "save": "Save",
+ "savedGroup": "Your bots",
+ "subtitle": "A specialist you can chat with on its own, and @mention in any project."
+ },
"browserChrome": {
"back": "Retour",
"closeCookies": "Fermer les cookies",
@@ -69,6 +98,7 @@
},
"composer": {
"attachImages": "Joindre des images",
+ "channelPlaceholder": "Message the channel, or @mention a member.",
"placeholder": "Demandez ce que vous voulez à Roxy… (collez ou déposez des images)",
"queuePlaceholder": "Mettre un message de suivi en file d’attente…",
"queuePlaceholderStop": "Mettre un message de suivi en file d’attente… (Échap pour arrêter)",
diff --git a/src/renderer/src/locales/hi.json b/src/renderer/src/locales/hi.json
index 4f4b7b8..4e836c9 100644
--- a/src/renderer/src/locales/hi.json
+++ b/src/renderer/src/locales/hi.json
@@ -1,4 +1,33 @@
{
+ "bots": {
+ "attach": "Attach",
+ "chatEmptyBody": "Only {{name}} answers here — use it to teach it and to ask it things directly. In a project, reach the same bot with @{{name}}.",
+ "chatEmptyNoBrief": "{{name}} has no instructions yet, so it'll answer as plain Roxy. Edit the bot to tell it how to work.",
+ "chatEmptyTitle": "This is your chat with {{name}}",
+ "chatPlaceholder": "Message {{name}}…",
+ "create": "Create bot",
+ "delete": "Delete bot",
+ "descriptionHint": "One line on what it's for. Shown to you and to the other bots it works with.",
+ "descriptionLabel": "Description",
+ "descriptionPlaceholder": "e.g. Reviews diffs before they ship",
+ "edit": "Edit bot",
+ "editTitle": "Edit {{name}}",
+ "instructionsHint": "How it should work: what to always do, what to never do, what good output looks like. This is what makes it different from Roxy.",
+ "instructionsLabel": "Instructions",
+ "instructionsPlaceholder": "You review changes for correctness and risk.\n\nAlways read the full diff before commenting. Flag missing tests and unhandled errors. Be specific: name the file and line. Don't rewrite the code yourself.",
+ "lookLabel": "Look",
+ "nameHint": "How you'll call it in a project: @Name.",
+ "nameLabel": "Name",
+ "namePlaceholder": "e.g. Reviewer",
+ "new": "New bot",
+ "newFooter": "You can change all of this later.",
+ "newShort": "New",
+ "newTitle": "New bot",
+ "openChat": "Open chat",
+ "save": "Save",
+ "savedGroup": "Your bots",
+ "subtitle": "A specialist you can chat with on its own, and @mention in any project."
+ },
"browserChrome": {
"back": "पीछे",
"closeCookies": "कुकीज़ बंद करें",
@@ -69,6 +98,7 @@
},
"composer": {
"attachImages": "छवियाँ संलग्न करें",
+ "channelPlaceholder": "Message the channel, or @mention a member.",
"placeholder": "Roxy से कुछ भी पूछें… (चित्र चिपकाएँ या यहाँ छोड़ें)",
"queuePlaceholder": "अगला संदेश कतार में जोड़ें…",
"queuePlaceholderStop": "अगला संदेश कतार में जोड़ें… (रोकने के लिए Esc)",
diff --git a/src/renderer/src/locales/ja.json b/src/renderer/src/locales/ja.json
index 4566e60..041f647 100644
--- a/src/renderer/src/locales/ja.json
+++ b/src/renderer/src/locales/ja.json
@@ -1,4 +1,33 @@
{
+ "bots": {
+ "attach": "Attach",
+ "chatEmptyBody": "Only {{name}} answers here — use it to teach it and to ask it things directly. In a project, reach the same bot with @{{name}}.",
+ "chatEmptyNoBrief": "{{name}} has no instructions yet, so it'll answer as plain Roxy. Edit the bot to tell it how to work.",
+ "chatEmptyTitle": "This is your chat with {{name}}",
+ "chatPlaceholder": "Message {{name}}…",
+ "create": "Create bot",
+ "delete": "Delete bot",
+ "descriptionHint": "One line on what it's for. Shown to you and to the other bots it works with.",
+ "descriptionLabel": "Description",
+ "descriptionPlaceholder": "e.g. Reviews diffs before they ship",
+ "edit": "Edit bot",
+ "editTitle": "Edit {{name}}",
+ "instructionsHint": "How it should work: what to always do, what to never do, what good output looks like. This is what makes it different from Roxy.",
+ "instructionsLabel": "Instructions",
+ "instructionsPlaceholder": "You review changes for correctness and risk.\n\nAlways read the full diff before commenting. Flag missing tests and unhandled errors. Be specific: name the file and line. Don't rewrite the code yourself.",
+ "lookLabel": "Look",
+ "nameHint": "How you'll call it in a project: @Name.",
+ "nameLabel": "Name",
+ "namePlaceholder": "e.g. Reviewer",
+ "new": "New bot",
+ "newFooter": "You can change all of this later.",
+ "newShort": "New",
+ "newTitle": "New bot",
+ "openChat": "Open chat",
+ "save": "Save",
+ "savedGroup": "Your bots",
+ "subtitle": "A specialist you can chat with on its own, and @mention in any project."
+ },
"browserChrome": {
"back": "戻る",
"closeCookies": "Cookieを閉じる",
@@ -69,6 +98,7 @@
},
"composer": {
"attachImages": "画像を添付",
+ "channelPlaceholder": "Message the channel, or @mention a member.",
"placeholder": "Roxy に何でも質問…(画像を貼り付けるかドロップ)",
"queuePlaceholder": "次のメッセージをキューに追加…",
"queuePlaceholderStop": "次のメッセージをキューに追加…(Esc で停止)",
diff --git a/src/renderer/src/locales/pt.json b/src/renderer/src/locales/pt.json
index aa867a7..216487d 100644
--- a/src/renderer/src/locales/pt.json
+++ b/src/renderer/src/locales/pt.json
@@ -1,4 +1,33 @@
{
+ "bots": {
+ "attach": "Attach",
+ "chatEmptyBody": "Only {{name}} answers here — use it to teach it and to ask it things directly. In a project, reach the same bot with @{{name}}.",
+ "chatEmptyNoBrief": "{{name}} has no instructions yet, so it'll answer as plain Roxy. Edit the bot to tell it how to work.",
+ "chatEmptyTitle": "This is your chat with {{name}}",
+ "chatPlaceholder": "Message {{name}}…",
+ "create": "Create bot",
+ "delete": "Delete bot",
+ "descriptionHint": "One line on what it's for. Shown to you and to the other bots it works with.",
+ "descriptionLabel": "Description",
+ "descriptionPlaceholder": "e.g. Reviews diffs before they ship",
+ "edit": "Edit bot",
+ "editTitle": "Edit {{name}}",
+ "instructionsHint": "How it should work: what to always do, what to never do, what good output looks like. This is what makes it different from Roxy.",
+ "instructionsLabel": "Instructions",
+ "instructionsPlaceholder": "You review changes for correctness and risk.\n\nAlways read the full diff before commenting. Flag missing tests and unhandled errors. Be specific: name the file and line. Don't rewrite the code yourself.",
+ "lookLabel": "Look",
+ "nameHint": "How you'll call it in a project: @Name.",
+ "nameLabel": "Name",
+ "namePlaceholder": "e.g. Reviewer",
+ "new": "New bot",
+ "newFooter": "You can change all of this later.",
+ "newShort": "New",
+ "newTitle": "New bot",
+ "openChat": "Open chat",
+ "save": "Save",
+ "savedGroup": "Your bots",
+ "subtitle": "A specialist you can chat with on its own, and @mention in any project."
+ },
"browserChrome": {
"back": "Voltar",
"closeCookies": "Fechar cookies",
@@ -69,6 +98,7 @@
},
"composer": {
"attachImages": "Anexar imagens",
+ "channelPlaceholder": "Message the channel, or @mention a member.",
"placeholder": "Pergunte qualquer coisa à Roxy… (cole ou solte imagens)",
"queuePlaceholder": "Adicione uma continuação à fila…",
"queuePlaceholderStop": "Adicione uma continuação à fila… (Esc para parar)",
diff --git a/src/renderer/src/locales/ru.json b/src/renderer/src/locales/ru.json
index 4f9722a..7b4aa1e 100644
--- a/src/renderer/src/locales/ru.json
+++ b/src/renderer/src/locales/ru.json
@@ -1,4 +1,33 @@
{
+ "bots": {
+ "attach": "Attach",
+ "chatEmptyBody": "Only {{name}} answers here — use it to teach it and to ask it things directly. In a project, reach the same bot with @{{name}}.",
+ "chatEmptyNoBrief": "{{name}} has no instructions yet, so it'll answer as plain Roxy. Edit the bot to tell it how to work.",
+ "chatEmptyTitle": "This is your chat with {{name}}",
+ "chatPlaceholder": "Message {{name}}…",
+ "create": "Create bot",
+ "delete": "Delete bot",
+ "descriptionHint": "One line on what it's for. Shown to you and to the other bots it works with.",
+ "descriptionLabel": "Description",
+ "descriptionPlaceholder": "e.g. Reviews diffs before they ship",
+ "edit": "Edit bot",
+ "editTitle": "Edit {{name}}",
+ "instructionsHint": "How it should work: what to always do, what to never do, what good output looks like. This is what makes it different from Roxy.",
+ "instructionsLabel": "Instructions",
+ "instructionsPlaceholder": "You review changes for correctness and risk.\n\nAlways read the full diff before commenting. Flag missing tests and unhandled errors. Be specific: name the file and line. Don't rewrite the code yourself.",
+ "lookLabel": "Look",
+ "nameHint": "How you'll call it in a project: @Name.",
+ "nameLabel": "Name",
+ "namePlaceholder": "e.g. Reviewer",
+ "new": "New bot",
+ "newFooter": "You can change all of this later.",
+ "newShort": "New",
+ "newTitle": "New bot",
+ "openChat": "Open chat",
+ "save": "Save",
+ "savedGroup": "Your bots",
+ "subtitle": "A specialist you can chat with on its own, and @mention in any project."
+ },
"browserChrome": {
"back": "Назад",
"closeCookies": "Закрыть куки",
@@ -69,6 +98,7 @@
},
"composer": {
"attachImages": "Прикрепить изображения",
+ "channelPlaceholder": "Message the channel, or @mention a member.",
"placeholder": "Спросите Roxy о чём угодно… (вставьте или перетащите изображения)",
"queuePlaceholder": "Добавить следующий запрос в очередь…",
"queuePlaceholderStop": "Добавить следующий запрос в очередь… (Esc — остановить)",
diff --git a/src/renderer/src/locales/zh.json b/src/renderer/src/locales/zh.json
index d766805..fb456cb 100644
--- a/src/renderer/src/locales/zh.json
+++ b/src/renderer/src/locales/zh.json
@@ -1,4 +1,33 @@
{
+ "bots": {
+ "attach": "Attach",
+ "chatEmptyBody": "Only {{name}} answers here — use it to teach it and to ask it things directly. In a project, reach the same bot with @{{name}}.",
+ "chatEmptyNoBrief": "{{name}} has no instructions yet, so it'll answer as plain Roxy. Edit the bot to tell it how to work.",
+ "chatEmptyTitle": "This is your chat with {{name}}",
+ "chatPlaceholder": "Message {{name}}…",
+ "create": "Create bot",
+ "delete": "Delete bot",
+ "descriptionHint": "One line on what it's for. Shown to you and to the other bots it works with.",
+ "descriptionLabel": "Description",
+ "descriptionPlaceholder": "e.g. Reviews diffs before they ship",
+ "edit": "Edit bot",
+ "editTitle": "Edit {{name}}",
+ "instructionsHint": "How it should work: what to always do, what to never do, what good output looks like. This is what makes it different from Roxy.",
+ "instructionsLabel": "Instructions",
+ "instructionsPlaceholder": "You review changes for correctness and risk.\n\nAlways read the full diff before commenting. Flag missing tests and unhandled errors. Be specific: name the file and line. Don't rewrite the code yourself.",
+ "lookLabel": "Look",
+ "nameHint": "How you'll call it in a project: @Name.",
+ "nameLabel": "Name",
+ "namePlaceholder": "e.g. Reviewer",
+ "new": "New bot",
+ "newFooter": "You can change all of this later.",
+ "newShort": "New",
+ "newTitle": "New bot",
+ "openChat": "Open chat",
+ "save": "Save",
+ "savedGroup": "Your bots",
+ "subtitle": "A specialist you can chat with on its own, and @mention in any project."
+ },
"browserChrome": {
"back": "后退",
"closeCookies": "关闭 Cookie",
@@ -69,6 +98,7 @@
},
"composer": {
"attachImages": "附加图片",
+ "channelPlaceholder": "Message the channel, or @mention a member.",
"placeholder": "问 Roxy 任何问题…(粘贴或拖放图片)",
"queuePlaceholder": "将后续消息加入队列…",
"queuePlaceholderStop": "将后续消息加入队列…(按 Esc 停止)",
diff --git a/src/shared/api.ts b/src/shared/api.ts
index 9a4004e..0baac5f 100644
--- a/src/shared/api.ts
+++ b/src/shared/api.ts
@@ -8,7 +8,10 @@ import type {
AppSettings,
AppVersions,
ActivityStats,
+ Bot,
Chat,
+ CreateBotInput,
+ UpdateBotInput,
ConnectedProvider,
ConnectProviderInput,
DeviceFlowStart,
@@ -20,6 +23,7 @@ import type {
QueueItem,
ReasoningEffort,
SessionKind,
+ SetChannelMembersInput,
ToolDiff,
ToolResult,
UsageStats,
@@ -371,6 +375,11 @@ export interface LlmStartInput {
messages: ChatMessage[]
/** Which primary agent to run (e.g. "build" or "plan"). Defaults to build. */
agentId?: string
+ /**
+ * The channel block for the answering member - who else is in the room, plus
+ * that member's own brief - appended to the base system prompt. Absent in a solo channel.
+ */
+ memberPrompt?: string
/** Thinking effort for reasoning-capable models. */
reasoningEffort?: ReasoningEffort
/** Whether the model supports reasoning (gates sending the effort param). */
@@ -763,6 +772,32 @@ export interface RoxyApi {
/** Persist the project order; `paths` is the full list, top → bottom. */
reorder(paths: string[]): Promise
}
+ channel: {
+ /**
+ * Replace a session's attached bots. Roxy is the host and is filtered out
+ * on the way in, so she can never be detached by a stale renderer list.
+ * Returns the updated session row.
+ */
+ setMembers(input: SetChannelMembersInput): Promise
+ }
+ /**
+ * The saved-bot library: bots the user owns, each with its own chat.
+ *
+ * Distinct from `channel.setMembers`, which edits who is in ONE session. A
+ * bot here is the reusable original — attaching it to a channel copies its
+ * identity in, and every call below returns the refreshed list so the
+ * carousel updates in one round trip.
+ */
+ bots: {
+ list(): Promise
+ /** Create a bot and the chat it lives in; returns the bot (with `chatId`). */
+ create(input: CreateBotInput): Promise
+ update(id: string, patch: UpdateBotInput): Promise
+ /** Delete a bot and its own conversation. Sessions it joined keep their copy. */
+ remove(id: string): Promise
+ /** Persist the carousel order; `ids` is the full list, first to last. */
+ reorder(ids: string[]): Promise
+ }
messages: {
list(chatId: string): Promise
add(input: AddMessageInput): Promise
diff --git a/src/shared/channel-members.ts b/src/shared/channel-members.ts
new file mode 100644
index 0000000..34249e3
--- /dev/null
+++ b/src/shared/channel-members.ts
@@ -0,0 +1,368 @@
+/**
+ * Channel members — the bots that sit in one session's conversation.
+ *
+ * A session is a CHANNEL, not a one-on-one chat. Roxy is always in it (see
+ * `ROXY_HOST`) and specialists are attached alongside her, WhatsApp-style, at
+ * any point in the conversation. Addressing is by `@Name`; an unaddressed
+ * message goes to Roxy, so the channel can never end up with nobody listening.
+ */
+import type { Bot, BotMember, Message } from './types'
+
+/** Id of the built-in host. Reserved — a user-added bot can never claim it. */
+export const ROXY_HOST_ID = 'roxy'
+
+/**
+ * The host. Present in every channel, cannot be removed, and answers anything
+ * that isn't addressed to somebody else.
+ *
+ * This is the identity the app itself has, which is why it carries no
+ * `systemPrompt`: Roxy's instructions are the base system prompt the harness
+ * already assembles (see main/harness/agent.ts). A specialist's prompt is
+ * appended to that base, so every bot is Roxy-with-a-brief rather than a blank
+ * model that has to be told who it is first.
+ */
+export const ROXY_HOST: BotMember = {
+ id: ROXY_HOST_ID,
+ name: 'Roxy',
+ role: 'Host · full workspace access',
+ icon: 'roxy',
+ color: 'accent',
+ builtIn: true
+}
+
+/**
+ * Specialists offered when attaching a member. Not defaults: a new channel has
+ * only Roxy in it, and these are picked from a list the way you pick a contact.
+ *
+ * Each prompt ends by naming who to hand back to, because that hand-off is what
+ * makes the channel a conversation instead of a queue of monologues.
+ */
+export const SUGGESTED_MEMBERS: BotMember[] = [
+ {
+ id: 'builder',
+ name: 'Builder',
+ role: 'Implementation',
+ icon: 'builder',
+ color: 'blue',
+ systemPrompt:
+ 'You are Builder, this channel\'s implementer. You write and change code, fix bugs, and run tests. Scope discipline: implement exactly what was asked, nothing more. When your change is done, state what you changed as file:line and hand off with "@Reviewer ready for review" if a Reviewer is in the channel; otherwise hand back to @Roxy.'
+ },
+ {
+ id: 'reviewer',
+ name: 'Reviewer',
+ role: 'Code review',
+ icon: 'reviewer',
+ color: 'purple',
+ systemPrompt:
+ 'You are Reviewer, this channel\'s critical code reviewer. Read the actual diff before commenting — never review code you have not read. Every finding cites file:line and proposes the concrete fix in a code block. Separate blocking bugs from nits, and say which is which. If it is clean, approve it plainly. If it is not, hand back with "@Builder" and the specific list.'
+ },
+ {
+ id: 'security',
+ name: 'Security',
+ role: 'Audit & secrets',
+ icon: 'security',
+ color: 'emerald',
+ systemPrompt:
+ "You are Security, this channel's auditor. You look for injection, XSS, path traversal, leaked secrets and keys, unsafe permissions, and missing validation at system boundaries. Report only findings you can point at with file:line, ranked by real exploitability — no speculative checklists. Hand fixes to @Builder."
+ },
+ {
+ id: 'architect',
+ name: 'Architect',
+ role: 'Design & tradeoffs',
+ icon: 'architect',
+ color: 'amber',
+ systemPrompt:
+ "You are Architect, this channel's designer. You map how a change fits the existing codebase, name the tradeoffs, and pick one option with a reason. Read the relevant code before proposing structure. Prefer the smallest design that solves the actual problem; say plainly when a simpler approach beats the one being discussed. Hand the chosen plan to @Builder."
+ },
+ {
+ id: 'tester',
+ name: 'Tester',
+ role: 'Tests & repro',
+ icon: 'tester',
+ color: 'cyan',
+ systemPrompt:
+ "You are Tester, this channel's verifier. You reproduce bugs before anyone fixes them, write tests that fail for the stated reason, and run the suite. Report the exact command and its real output — never claim a test passed without running it. Hand failures to @Builder with the failing case."
+ }
+]
+
+/** Avatar/accent pairs a saved bot can wear. Keys must exist in BotAvatar. */
+export const BOT_LOOKS = [
+ { icon: 'builder', color: 'blue' },
+ { icon: 'reviewer', color: 'purple' },
+ { icon: 'security', color: 'emerald' },
+ { icon: 'architect', color: 'amber' },
+ { icon: 'tester', color: 'cyan' }
+] as const
+
+/**
+ * A bot's id, derived from its name.
+ *
+ * Slugged so `@Name` addressing and the id agree, and suffixed rather than
+ * rejected on collision — two bots called "QA" is the user's call, but two bots
+ * with one id would make every roster ambiguous. The host id can never be
+ * claimed: Roxy is in every channel and a second `roxy` would shadow her.
+ */
+export function botId(name: string, taken: Iterable = []): string {
+ const base =
+ name
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-|-$/g, '') || 'bot'
+ const used = new Set(taken)
+ used.add(ROXY_HOST_ID)
+ if (!used.has(base)) return base
+ for (let n = 2; ; n++) {
+ const candidate = `${base}-${n}`
+ if (!used.has(candidate)) return candidate
+ }
+}
+
+/**
+ * The channel identity of a SAVED bot.
+ *
+ * The single conversion between the two shapes, so a bot attached to a project
+ * channel is the same bot as the one in its own chat — same id, same brief,
+ * same face. Duplicating this mapping per call site is how a bot ends up
+ * answering to `@Name` with a preset's prompt instead of its own.
+ */
+export function botMember(bot: Bot): BotMember {
+ return {
+ id: bot.id,
+ name: bot.name,
+ role: bot.description || 'Specialist',
+ icon: bot.icon,
+ color: bot.color,
+ systemPrompt: bot.instructions.trim() || undefined
+ }
+}
+
+/** Every channel starts with just the host in it. */
+export function defaultMembers(): BotMember[] {
+ return [ROXY_HOST]
+}
+
+/**
+ * Normalize a stored member list: host first, always present, deduped by id.
+ *
+ * Called on every read rather than trusting what was persisted, so a list
+ * written by an older build (or hand-edited) can't leave a channel hostless.
+ */
+export function withHost(members: BotMember[] | null | undefined): BotMember[] {
+ const rest: BotMember[] = []
+ const seen = new Set([ROXY_HOST_ID])
+ for (const m of members ?? []) {
+ if (m.id === ROXY_HOST_ID) continue
+ if (seen.has(m.id)) continue
+ seen.add(m.id)
+ rest.push(m)
+ }
+ return [ROXY_HOST, ...rest]
+}
+
+/** Normalize a name for matching: strip a leading `@`, casefold, trim. */
+function norm(name: string): string {
+ return name.trim().replace(/^@/, '').toLowerCase()
+}
+
+/**
+ * Members addressed by `@Name` in `text`, in the order they appear.
+ *
+ * Order is the mention's position in the text, not the member list's, because
+ * "@Builder then @Reviewer" means Builder goes first. Matching is on a word
+ * boundary so `@Builder,` and `@Builder.` both land, while an email address or
+ * a `user@example.com` in a log line does not.
+ */
+export function parseBotMentions(text: string, members: BotMember[]): BotMember[] {
+ if (!text) return []
+ const byName = new Map(members.map((m) => [norm(m.name), m]))
+ const found: BotMember[] = []
+ const seen = new Set()
+ // Require the `@` to start a word, so `user@example.com` isn't a mention.
+ for (const match of text.matchAll(/(?:^|[^\w@])@([\w-]+)/g)) {
+ const member = byName.get(norm(match[1]))
+ if (member && !seen.has(member.id)) {
+ seen.add(member.id)
+ found.push(member)
+ }
+ }
+ return found
+}
+
+/** Find a member by `@Name`, plain name, or id. */
+export function findMember(name: string, members: BotMember[]): BotMember | undefined {
+ const target = norm(name)
+ return members.find((m) => norm(m.name) === target || m.id.toLowerCase() === target)
+}
+
+/**
+ * Who should answer `text` — the first member it addresses, or the host.
+ *
+ * The host fallback is what makes an unaddressed message always land somewhere.
+ * Grok Bot's equivalent has no host, so a message with no mention in it reaches
+ * nobody and the user has to name a bot before any work can start.
+ */
+/**
+ * Who answers in a BOT chat — always that bot, whatever the message says.
+ *
+ * A bot's own chat is a one-on-one, not a channel: it has exactly one attached
+ * member and the host is present only as a technicality. Routing it through
+ * `resolveRecipient` would hand every unaddressed message to Roxy, so talking
+ * to your bot would silently be talking to Roxy — the one thing this chat
+ * exists NOT to do. Falls back to the host if the row somehow has no member,
+ * because a chat with nobody listening is worse than the wrong greeter.
+ */
+export function soloSpeaker(members: BotMember[]): BotMember {
+ const roster = withHost(members)
+ return roster.find((m) => m.id !== ROXY_HOST_ID) ?? roster[0]
+}
+
+export function resolveRecipient(text: string, members: BotMember[]): BotMember {
+ const host = withHost(members)[0]
+ // Addressed means the message OPENS with the mention ("@Reviewer look at
+ // this"). A mention later in the sentence is the user talking ABOUT a member,
+ // not to them: "create the PR and then call @Reviewer" is work for the host
+ // that ENDS in a hand-off, and routing it to Reviewer instead made Reviewer
+ // do the whole job - the opposite of what was asked. So a non-leading mention
+ // leaves the turn with the host, who works and then hands off by @mentioning
+ // them (see resolveHandoff).
+ const leading = text.trimStart().match(/^@([\w-]+)/)
+ if (!leading) return host
+ return findMember(leading[1], withHost(members)) ?? host
+}
+
+/**
+ * The channel block for the system prompt: who is in the room, who is speaking,
+ * and how to reach the others.
+ *
+ * Without this the model has no idea the channel exists. Asked to "call
+ * @Reviewer" it would search GitHub for a user by that name, find nothing, and
+ * fall back to spawning a `task` subagent - precisely the wrong shape. A
+ * subagent starts blank, inherits the caller's misreading of the job, reports
+ * only back into the caller's own context, and never appears in the channel as
+ * a peer. Naming the roster in the prompt is what turns "call @Reviewer" into a
+ * hand-off to a real member with its own brief and its own scoped transcript.
+ *
+ * The member's brief is framed as a standing ROLE rather than pasted in raw,
+ * because a model handed a bare job description treats it as a work order and
+ * executes it the moment it is addressed - see the note on the brief below.
+ *
+ * Returns undefined for a briefless solo channel, where there is nothing to say
+ * that the base prompt doesn't already cover.
+ */
+export function channelPrompt(
+ members: BotMember[],
+ speaker: BotMember,
+ /**
+ * Treat this as a PRIVATE one-on-one: nobody else is in the room, whatever
+ * the member list says.
+ *
+ * Set for a bot's own chat. The host is in every membership list as a
+ * technicality (`withHost`), so without this a bot alone with the user would
+ * be told Roxy is standing there and offered the hand-off protocol - and it
+ * uses it: a question it judged out of scope came back as "@Roxy can you take
+ * this", to nobody, in a chat with no host turn to follow. A private chat has
+ * one participant and no exits.
+ */
+ solo = false
+): string | undefined {
+ const roster = withHost(members)
+ const others = solo ? [] : roster.filter((m) => m.id !== speaker.id)
+ const brief = speaker.systemPrompt?.trim()
+ if (others.length === 0 && !brief) return undefined
+ const lines = [
+ '',
+ `This session is a CHANNEL, not a one-on-one chat. You are @${speaker.name}${speaker.role ? ` - ${speaker.role}` : ''}.`
+ ]
+ if (others.length) {
+ lines.push(
+ '',
+ 'Who else is here:',
+ ...others.map((m) => ` @${m.name} - ${m.role}`),
+ '',
+ 'To bring another member in, END your reply by @mentioning them with what you need ("@Reviewer the PR is up, please review it"). That hands the turn over: they answer next, in this channel, as themselves.',
+ `Do NOT use the \`task\` tool to reach a member. A subagent is a blank child of YOUR context that inherits your mistakes and reports only back to you, whereas @${others[0].name} is a peer with their own brief and their own view of this conversation. Members are also not GitHub users - never look one up, or assign it an issue or a PR.`,
+ 'Only @mention a member when you want them to act now; a reply that mentions nobody returns the turn to the user.'
+ )
+ }
+ if (brief) {
+ // The brief describes WHO the member is - a standing role, not a work
+ // order. Without this the model reads its own job description as the task
+ // and starts executing it on contact: "@Reviewer hi" made a review bot
+ // clone repos and diff branches instead of saying hello. The newest
+ // message in the conversation is what decides what to do, and the reply
+ // has to be sized to it.
+ lines.push(
+ '',
+ 'Your role below is your standing identity, NOT an instruction to carry out right now. What to do is decided by the latest message in this channel; match your reply to it. Being greeted or asked a question means answering as yourself - conversationally, with no tools - and waiting. Only start the work your role describes when someone actually asks for it.',
+ '',
+ 'Your role:',
+ brief
+ )
+ }
+ lines.push('')
+ return lines.join('\n')
+}
+
+/**
+ * Who a bot handed off to, given what it just said.
+ *
+ * `speaker` is excluded so a bot that refers to itself in the third person
+ * ("@Reviewer already checked this") doesn't hand the turn back to itself and
+ * spin. Returns undefined when the reply addresses nobody, which ends the
+ * relay chain and returns control to the user.
+ */
+export function resolveHandoff(
+ reply: string,
+ speaker: BotMember,
+ members: BotMember[]
+): BotMember | undefined {
+ return parseBotMentions(reply, members).find((m) => m.id !== speaker.id)
+}
+
+/**
+ * How many bot-to-bot hand-offs may fire from one user message.
+ *
+ * A chain costs a full model turn per hop and each hop is invisible work the
+ * user didn't ask for, so it is capped rather than trusted to terminate. Four
+ * covers the shapes people actually use (Roxy → Builder → Reviewer → Builder →
+ * Reviewer); anything longer wants a human in it.
+ */
+export const MAX_HANDOFF_HOPS = 4
+
+/**
+ * The transcript ONE member should see, from the channel's full history.
+ *
+ * This is the point of having separate bots at all. A single agent carrying a
+ * whole project's context degrades: it re-reads its own earlier conclusions as
+ * fresh evidence, and unrelated work crowds the window. So each specialist gets
+ * a deliberately narrow view:
+ *
+ * - every user message — the user is talking to the room
+ * - its own past turns — its memory of what it already did
+ * - turns that ADDRESS it by name — the hand-off it is answering
+ * - system/join notices — who else is in the room
+ *
+ * What it does NOT see is unrelated cross-talk between other members. Reviewer
+ * reviewing a diff has no use for Security's audit of a different file, and
+ * including it measurably makes the review worse.
+ *
+ * The HOST is exempt: Roxy is the one identity that must follow the whole
+ * conversation, since she's who the user talks to when they don't name anyone
+ * and who has to make sense of the room's state.
+ */
+export function visibleMessages(messages: Message[], member: BotMember): Message[] {
+ if (member.id === ROXY_HOST_ID) return messages
+ const mention = new RegExp(`(?:^|[^\\w@])@${escapeRegex(member.name)}\\b`, 'i')
+ return messages.filter((m) => {
+ if (m.role !== 'assistant') return true
+ // Legacy rows predate authorship and could have come from anyone; showing
+ // them is the safe direction (they are this session's own history).
+ if (!m.author) return true
+ if (norm(m.author.name) === norm(member.name)) return true
+ return mention.test(m.content)
+ })
+}
+
+function escapeRegex(s: string): string {
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+}
diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts
index 5bec2a4..278c75b 100644
--- a/src/shared/ipc.ts
+++ b/src/shared/ipc.ts
@@ -46,6 +46,16 @@ export const CHANNELS = {
messagesList: 'messages:list',
messagesAdd: 'messages:add',
+ /** Attach/detach the bots in a session's channel. */
+ channelSetMembers: 'channel:setMembers',
+
+ /** The saved-bot library — bots that outlive a session and own their own chat. */
+ botsList: 'bots:list',
+ botsCreate: 'bots:create',
+ botsUpdate: 'bots:update',
+ botsRemove: 'bots:remove',
+ botsReorder: 'bots:reorder',
+
integrationsList: 'integrations:list',
integrationsSetEnabled: 'integrations:setEnabled',
diff --git a/src/shared/types.ts b/src/shared/types.ts
index 7937c5d..2abe7f7 100644
--- a/src/shared/types.ts
+++ b/src/shared/types.ts
@@ -93,9 +93,10 @@ export interface DeviceFlowStart {
/**
* Every chat row is a session. Main sessions are the ones a user opens against a
* workspace; sub sessions are spawned by the harness (e.g. the `task` tool);
- * loop sessions are driven by a scheduled Loop.
+ * loop sessions are driven by a scheduled Loop; `bot` sessions are the private
+ * one-on-one chat a saved Bot owns (see `Bot.chatId`).
*/
-export type SessionKind = 'main' | 'sub' | 'loop'
+export type SessionKind = 'main' | 'sub' | 'loop' | 'bot'
/** A single item in a session's agent-maintained task checklist. */
export interface SessionTask {
@@ -184,6 +185,14 @@ export interface Chat {
description: string | null
/** Agent-maintained task checklist for this session. */
tasks: SessionTask[]
+ /**
+ * The specialist bots ATTACHED to this session's channel, in the order they
+ * were added. Roxy (the host) is always present and is NOT in this list -
+ * read the full membership through `withHost` in shared/channel-members.ts.
+ * Empty means a plain single-agent session, which is what every session
+ * created before channels existed is.
+ */
+ channelMembers: BotMember[]
/** User-defined sort key within its project (higher = higher in the list). */
sortOrder: number
createdAt: number
@@ -192,6 +201,101 @@ export interface Chat {
export type MessageRole = 'user' | 'assistant' | 'system'
+/**
+ * Which channel member wrote an assistant message.
+ *
+ * Denormalized onto the message rather than joined from the member list,
+ * because a member can be detached from the channel later and the transcript
+ * must still show who said what. Absent on messages written before channels
+ * existed, and on turns from a single-agent session.
+ */
+export interface BotAuthor {
+ name: string
+ role?: string
+ icon?: string
+ color?: string
+}
+
+/**
+ * A bot sitting in one session's channel.
+ *
+ * `systemPrompt` is APPENDED to Roxy's base system prompt, never a replacement
+ * for it — a member is Roxy with a brief, so it inherits the workspace, the
+ * tools, and the house rules without being told who it is first.
+ */
+export interface BotMember {
+ id: string
+ name: string
+ role: string
+ /** Avatar key resolved by the renderer's icon map (see BotAvatar). */
+ icon?: string
+ /** Accent key used for the member's name/badge color. */
+ color?: string
+ /** Specialty instructions, appended to the base prompt. Absent on the host. */
+ systemPrompt?: string
+ /** Pinned model for this member. Null/absent = the session's model. */
+ model?: string
+ /** True for the built-in host (Roxy), which cannot be edited or detached. */
+ builtIn?: boolean
+}
+
+/**
+ * A bot the user SAVED — a named specialist that outlives any one session.
+ *
+ * Two things hang off one row, and that is the point:
+ *
+ * - its own CHAT (`chatId`), a private one-on-one where the bot is the only
+ * one answering. This is what Grok's bots do, and it is where you TEACH one:
+ * talk to it directly, correct it, let it accumulate its own history.
+ * - its `member` identity, offered to every project channel so `@Name` reaches
+ * the same bot you have been training rather than a fresh copy of a preset.
+ *
+ * So the dedicated chat does not compete with `@mention`ing it inside a
+ * project — they are two doors onto the same bot. Editing the bot here changes
+ * the brief every channel it sits in will use from its next turn.
+ */
+export interface Bot {
+ /** Slug derived from the name at create time, and stable after a rename. */
+ id: string
+ name: string
+ /**
+ * The one-line role shown beside the name — in the roster, in the `@` menu,
+ * and in the channel prompt other bots read. NOT the brief; see `instructions`.
+ */
+ description: string
+ /** Avatar key resolved by the renderer's icon map (see BotAvatar). */
+ icon: string
+ /** Accent key used for the avatar/name color (see BotAvatar's ACCENTS). */
+ color: string
+ /**
+ * The bot's full brief, appended to Roxy's base system prompt exactly as
+ * `BotMember.systemPrompt` is — a bot is Roxy with a specialty, never a blank
+ * model that has to be told who it is first.
+ */
+ instructions: string
+ /**
+ * The session holding this bot's own conversation. Created with the bot and
+ * deleted with it, so a bot always has somewhere to be talked to.
+ */
+ chatId: string
+ /** Display order in the carousel (higher = first, newest-first by default). */
+ sortOrder: number
+ createdAt: number
+ updatedAt: number
+}
+
+/** Fields a new bot is created from. Everything else is derived or defaulted. */
+export interface CreateBotInput {
+ name: string
+ description?: string
+ icon?: string
+ color?: string
+ instructions?: string
+}
+
+/** Any subset of a bot's editable fields. */
+export type UpdateBotInput = Partial> & { name?: string }
+
/**
* One ordered piece of a turn. An assistant turn is a sequence of these, so
* reasoning, tool calls, and prose interleave in the order they happened
@@ -259,6 +363,20 @@ export type MessagePart =
*/
children?: MessagePart[]
}
+ | {
+ /**
+ * A channel notice: who joined or left, or a bot-to-bot hand-off. Carried
+ * as a part (not a bare text message) so the transcript renders it as a
+ * divider instead of prose, and so `visibleMessages` can keep it in every
+ * member's view regardless of who it names.
+ */
+ type: 'notice'
+ kind: 'join' | 'leave' | 'handoff'
+ /** The member the notice is about; the speaker, for a hand-off. */
+ member: string
+ /** For a hand-off: who the turn was passed to. */
+ to?: string
+ }
export interface Message {
id: string
@@ -268,6 +386,7 @@ export interface Message {
/** Ordered parts for rich rendering; falls back to a single text part. */
parts: MessagePart[]
createdAt: number
+ author?: BotAuthor
}
export interface AddMessageInput {
@@ -275,6 +394,13 @@ export interface AddMessageInput {
role: MessageRole
content: string
parts?: MessagePart[]
+ author?: BotAuthor
+}
+
+/** Replace a session's channel membership. The host is implicit and not stored. */
+export interface SetChannelMembersInput {
+ chatId: string
+ members: BotMember[]
}
// ---- Loops (scheduled agentic prompts) ---------------------------------------
diff --git a/test/shared.ts b/test/shared.ts
index d1ffca1..b5fd05a 100644
--- a/test/shared.ts
+++ b/test/shared.ts
@@ -9,6 +9,23 @@ import {
TOOL_CATEGORIES,
isInterruptibleTool
} from '../src/shared/tools'
+import {
+ MAX_HANDOFF_HOPS,
+ ROXY_HOST_ID,
+ SUGGESTED_MEMBERS,
+ BOT_LOOKS,
+ botId,
+ botMember,
+ findMember,
+ channelPrompt,
+ parseBotMentions,
+ soloSpeaker,
+ resolveHandoff,
+ resolveRecipient,
+ visibleMessages,
+ withHost
+} from '../src/shared/channel-members'
+import type { BotMember, Message } from '../src/shared/types'
import {
AGENTS,
getAgent,
@@ -6227,6 +6244,294 @@ async function main(): Promise {
String(resolveFontStack('SF Mono', 'mono', 'win32'))
)
+ // ---- Channel members (multi-bot sessions) --------------------------------
+
+ const builder = SUGGESTED_MEMBERS.find((m) => m.id === 'builder')!
+ const reviewer = SUGGESTED_MEMBERS.find((m) => m.id === 'reviewer')!
+ const room = withHost([builder, reviewer])
+ const host = room[0]
+
+ check('channel: the host is always first', host.id === ROXY_HOST_ID)
+ check('channel: attached members follow the host', room.length === 3)
+ check(
+ 'channel: a stored list cannot duplicate the host',
+ withHost([host, builder, host]).filter((m) => m.id === ROXY_HOST_ID).length === 1
+ )
+ check(
+ 'channel: an empty channel still has the host in it',
+ withHost(undefined).length === 1 && withHost([]).length === 1
+ )
+ check(
+ 'channel: no suggested member can claim the host id',
+ SUGGESTED_MEMBERS.every((m) => m.id !== ROXY_HOST_ID)
+ )
+ check(
+ 'channel: every suggested member carries a brief',
+ SUGGESTED_MEMBERS.every((m) => !!m.systemPrompt?.trim())
+ )
+ check(
+ 'channel: the host carries NO brief (it is the base prompt)',
+ host.systemPrompt === undefined
+ )
+
+ // Mentions
+ check(
+ 'channel: mentions resolve in the order they appear',
+ parseBotMentions('@Reviewer then @Builder', room)
+ .map((m) => m.id)
+ .join() === 'reviewer,builder'
+ )
+ check(
+ 'channel: a mention is matched case-insensitively and with punctuation',
+ parseBotMentions('hey @builder, look', room)[0]?.id === 'builder'
+ )
+ check(
+ 'channel: an email address is not a mention',
+ parseBotMentions('mail user@builder.com', room).length === 0
+ )
+ check(
+ 'channel: a member not in the room is not matched',
+ parseBotMentions('@Security audit this', room).length === 0
+ )
+ check(
+ 'channel: a mention is deduped',
+ parseBotMentions('@Builder and @Builder', room).length === 1
+ )
+
+ // Routing
+ check(
+ 'channel: an unaddressed message goes to the host',
+ resolveRecipient('fix the login bug', room).id === ROXY_HOST_ID
+ )
+ check(
+ 'channel: an addressed message goes to that member',
+ resolveRecipient('@Builder fix the login bug', room).id === 'builder'
+ )
+ check(
+ 'channel: routing falls back to the host even with no members stored',
+ resolveRecipient('anything', withHost([])).id === ROXY_HOST_ID
+ )
+ check(
+ 'channel: a mention mid-sentence is ABOUT a member, so the host answers',
+ resolveRecipient('create the PR and then call @Reviewer to review it', room).id === ROXY_HOST_ID
+ )
+ check(
+ 'channel: leading whitespace still counts as addressed',
+ resolveRecipient(' @Builder fix it', room).id === 'builder'
+ )
+ check(
+ 'channel: a leading mention of a member NOT in the room falls back to the host',
+ resolveRecipient('@Security audit this', room).id === ROXY_HOST_ID
+ )
+ check(
+ 'channel: findMember accepts @Name, name, and id',
+ findMember('@Builder', room)?.id === 'builder' &&
+ findMember('builder', room)?.id === 'builder' &&
+ findMember('BUILDER', room)?.id === 'builder'
+ )
+
+ // Hand-off
+ check(
+ 'channel: a reply naming another member hands off to it',
+ resolveHandoff('done. @Reviewer ready for review', builder, room)?.id === 'reviewer'
+ )
+ check(
+ 'channel: a bot naming ITSELF does not hand off (would spin forever)',
+ resolveHandoff('@Builder already did this', builder, room) === undefined
+ )
+ check(
+ 'channel: a reply naming nobody ends the chain',
+ resolveHandoff('all done, tests pass.', builder, room) === undefined
+ )
+ check(
+ 'channel: a bot naming itself THEN another still hands off to the other',
+ resolveHandoff('@Builder is done, @Reviewer over to you', builder, room)?.id === 'reviewer'
+ )
+ check('channel: the hand-off chain is bounded', MAX_HANDOFF_HOPS > 0 && MAX_HANDOFF_HOPS <= 8)
+
+ // The roster block - without it a bot has no idea the channel exists and
+ // answers "call @Reviewer" by spawning a subagent or hunting a GitHub user.
+ const hostBlock = channelPrompt(room, host) ?? ''
+ check(
+ 'channel: the prompt names the other members and how to reach them',
+ hostBlock.includes('@Builder') &&
+ hostBlock.includes('@Reviewer') &&
+ hostBlock.includes('@mention')
+ )
+ check(
+ 'channel: the prompt tells the bot NOT to delegate to a member via `task`',
+ /task/.test(hostBlock) && /not/i.test(hostBlock)
+ )
+ check('channel: the prompt says members are not GitHub users', /github/i.test(hostBlock))
+ check(
+ 'channel: the prompt does not list the speaker as somebody else in the room',
+ !/ {2}@Roxy /.test(hostBlock)
+ )
+ check(
+ "channel: a member's brief is framed as a standing role, not a work order",
+ (() => {
+ const block = channelPrompt(room, builder) ?? ''
+ return block.includes(builder.systemPrompt!) && /standing identity/i.test(block)
+ })()
+ )
+ check(
+ 'channel: a solo member still gets its role framed (the brief alone would read as a task)',
+ (() => {
+ const solo = channelPrompt([], builder) ?? ''
+ return solo.includes(builder.systemPrompt!) && /standing identity/i.test(solo)
+ })()
+ )
+ check(
+ 'channel: a briefless solo channel gets no block at all',
+ channelPrompt([], host) === undefined
+ )
+
+ // Context isolation - the reason separate bots exist at all.
+ const msg = (role: Message['role'], content: string, authorName?: string): Message => ({
+ id: `${role}-${content.slice(0, 12)}`,
+ chatId: 'c',
+ role,
+ content,
+ parts: [{ type: 'text', text: content }],
+ createdAt: 1,
+ author: authorName ? { name: authorName } : undefined
+ })
+
+ const history: Message[] = [
+ msg('user', 'fix the login bug'),
+ msg('assistant', 'on it', 'Builder'),
+ msg('assistant', 'unrelated audit of billing.ts', 'Security'),
+ msg('assistant', '@Reviewer please check', 'Builder'),
+ msg('assistant', 'reviewed', 'Reviewer')
+ ]
+
+ const reviewerView = visibleMessages(history, reviewer)
+ check(
+ "channel: a member always sees the user's messages",
+ reviewerView.some((m) => m.role === 'user')
+ )
+ check(
+ 'channel: a member sees its own past turns',
+ reviewerView.some((m) => m.author?.name === 'Reviewer')
+ )
+ check(
+ 'channel: a member sees the turn that ADDRESSED it',
+ reviewerView.some((m) => m.content.includes('@Reviewer please check'))
+ )
+ check(
+ 'channel: a member does NOT see unrelated cross-talk',
+ !reviewerView.some((m) => m.content.includes('unrelated audit'))
+ )
+ check(
+ 'channel: the HOST sees the whole conversation',
+ visibleMessages(history, host).length === history.length
+ )
+ check(
+ 'channel: a legacy turn with no author stays visible',
+ visibleMessages([msg('assistant', 'old reply')], reviewer).length === 1
+ )
+ check(
+ 'channel: notices survive the filter (they are not assistant turns)',
+ visibleMessages(
+ [
+ {
+ id: 'n',
+ chatId: 'c',
+ role: 'system',
+ content: 'Security joined the channel',
+ parts: [{ type: 'notice', kind: 'join', member: 'Security' }],
+ createdAt: 1
+ }
+ ],
+ reviewer
+ ).length === 1
+ )
+
+ // ---- Saved bots (the bot library + its private chats) --------------------
+
+ check('bots: a name slugs to an addressable id', botId('Code Reviewer') === 'code-reviewer')
+ check('bots: punctuation and case collapse into the slug', botId(' QA!! Bot ') === 'qa-bot')
+ check('bots: a nameless bot still gets an id', botId('') === 'bot' && botId('!!!') === 'bot')
+ check(
+ 'bots: a colliding name is suffixed, not rejected',
+ botId('QA', ['qa']) === 'qa-2' && botId('QA', ['qa', 'qa-2']) === 'qa-3'
+ )
+ check(
+ 'bots: no bot can claim the host id and shadow Roxy',
+ botId('Roxy') !== ROXY_HOST_ID && botId('roxy') !== ROXY_HOST_ID
+ )
+ check(
+ 'bots: every offered look is one BotAvatar can render',
+ BOT_LOOKS.every((l) => !!l.icon && !!l.color)
+ )
+
+ const savedBot = {
+ id: 'reviewer-x',
+ name: 'Reviewer X',
+ description: 'Reviews diffs',
+ icon: 'reviewer',
+ color: 'purple',
+ instructions: 'You review changes for risk.',
+ chatId: 'chat-1',
+ sortOrder: 1,
+ createdAt: 1,
+ updatedAt: 1
+ }
+ const asMember = botMember(savedBot)
+ check(
+ 'bots: a saved bot keeps its id as a member, so @mention resolves to IT',
+ asMember.id === savedBot.id
+ )
+ check(
+ "bots: the bot's instructions become the member's brief",
+ asMember.systemPrompt === savedBot.instructions
+ )
+ check(
+ 'bots: the description becomes the role the other bots read',
+ asMember.role === savedBot.description
+ )
+ check(
+ 'bots: a briefless bot carries no empty prompt (it would blank the base one)',
+ botMember({ ...savedBot, instructions: ' ' }).systemPrompt === undefined
+ )
+ check(
+ 'bots: a roleless bot still gets a role, never an empty label',
+ !!botMember({ ...savedBot, description: '' }).role
+ )
+ check(
+ 'bots: a saved bot is findable by @mention in a channel it joined',
+ findMember('Reviewer X', withHost([asMember]))?.id === savedBot.id
+ )
+
+ // The private chat: one bot, and it answers everything.
+ const solo = withHost([asMember])
+ check('bots: its own chat has exactly the bot plus the host', solo.length === 2)
+ check(
+ 'bots: the bot answers an unaddressed message in its own chat',
+ soloSpeaker(solo).id === savedBot.id
+ )
+ check(
+ 'bots: routing that same message as a CHANNEL would have hit Roxy instead',
+ resolveRecipient('hello', solo).id === ROXY_HOST_ID
+ )
+ check(
+ 'bots: a chat that somehow lost its bot still has somebody listening',
+ soloSpeaker([]).id === ROXY_HOST_ID
+ )
+ const soloBlock = channelPrompt(solo, asMember, true) ?? ''
+ check(
+ 'bots: a solo chat still tells the bot who it is',
+ soloBlock.includes(savedBot.instructions)
+ )
+ check(
+ 'bots: a solo chat offers no hand-off (there is nobody to hand off to)',
+ !soloBlock.includes('@Roxy') && !soloBlock.includes('Roxy')
+ )
+ check(
+ 'bots: the same roster WITHOUT solo does advertise the host',
+ (channelPrompt(solo, asMember) ?? '').includes('Roxy')
+ )
+
if (fails.length) {
console.error(`\nSHARED FAILED \u2014 ${fails.length} failing: ${fails.join(', ')}`)
process.exit(1)
diff --git a/test/smoke.ts b/test/smoke.ts
index 43f1545..827f6c4 100644
--- a/test/smoke.ts
+++ b/test/smoke.ts
@@ -730,6 +730,85 @@ async function main(): Promise {
const dueAfter = repo.dueLoops(Date.now() + 1000).some((l) => l.id === projLoop.id)
check('markLoopRan advances the schedule', dueBefore === true && dueAfter === false)
+ // ---- saved bots: a bot owns a chat, and that chat is where it answers ----
+ const rev = repo.createBot({
+ name: 'Reviewer X',
+ description: 'Reviews diffs',
+ instructions: 'You review changes for risk.',
+ icon: 'reviewer',
+ color: 'purple'
+ })
+ check('createBot slugs the name into an addressable id', rev.id === 'reviewer-x')
+ check('createBot gives the bot its own chat', !!rev.chatId)
+ const revChat = repo.getChat(rev.chatId)
+ check('bot chat is kind=bot', revChat?.kind === 'bot')
+ check('bot chat carries no workspace (it is for talking, not building)', !revChat?.workspacePath)
+ // The membership is what makes the BOT the one answering there rather than
+ // Roxy wearing its name: the turn path resolves the speaker from this list.
+ check(
+ 'bot chat has the bot attached as its only member',
+ revChat?.channelMembers?.length === 1 && revChat.channelMembers[0].id === rev.id
+ )
+ check(
+ "the attached member carries the bot's brief",
+ revChat?.channelMembers?.[0].systemPrompt === 'You review changes for risk.'
+ )
+ check(
+ 'a bot chat is not listed as a project session',
+ !repo.listChats().some((c) => c.id === rev.chatId && c.kind === 'main')
+ )
+
+ const twin = repo.createBot({ name: 'Reviewer X' })
+ check('two bots may share a name but never an id', twin.id !== rev.id)
+ check('a same-named bot gets its own chat', twin.chatId !== rev.chatId)
+ check(
+ 'a briefless bot is still created (it just answers as Roxy)',
+ repo.getBot(twin.id)?.instructions === ''
+ )
+
+ check('getBotByChat maps a chat back to its bot', repo.getBotByChat(rev.chatId)?.id === rev.id)
+ check('listBots returns both', repo.listBots().length >= 2)
+
+ // Renaming has to reach the chat's stored membership, or the one place you
+ // went to change the bot keeps addressing @OldName with the old brief.
+ const renamed = repo.updateBot(rev.id, { name: 'Auditor', instructions: 'You audit.' })
+ check('updateBot keeps the id stable across a rename', renamed.id === rev.id)
+ check('updateBot retitles the bot chat', repo.getChat(rev.chatId)?.title === 'Auditor')
+ const afterEdit = repo.getChat(rev.chatId)?.channelMembers?.[0]
+ check('updateBot rewrites the chat membership name', afterEdit?.name === 'Auditor')
+ check('updateBot rewrites the chat membership brief', afterEdit?.systemPrompt === 'You audit.')
+ check(
+ 'a field left out of the patch is untouched',
+ repo.getBot(rev.id)?.description === 'Reviews diffs'
+ )
+
+ // A bot attached to a PROJECT session is a copy: the transcript must keep
+ // saying who spoke even after the original is renamed or deleted.
+ repo.setChannelMembers(chat.id, [{ ...afterEdit! }])
+ repo.updateBot(rev.id, { name: 'Auditor 2' })
+ check(
+ 'renaming a bot does NOT rewrite the sessions it joined',
+ repo.getChat(chat.id)?.channelMembers?.[0].name === 'Auditor'
+ )
+
+ repo.reorderBots([twin.id, rev.id])
+ check('reorderBots puts the requested bot first', repo.listBots()[0].id === twin.id)
+
+ repo.addMessage({ chatId: twin.chatId, role: 'user', content: 'hi' })
+ const twinChatId = twin.chatId
+ repo.deleteBot(twin.id)
+ check('deleteBot removes the bot', !repo.getBot(twin.id))
+ check(
+ 'deleteBot takes its conversation with it (nothing else can reach it)',
+ !repo.listChats().some((c) => c.id === twinChatId)
+ )
+ check('a deleted bot leaves the other one alone', !!repo.getBot(rev.id))
+ check(
+ 'a session the deleted bot joined keeps its member record',
+ repo.getChat(chat.id)?.channelMembers?.length === 1
+ )
+ repo.setChannelMembers(chat.id, [])
+
// ---- sessions status excludes loop chats ----
const status = repo.listSessionsStatus()
check(