Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,14 +133,20 @@ Secrets are write-only: the UI only ever sees "configured" flags.
</tr>
</table>

### #️⃣ 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
to what ran overnight while you make breakfast. Hit **call** and it's a conversation: it hears you, tells
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
Expand Down
16 changes: 14 additions & 2 deletions server/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
Expand Down
34 changes: 24 additions & 10 deletions server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down
9 changes: 9 additions & 0 deletions server/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
3 changes: 2 additions & 1 deletion server/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -577,6 +577,7 @@ export class Store {
createdAt: Date.now(),
dm: dm || undefined,
busyBotId: null,
section,
};
this.groups.unshift(group);
this.saveGroups();
Expand Down
2 changes: 1 addition & 1 deletion src/components/CallView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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."
: "";

Expand Down
4 changes: 2 additions & 2 deletions src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
/>
<kbd className="shrink-0 rounded-md border border-hairline/40 px-1.5 py-0.5 text-[11px] text-ink-secondary">
Expand Down Expand Up @@ -195,7 +195,7 @@ export function CommandPalette() {
)}
{rooms.length > 0 && (
<div className="px-3 pb-1 pt-2 text-[11px] font-medium uppercase tracking-[0.08em] text-ink-secondary">
Rooms
Channels
</div>
)}
{rooms.map((group, i) =>
Expand Down
2 changes: 1 addition & 1 deletion src/components/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,7 @@ export function Composer({
</span>
)}
<span className="min-w-0 flex-1 truncate text-[14px] font-medium text-ink">{peer.name}</span>
<span className="shrink-0 text-xs text-ink-secondary">{peer.bot ? "Agent" : "Room"}</span>
<span className="shrink-0 text-xs text-ink-secondary">{peer.bot ? "Agent" : "Channel"}</span>
</button>
))}
</div>
Expand Down
10 changes: 5 additions & 5 deletions src/components/GroupCallView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(", ") + "."
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -457,7 +457,7 @@ function GroupCall({ group, members }: { group: Group; members: Bot[] }) {
<span className="text-ink-secondary">
{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…"}
</span>
)
) : phase === "speaking" ? (
Expand Down
22 changes: 11 additions & 11 deletions src/components/GroupView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ? <PinOff size={14} /> : <Pin size={14} />}
</button>
Expand Down Expand Up @@ -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`;
Expand All @@ -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"
>
<optgroup label="Room lead">
<optgroup label="Channel lead">
{members.map((member) => (
<option key={member.id} value={`member:${member.id}`}>
Lead: {member.name}
</option>
))}
</optgroup>
<optgroup label="Room behavior">
<optgroup label="Channel behavior">
<option value="everyone">Everyone responds</option>
<option value="mentions">Mentions only</option>
</optgroup>
Expand Down Expand Up @@ -269,14 +269,14 @@ function RoomWorkingFolder({ group }: { group: Group }) {
return (
<div className="rounded-xl bg-card p-4">
<div className="text-[15px] font-medium text-ink">Working folder</div>
<div className="mt-0.5 text-[13px] text-ink-secondary">Where every bot in this room runs its shell and file tools.</div>
<div className="mt-0.5 text-[13px] text-ink-secondary">Where every bot in this channel runs its shell and file tools.</div>
{locked ? (
<div className="mt-3">
<div className="truncate rounded-lg border border-hairline/40 bg-inset px-3 py-2 font-mono text-[12.5px] text-ink" title={shownCwd}>
{shownCwd ? shortPath(shownCwd, home) : <span className="text-ink-secondary">Each bot's own folder</span>}
</div>
<div className="mt-2 text-[12px] text-ink-secondary">
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.
</div>
</div>
) : canPick ? (
Expand Down Expand Up @@ -328,7 +328,7 @@ function RoomWorkingFolderChip({ group, onToggle }: { group: Group; onToggle: ()
<button
onClick={onToggle}
className="rounded-md p-1.5 text-ink-secondary hover:bg-raised hover:text-ink"
title="Room working folder"
title="Channel working folder"
>
<Folder size={14} />
</button>
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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"
/>
Expand All @@ -558,11 +558,11 @@ export function GroupView({ group }: { group: Group }) {
<button
onClick={() => setBulletinOpen(true)}
className="mb-1 flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left hover:bg-raised/40"
title="Room bulletin — shared instructions for every bot here"
title="Channel bulletin — shared instructions for every bot here"
>
<Pin size={12} className="shrink-0 text-ink-secondary" />
<span className={cn("truncate text-[12.5px]", group.bulletin ? "text-ink-secondary" : "text-ink-secondary/60")}>
{group.bulletin.split("\n")[0] || "Add room instructions…"}
{group.bulletin.split("\n")[0] || "Add channel instructions…"}
</span>
</button>
)}
Expand Down
6 changes: 3 additions & 3 deletions src/components/ManageMembersPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -113,8 +113,8 @@ export function ManageMembersPanel({
>
<div className="mb-1 text-[15px] font-semibold text-ink">Manage Members</div>
<div className="mb-3 truncate text-[13px] text-ink-secondary">{group.name}</div>
<BotPickerList bots={bots} picked={picked} onToggle={toggle} emptyHint="Create a bot first — rooms are made of bots." />
{!memberIds.length && <div className="mt-2 text-[12px] text-ink-secondary">A room needs at least one bot.</div>}
<BotPickerList bots={bots} picked={picked} onToggle={toggle} emptyHint="Create a bot first — channels are made of bots." />
{!memberIds.length && <div className="mt-2 text-[12px] text-ink-secondary">A channel needs at least one bot.</div>}
{saveError && (
<div role="alert" className="mt-2 text-[12px] text-danger">
{saveError}
Expand Down
2 changes: 1 addition & 1 deletion src/components/RoomTurnTimeoutSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ export function RoomTurnTimeoutSettings() {
<span className="pr-3 text-[13px] text-ink-secondary">minutes</span>
</div>
<p id="room-turn-timeout-help" className="text-[12px] leading-relaxed text-ink-secondary">
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.
</p>
{error ? (
<p id="room-turn-timeout-error" role="alert" className="text-[12px] text-danger">
Expand Down
2 changes: 1 addition & 1 deletion src/components/SettingsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,7 @@ export function SettingsModal() {
<Card title="Skin" subtitle="Applies instantly and is remembered on this machine.">
<SkinPicker />
</Card>
<Card title="Room turns" subtitle="Set one maximum duration for every bot turn in a room.">
<Card title="Channel turns" subtitle="Set one maximum duration for every bot turn in a channel.">
<RoomTurnTimeoutSettings />
</Card>
<UpdatesRow />
Expand Down
Loading
Loading