diff --git a/README.md b/README.md index 4a7dfee59..89a068a79 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,12 @@ Secrets are write-only: the UI only ever sees "configured" flags. +### #️⃣ Channels for every context + +Keep Work, Personal, and each project in separate channels without cloning your bots. Every channel has +its own transcript, shared instructions, working folder, responder rules, and editable bot roster. File a +channel and its bots under a named context, then rename it or change its members whenever the team changes. + ### 🎧 Bots that talk back Press the speaker on any reply, or switch a bot to read its answers out as they land — so you can listen @@ -140,7 +146,7 @@ to what ran overnight while you make breakfast. Hit **call** and it's a conversa you what it's doing while it works, and asks for approvals out loud. Bring your own ElevenLabs key — paste it once in App Settings, pick a voice, and every bot can talk. -Give a bot its own voice and a room stops sounding like one person. +Give a bot its own voice and a channel stops sounding like one person. **Also in the box:** streaming replies with tool-run activity chips · native macOS dictation from the composer mic (on-device Apple speech recognition — desktop app) · SupaMaus cursor mascots with role-aware diff --git a/server/index.test.ts b/server/index.test.ts index 7525ddcd5..d765d3699 100644 --- a/server/index.test.ts +++ b/server/index.test.ts @@ -559,8 +559,20 @@ describe("harness HTTP API", () => { expect(clearedEmpty.status).toBe(200); expect(clearedEmpty.body.bot).not.toHaveProperty("section"); - // rooms file under the same sidebar sections, with the same contract - const sectionRoom = (await api("POST", "/api/groups", { name: "Filed", memberIds: [bot.id] })).body.group; + // Channels can be born inside a Work/Personal/project context, and can + // later move through the same context contract as bots. + const createdInContext = await api("POST", "/api/groups", { + name: "Filed", + memberIds: [bot.id, bot.id], + section: " Work ", + }); + expect(createdInContext.status).toBe(201); + expect(createdInContext.body.group).toMatchObject({ section: "Work", memberIds: [bot.id] }); + expect((await api("POST", "/api/groups", { name: 7, memberIds: [bot.id] })).status).toBe(400); + expect((await api("POST", "/api/groups", { name: "N".repeat(101), memberIds: [bot.id] })).status).toBe(400); + expect((await api("POST", "/api/groups", { name: "Bad context", memberIds: [bot.id], section: 7 })).status).toBe(400); + expect((await api("POST", "/api/groups", { name: "Long context", memberIds: [bot.id], section: "S".repeat(61) })).status).toBe(400); + const sectionRoom = createdInContext.body.group; const roomSectioned = await api("PATCH", `/api/groups/${sectionRoom.id}`, { section: " Clients " }); expect(roomSectioned.status).toBe(200); expect(roomSectioned.body.group).toMatchObject({ section: "Clients" }); diff --git a/server/index.ts b/server/index.ts index e2f3eab23..ef086d73c 100644 --- a/server/index.ts +++ b/server/index.ts @@ -2968,18 +2968,32 @@ const server = createServer(async (req, res) => { return res.end(lines.join("\n")); } - // ── rooms (group chats) ───────────────────────────────────────────── + // ── channels (persisted internally as groups) ─────────────────────── if (method === "POST" && path === "/api/groups") { const body = await readBody(req); - const memberIds = (Array.isArray(body.memberIds) ? body.memberIds : []).filter( - (id: unknown): id is string => typeof id === "string" && Boolean(store.bot(id)), - ); - if (memberIds.length === 0) return json(res, 400, { error: "a room needs at least one bot" }); - const name = - typeof body.name === "string" && body.name.trim() - ? body.name.trim() - : `${store.bot(memberIds[0])!.name} & co.`; - const group = store.createGroup(name, memberIds); + const requestedMemberIds: unknown[] = Array.isArray(body.memberIds) ? body.memberIds : []; + const memberIds = [ + ...new Set( + requestedMemberIds.filter( + (id): id is string => typeof id === "string" && Boolean(store.bot(id)), + ), + ), + ]; + if (memberIds.length === 0) return json(res, 400, { error: "a channel needs at least one bot" }); + if (body.name !== undefined && typeof body.name !== "string") { + return json(res, 400, { error: "channel name must be a string" }); + } + const name = body.name?.trim() || `${store.bot(memberIds[0])!.name} & co.`; + if (name.length > 100) return json(res, 400, { error: "channel name must be at most 100 characters" }); + let section: string | undefined; + if (body.section !== undefined && body.section !== null) { + if (typeof body.section !== "string") return json(res, 400, { error: "context must be a string" }); + section = body.section.trim() || undefined; + if (section && section.length > 60) { + return json(res, 400, { error: "context must be at most 60 characters" }); + } + } + const group = store.createGroup(name, memberIds, false, section); return json(res, 201, { group: { ...group, messages: [] } }); } if (method === "POST" && path === "/api/teams/export") { diff --git a/server/store.test.ts b/server/store.test.ts index 2baf0c89c..4b8219390 100644 --- a/server/store.test.ts +++ b/server/store.test.ts @@ -82,6 +82,15 @@ describe("Store", () => { expect(reloaded.group(group.id)?.defaultResponder).toEqual({ kind: "member", botId: second.id }); }); + it("persists a channel's context when it is created", () => { + const store = new Store(selection); + const bot = store.createBot(); + const channel = store.createGroup("Website launch", [bot.id], false, "Work"); + + expect(channel.section).toBe("Work"); + expect(new Store(selection).group(channel.id)?.section).toBe("Work"); + }); + it("migrates old rooms without routing to their first member", () => { const store = new Store(selection); const first = store.createBot(); diff --git a/server/store.ts b/server/store.ts index 742f3a591..903718f73 100644 --- a/server/store.ts +++ b/server/store.ts @@ -565,7 +565,7 @@ export class Store { return this.groups.find((g) => g.threadId === threadId); } - createGroup(name: string, memberIds: string[], dm = false): GroupRecord { + createGroup(name: string, memberIds: string[], dm = false, section?: string): GroupRecord { const group: GroupRecord = { id: newId(), threadId: newId(), @@ -577,6 +577,7 @@ export class Store { createdAt: Date.now(), dm: dm || undefined, busyBotId: null, + section, }; this.groups.unshift(group); this.saveGroups(); diff --git a/src/components/CallView.tsx b/src/components/CallView.tsx index e9da08d75..9e17ad015 100644 --- a/src/components/CallView.tsx +++ b/src/components/CallView.tsx @@ -106,7 +106,7 @@ export function CallTargetButton({ ? "Add an ElevenLabs API key so the bot can speak during calls." : !voiceReady ? voices.length > 1 - ? "Give every room member an ElevenLabs voice before starting a room call." + ? "Give every channel member an ElevenLabs voice before starting a channel call." : "Choose an ElevenLabs voice before starting a call." : ""; diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette.tsx index ccdd7d733..731b9cc82 100644 --- a/src/components/CommandPalette.tsx +++ b/src/components/CommandPalette.tsx @@ -161,7 +161,7 @@ export function CommandPalette() { autoFocus value={query} onChange={(e) => setQuery(e.target.value)} - placeholder="Search bots, rooms, messages…" + placeholder="Search bots, channels, messages…" className="w-full bg-transparent text-[14px] text-ink placeholder:text-ink-secondary focus:outline-none" /> @@ -195,7 +195,7 @@ export function CommandPalette() { )} {rooms.length > 0 && (
- Rooms + Channels
)} {rooms.map((group, i) => diff --git a/src/components/Composer.tsx b/src/components/Composer.tsx index b37213eb2..88ea67575 100644 --- a/src/components/Composer.tsx +++ b/src/components/Composer.tsx @@ -296,7 +296,7 @@ export function Composer({ )} {peer.name} - {peer.bot ? "Agent" : "Room"} + {peer.bot ? "Agent" : "Channel"} ))} diff --git a/src/components/GroupCallView.tsx b/src/components/GroupCallView.tsx index fc4c442ba..d1212455b 100644 --- a/src/components/GroupCallView.tsx +++ b/src/components/GroupCallView.tsx @@ -297,7 +297,7 @@ function GroupCall({ group, members }: { group: Group; members: Bot[] }) { const member = members.find((candidate) => candidate.id === approval.message.from?.botId); askedApproval.current = { requestId: approval.requestId, member }; spokenIds.current.add(approval.message.id); - const name = member?.name ?? approval.message.from?.name ?? "A room member"; + const name = member?.name ?? approval.message.from?.name ?? "A channel member"; enqueueSpeech( name + " wants to " + approval.tool + ". " + approval.detail + ". Should I allow it?", member, @@ -309,7 +309,7 @@ function GroupCall({ group, members }: { group: Group; members: Bot[] }) { const member = members.find((candidate) => candidate.id === question.from?.botId); askedQuestion.current = { requestId: question.card.requestId, member }; spokenIds.current.add(question.id); - const name = member?.name ?? question.from?.name ?? "A room member"; + const name = member?.name ?? question.from?.name ?? "A channel member"; const detail = question.card.subtitle.trim(); const choices = question.card.options.length ? " The options are " + question.card.options.join(", ") + "." @@ -389,9 +389,9 @@ function GroupCall({ group, members }: { group: Group; members: Bot[] }) { ? "Push to talk" : "Listening" : phase === "sending" - ? "Bringing the room in" + ? "Bringing the channel in" : phase === "speaking" - ? (speakingMember?.name ?? "Room member") + " is speaking" + ? (speakingMember?.name ?? "Channel member") + " is speaking" : workingMember ? workingMember.name + " is working" : "Working"; @@ -457,7 +457,7 @@ function GroupCall({ group, members }: { group: Group; members: Bot[] }) { {pushToTalk ? "Release Control + Option to send…" - : "Say a name, say “everyone,” or just talk to the room…"} + : "Say a name, say “everyone,” or just talk to the channel…"} ) ) : phase === "speaking" ? ( diff --git a/src/components/GroupView.tsx b/src/components/GroupView.tsx index f6babcb2a..a61ae567c 100644 --- a/src/components/GroupView.tsx +++ b/src/components/GroupView.tsx @@ -80,7 +80,7 @@ function PinToggle({ group, message }: { group: Group; message: Message }) { } aria-label={pinned ? "Unpin message" : "Pin message"} className="rounded-md p-1.5 text-ink-secondary opacity-0 transition-opacity hover:bg-raised hover:text-ink focus-visible:opacity-100 group-hover:opacity-100 group-focus-within:opacity-100" - title={pinned ? "Unpin this message" : "Pin this message to the top of the room"} + title={pinned ? "Unpin this message" : "Pin this message to the top of the channel"} > {pinned ? : } @@ -189,7 +189,7 @@ function DefaultResponderSelect({ group, members }: { group: Group; members: Bot const lead = responder.kind === "member" ? members.find((member) => member.id === responder.botId) : undefined; const title = responder.kind === "everyone" - ? "Plain messages go to every room member; @mentions override this" + ? "Plain messages go to every channel member; @mentions override this" : responder.kind === "mentions" ? "Only explicitly @mentioned bots respond" : `Plain messages go to ${lead?.name ?? "the lead bot"}; @mentions override this`; @@ -210,14 +210,14 @@ function DefaultResponderSelect({ group, members }: { group: Group; members: Bot onChange={(event) => change(event.target.value)} className="h-8 max-w-[190px] appearance-none truncate rounded-full border border-hairline/40 bg-raised/60 py-1 pl-3 pr-7 text-[12.5px] font-medium text-ink outline-none hover:bg-raised focus:border-accent" > - + {members.map((member) => ( ))} - + @@ -269,14 +269,14 @@ function RoomWorkingFolder({ group }: { group: Group }) { return (
Working folder
-
Where every bot in this room runs its shell and file tools.
+
Where every bot in this channel runs its shell and file tools.
{locked ? (
{shownCwd ? shortPath(shownCwd, home) : Each bot's own folder}
- Fixed after this room's first turn. Create a new room and choose its folder before sending the first message to work somewhere else. + Fixed after this channel's first turn. Create a new channel and choose its folder before sending the first message to work somewhere else.
) : canPick ? ( @@ -328,7 +328,7 @@ function RoomWorkingFolderChip({ group, onToggle }: { group: Group; onToggle: () @@ -521,7 +521,7 @@ export function GroupView({ group }: { group: Group }) { type="button" onClick={() => setMembersOpen(true)} title="Manage members" - aria-label={`Manage members — ${members.length} ${members.length === 1 ? "bot" : "bots"} in this room`} + aria-label={`Manage members — ${members.length} ${members.length === 1 ? "bot" : "bots"} in this channel`} className="flex items-center gap-1.5 rounded-full py-0.5 pl-1 pr-1.5 hover:bg-raised/60" > {memberMauses} @@ -549,7 +549,7 @@ export function GroupView({ group }: { group: Group }) { setBulletinOpen(false); } }} - placeholder="Room instructions — every bot in this room follows them (who does what, tone, goals, a task checklist…)" + placeholder="Channel instructions — every bot in this channel follows them (who does what, tone, goals, a task checklist…)" rows={4} className="w-full resize-none bg-transparent text-[13px] leading-relaxed text-ink placeholder:text-ink-secondary focus:outline-none" /> @@ -558,11 +558,11 @@ export function GroupView({ group }: { group: Group }) { )} diff --git a/src/components/ManageMembersPanel.tsx b/src/components/ManageMembersPanel.tsx index dcd3f69b8..368b86fcb 100644 --- a/src/components/ManageMembersPanel.tsx +++ b/src/components/ManageMembersPanel.tsx @@ -85,7 +85,7 @@ export function ManageMembersPanel({ const rosterChanged = opened.length !== group.memberIds.length || opened.some((id, index) => id !== group.memberIds[index]); if (rosterChanged) { - setSaveError("This room's members changed while the panel was open. Close it and try again."); + setSaveError("This channel's members changed while the panel was open. Close it and try again."); return; } if (changed) { @@ -113,8 +113,8 @@ export function ManageMembersPanel({ >
Manage Members
{group.name}
- - {!memberIds.length &&
A room needs at least one bot.
} + + {!memberIds.length &&
A channel needs at least one bot.
} {saveError && (
{saveError} diff --git a/src/components/RoomTurnTimeoutSettings.tsx b/src/components/RoomTurnTimeoutSettings.tsx index 0bae109f5..713c2fb52 100644 --- a/src/components/RoomTurnTimeoutSettings.tsx +++ b/src/components/RoomTurnTimeoutSettings.tsx @@ -85,7 +85,7 @@ export function RoomTurnTimeoutSettings() { minutes

- Applies to every bot turn in rooms. Direct chats use the inactivity watchdog instead. + Applies to every bot turn in channels. Direct chats use the inactivity watchdog instead.

{error ? (
, document.body, ); } -/** Pick members → Create. The room name is optional; the server defaults it. */ +/** Pick members and an optional Work/Personal/project context, then create. */ function NewRoomPanel({ onClose }: { onClose: () => void }) { const { state, dispatch } = useStore(); const [name, setName] = useState(""); + const [section, setSection] = useState(""); const [picked, setPicked] = useState>(new Set()); const bots = state.bots.filter((b) => !b.hidden); const toggle = (id: string) => @@ -401,8 +402,13 @@ function NewRoomPanel({ onClose }: { onClose: () => void }) { }); const create = () => { if (!picked.size) return; - dispatch({ type: "createGroup", memberIds: [...picked], name: name.trim() || undefined }); - track("room_created", { members: picked.size }); + dispatch({ + type: "createGroup", + memberIds: [...picked], + name: name.trim() || undefined, + section: section.trim() || undefined, + }); + track("room_created", { members: picked.size, context: Boolean(section.trim()) }); onClose(); }; return ( @@ -411,30 +417,43 @@ function NewRoomPanel({ onClose }: { onClose: () => void }) { onMouseDown={(e) => e.target === e.currentTarget && onClose()} >
-
New Room
+
New Channel
setName(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") create(); if (e.key === "Escape") onClose(); }} - placeholder="Room name (optional)" + placeholder="Channel name (for example, Website launch)" + className="mb-3 w-full rounded-lg bg-raised/70 px-3 py-2 text-[14px] text-ink placeholder:text-ink-secondary focus:outline-none" + /> + setSection(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") create(); + if (e.key === "Escape") onClose(); + }} + placeholder="Context (optional): Work, Personal, Client…" + aria-label="Channel context" className="mb-3 w-full rounded-lg bg-raised/70 px-3 py-2 text-[14px] text-ink placeholder:text-ink-secondary focus:outline-none" />
@@ -456,7 +475,7 @@ function SectionDivider({ name }: { name: string }) { /** Move-to-section popover: existing sections as chips (checkmark on the * target's current one), a create field, and a remove action. Serves bots - * and rooms alike — the caller supplies the assignment. Mirrors the + * and channels alike — the caller supplies the assignment. Mirrors the * context menu's fixed positioning + dismiss-on-outside-click contract. */ function SectionPicker({ current, @@ -490,8 +509,8 @@ function SectionPicker({ }; }, [onClose]); - // hidden bots can carry a stale assignment; don't offer it as a section. - // Rooms and bots share one namespace, so a heading can hold both. + // Hidden bots can carry a stale assignment; don't offer it as a context. + // Channels and bots share one namespace, so Work or Personal can hold both. const sections = [ ...new Set([ ...state.bots.filter((b) => !b.hidden && b.section).map((b) => b.section!), @@ -514,7 +533,7 @@ function SectionPicker({ className="fixed z-40 w-[236px] overflow-hidden rounded-xl border border-hairline/50 bg-card py-2 shadow-2xl shadow-black/60" >
- Move to section + Move to context
{sections.length > 0 && (
@@ -546,8 +565,8 @@ function SectionPicker({ maxLength={60} value={name} onChange={(e) => setName(e.target.value)} - placeholder="New section…" - aria-label="New section name" + placeholder="New context…" + aria-label="New context name" className="w-full rounded-lg bg-raised/70 px-2.5 py-1.5 text-[13px] text-ink placeholder:text-ink-secondary focus:outline-none" /> )} @@ -1321,7 +1340,7 @@ export function Sidebar({ open, onClose }: { open: boolean; onClose: () => void className="flex w-full items-center gap-3 px-3.5 py-2 text-left text-[14px] text-ink hover:bg-raised/70" > - New Room + New Channel
)} + {unsectionedGroups.length > 0 && density !== "icons" && } {unsectionedGroups.map((g) => ( ))} + {visibleBots.length > 0 && density !== "icons" && } {visibleBots.map((b) => ( ) ) : ( - "No room is created—you can make one later if you want." + "No channel is created—you can make one later if you want." )}

- Creates the team as new bots, opens a room for them, and points the room at this folder. + Creates the team as new bots, opens a channel for them, and points the channel at this folder.

)} diff --git a/src/lib/room-turn-timeout.test.ts b/src/lib/room-turn-timeout.test.ts index 934e53783..3df14bb99 100644 --- a/src/lib/room-turn-timeout.test.ts +++ b/src/lib/room-turn-timeout.test.ts @@ -61,7 +61,7 @@ describe("room turn timeout input", () => { throw "unavailable"; }); - expect(result).toEqual({ ok: false, error: "Could not save the room turn limit." }); + expect(result).toEqual({ ok: false, error: "Could not save the channel turn limit." }); }); it("blocks a second save while the first is pending and allows a later save", async () => { diff --git a/src/lib/room-turn-timeout.ts b/src/lib/room-turn-timeout.ts index 26c1fe763..91522350e 100644 --- a/src/lib/room-turn-timeout.ts +++ b/src/lib/room-turn-timeout.ts @@ -48,6 +48,6 @@ export async function saveRoomTurnTimeoutMinutes( try { return { ok: true, minutes: await persist(parsed.minutes) }; } catch (cause) { - return { ok: false, error: cause instanceof Error ? cause.message : "Could not save the room turn limit." }; + return { ok: false, error: cause instanceof Error ? cause.message : "Could not save the channel turn limit." }; } } diff --git a/src/state/store.tsx b/src/state/store.tsx index d38ac83b6..d9dfdfd3f 100644 --- a/src/state/store.tsx +++ b/src/state/store.tsx @@ -403,7 +403,7 @@ export type Action = | { type: "markRoutineRunSeen"; runId: string } | { type: "groupPatched"; group: Partial & { id: string } } | { type: "groupDeleted"; groupId: string } - | { type: "createGroup"; memberIds: string[]; name?: string } + | { type: "createGroup"; memberIds: string[]; name?: string; section?: string } | { type: "sendGroup"; groupId: string; text: string } | { type: "patchGroup"; @@ -1317,7 +1317,7 @@ export function StoreProvider({ children }: { children: ReactNode }) { case "createGroup": api(`/api/groups`, { method: "POST", - body: JSON.stringify({ memberIds: action.memberIds, name: action.name }), + body: JSON.stringify({ memberIds: action.memberIds, name: action.name, section: action.section }), }) .then(({ group }) => { rawDispatch({ type: "groupPatched", group });