Skip to content
1,942 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Chat and workspace agents: channels, DMs and named agents you talk to1/**
2 * The chat service: a workspace's channels, direct messages, threads and
3 * messages. People and agents are members alike. Plan: docs/WORKSPACE.md.
4 *
5 * Reached through service bindings: `POST /rpc/<method>` with snake_case
6 * bodies (`chatClient` in @g1t/contracts), and `GET /live` for a channel's
7 * socket, which the site forwards after checking the session. Each channel
8 * has a room (src/room.ts) that delivers what happens in it live.
9 *
10 * Workspaces are kept by id, so renaming one changes nothing here; who is
11 * in a workspace comes from the viewer's memberships, as in every service.
12 */
13
14import {
15 CHAT_MAX_HOPS,
16 askerAccess,
17 CHAT_VIEWER_HEADER,
18 fail,
19 identityClient,
20 newId,
21 ok,
22 openD1,
23 parsePrincipalKey,
24 principalKey,
25 workspaceAgentsClient,
26 type AgentDelivery,
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)27 type AgentFoundMessage,
Chat and workspace agents: channels, DMs and named agents you talk to28 type AgentPostMessage,
29 type AskerAccess,
30 type Channel,
Chat controls, public profiles, shadcn selects, and no Docs tab in a project31 type ChannelChange,
32 type ChannelDetail,
Chat and workspace agents: channels, DMs and named agents you talk to33 type ChannelMember,
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)34 type ChatAudience,
Chat and workspace agents: channels, DMs and named agents you talk to35 type ChatLiveEvent,
36 type ChatMessage,
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)37 type ChatReaction,
38 type CustomEmoji,
39 type EmojiFile,
40 type EmojiList,
41 type EmojiUpload,
Chat controls, public profiles, shadcn selects, and no Docs tab in a project42 type ChatSettings,
43 type ChatSettingsView,
Chat and workspace agents: channels, DMs and named agents you talk to44 type ChatSidebar,
45 type ChatSidebarEntry,
46
47 type Member,
48 type MemberProfile,
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar49 type CardActionResult,
Chat and workspace agents: channels, DMs and named agents you talk to50 type MessageCard,
51 type MessagePage,
52 type NewChannel,
53 type PostMessage,
54 type Principal,
55 type Result,
56 type ServiceBinding,
57 type User,
58 type Viewer,
59 type Workspace,
60 type WorkspaceAgent,
61} from "@g1t/contracts";
62
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)63import { audienceKind, isShared, likePattern, readableBy } from "./audience.ts";
64import { MAX_HOPS, addsOrchestrator, chainFor, deliveries, delivery, sender, type Chain } from "./delivery.ts";
65import {
66 MAX_REACTIONS_PER_MESSAGE,
67 emojiImage,
68 emojiName,
69 fromBase64,
70 mayRemove,
71 mayUpload,
72 reactionEmoji,
73 roomForReaction,
74 tallyReactions,
75 type ReactionRow,
76 type ReactionTally,
77} from "./emoji.ts";
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar78import { cleanCard } from "./cards.ts";
Chat and workspace agents: channels, DMs and named agents you talk to79import { mentionedHandles, mentionsColumn } from "./mentions.ts";
80import { AGENT_TYPING_MS, historyOf, historySize, messageBody, meterDay, pageOf, pageSize } from "./messages.ts";
81import { GENERAL, MAX_DM_MEMBERS, channelName, dmKey, dmMembers } from "./names.ts";
82import { ROOM_MEMBER_HEADER, type ChannelRoom, type RoomMember } from "./room.ts";
Chat controls, public profiles, shadcn selects, and no Docs tab in a project83import { mayCreateChannel, mayManageChannel, permissionsFor, rowFor, settingsChange, settingsOf, type SettingsRow } from "./settings.ts";
Chat and workspace agents: channels, DMs and named agents you talk to84import { dmTitle, sidebarOrder, tally, type UnreadRow } from "./unread.ts";
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)85// Live notifications and counts (services/notify).
86import { notifyMessage, notifyMuted, notifyRead } from "./notify.ts";
Chat and workspace agents: channels, DMs and named agents you talk to87
88export { ChannelRoom } from "./room.ts";
89
90// The hop limit here is the one in the contract.
91const SAME_HOP_LIMIT: typeof CHAT_MAX_HOPS = MAX_HOPS;
92void SAME_HOP_LIMIT;
93
94type Env = {
95 DB: D1Database;
96 IDENTITY: ServiceBinding;
97 AGENTS: ServiceBinding;
98 ROOMS: DurableObjectNamespace<ChannelRoom>;
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)99 /** The avatars namespace: custom emoji images, under `emoji/<sha256>`, which the usercontent origin serves. */
100 AVATARS: KVNamespace;
101 /** Live notifications and unread counts (services/notify); absent, nobody is told. */
102 NOTIFY?: ServiceBinding;
Chat and workspace agents: channels, DMs and named agents you talk to103};
104
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)105type EmojiRow = {
106 workspace_id: string;
107 name: string;
108 alias_of: string | null;
109 file: string;
110 content_type: CustomEmoji["content_type"];
111 bytes: number;
112 created_by: string;
113 created_at: string;
114 deleted_at: string | null;
115};
116
117/** The viewer's role in a workspace, or null when they are not in it. */
118function roleOf(viewer: Viewer, workspace: string): "owner" | "member" | null {
119 return viewer?.workspaces?.find((m) => m.slug === workspace.toLowerCase())?.role ?? null;
120}
121
122async function sha256(bytes: Uint8Array): Promise<string> {
123 const digest = await crypto.subtle.digest("SHA-256", bytes);
124 return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
125}
126
Chat and workspace agents: channels, DMs and named agents you talk to127type ChannelRow = {
128 id: string;
129 workspace_id: string;
130 kind: "channel" | "dm";
131 name: string | null;
132 topic: string | null;
133 private: number;
134 dm_key: string | null;
135 created_by: string;
136 created_at: string;
137 archived_at: string | null;
138 last_message_at: string | null;
139};
140
141type MemberRow = {
142 channel_id: string;
143 principal: string;
144 role: "owner" | "member";
145 starred: number;
146 muted: number;
147 last_read_id: string | null;
148 joined_at: string;
149};
150
151type MessageRow = {
152 id: string;
153 channel_id: string;
154 author: string;
155 kind: "text" | "card";
156 body: string;
157 card: string | null;
158 mentions: string;
159 thread_root: string | null;
160 reply_count: number;
161 last_reply_at: string | null;
162 created_at: string;
163 edited_at: string | null;
164 deleted_at: string | null;
165};
166
167/** What a method found out about the channel it was asked about. */
168type Place = { slug: string; workspace: Workspace; channel: ChannelRow; member: MemberRow | null };
169
170/** The most unread messages one sidebar reads to count; past it, counts are "at least". */
171const MAX_UNREAD_ROWS = 5_000;
172/** The longest channel topic. */
173const MAX_TOPIC = 250;
174/** How many others a direct message's sidebar entry shows. */
175const DM_FACES = 4;
176
177const now = () => new Date().toISOString();
178
179function isMember(viewer: Viewer, workspace: string): boolean {
180 return !!viewer?.workspaces?.some((m) => m.slug === workspace.toLowerCase());
181}
182
183function isOwner(viewer: Viewer, workspace: string): boolean {
184 return !!viewer?.workspaces?.some((m) => m.slug === workspace.toLowerCase() && m.role === "owner");
185}
186
187function userKey(viewer: User): string {
188 return principalKey({ kind: "user", id: viewer.id });
189}
190
191function toChannel(row: ChannelRow): Channel {
192 return {
193 id: row.id,
194 workspace_id: row.workspace_id,
195 kind: row.kind,
196 name: row.kind === "dm" ? null : row.name,
197 topic: row.topic,
198 private: row.kind === "dm" || !!row.private,
199 created_by: parsePrincipalKey(row.created_by) ?? { kind: "user", id: row.created_by },
200 created_at: row.created_at,
201 archived_at: row.archived_at,
202 last_message_at: row.last_message_at,
203 };
204}
205
206/** An asker handed back by the agents service, or null when what was sent is not one. */
207function cleanAsker(asker: unknown): AskerAccess | null {
208 if (!asker || typeof asker !== "object") return null;
209 const a = asker as Record<string, unknown>;
210 if (typeof a.username !== "string" || !["owner", "member", "outside"].includes(String(a.role))) return null;
211 return { username: a.username, role: a.role as AskerAccess["role"], can_write: a.can_write === true };
212}
213
214function bytesOf(text: string): number {
215 return new TextEncoder().encode(text).length;
216}
217
218class Chat {
219 private readonly workspaces = new Map<string, Promise<Workspace | null>>();
220 private readonly people = new Map<string, Promise<Map<string, Member>>>();
221 private readonly usernames = new Map<string, string>();
222 private readonly agents = new Map<string, WorkspaceAgent | null>();
Chat controls, public profiles, shadcn selects, and no Docs tab in a project223 private readonly settingsRows = new Map<string, Promise<SettingsRow | null>>();
Chat and workspace agents: channels, DMs and named agents you talk to224
225 /** `defer` runs work after the answer is sent: the request's waitUntil. */
226 constructor(
227 private readonly env: Env,
228 private readonly defer: (work: Promise<unknown>) => void = () => {},
229 ) {}
230
231 private get db() {
232 return this.env.DB;
233 }
234
235 // ── Who and where ───────────────────────────────────────────────────────
236
237 private workspace(slug: string): Promise<Workspace | null> {
238 const key = slug.toLowerCase();
239 let found = this.workspaces.get(key);
240 if (!found) {
241 found = identityClient(this.env.IDENTITY).getWorkspace(key);
242 this.workspaces.set(key, found);
243 }
244 return found;
245 }
246
247 /** The workspace's people by username, with their names and avatars; asked once per request. */
248 private members(slug: string, workspace: Workspace): Promise<Map<string, Member>> {
249 let found = this.people.get(workspace.id);
250 if (!found) {
251 // Asked as the workspace itself, so it works for agents' calls too.
252 const actor: User = {
253 id: workspace.id,
254 username: workspace.slug,
255 kind: "workspace",
256 verified: true,
257 workspaces: [{ slug: workspace.slug, role: "member" }],
258 };
259 found = identityClient(this.env.IDENTITY)
260 .listMembers(slug, actor)
261 .then((result) => new Map(result.ok ? result.value.map((m) => [m.username, m]) : []))
262 .catch((error) => {
263 console.error("chat could not list members of", slug, error);
264 return new Map<string, Member>();
265 });
266 this.people.set(workspace.id, found);
267 }
268 return found;
269 }
270
271 /** Agents by id; ones the agents service does not know are null. Asked once per request. */
272 private async agentsById(ids: string[]): Promise<Map<string, WorkspaceAgent | null>> {
273 const wanted = [...new Set(ids)].filter((id) => !this.agents.has(id));
274 if (wanted.length) {
275 let found: WorkspaceAgent[] = [];
276 try {
277 found = await workspaceAgentsClient(this.env.AGENTS).byIds(wanted);
278 } catch (error) {
279 console.error("chat could not resolve agents", error);
280 }
281 for (const id of wanted) this.agents.set(id, found.find((a) => a.id === id) ?? null);
282 }
283 return new Map(ids.map((id) => [id, this.agents.get(id) ?? null]));
284 }
285
286 /** An agent of this workspace that is not archived, or null. */
287 private async liveAgent(workspace: Workspace, id: string): Promise<WorkspaceAgent | null> {
288 const agent = (await this.agentsById([id])).get(id) ?? null;
289 return agent && agent.workspace_id === workspace.id && !agent.archived_at ? agent : null;
290 }
291
292 /** How each member key shows, for one workspace. */
293 private async profiles(slug: string, workspace: Workspace, keys: string[]): Promise<Map<string, MemberProfile>> {
294 const principals = [...new Set(keys)].map((key) => parsePrincipalKey(key)).filter((p): p is Principal => !!p);
295 const userIds = principals.filter((p) => p.kind === "user").map((p) => p.id);
296 const agentIds = principals.filter((p) => p.kind === "agent").map((p) => p.id);
297 const unnamed = userIds.filter((id) => !this.usernames.has(id));
298 const [named, people, agents] = await Promise.all([
299 unnamed.length ? identityClient(this.env.IDENTITY).usernames(unnamed).catch(() => ({}) as Record<string, string>) : ({} as Record<string, string>),
300 userIds.length ? this.members(slug, workspace) : new Map<string, Member>(),
301 this.agentsById(agentIds),
302 ]);
303 for (const [id, username] of Object.entries(named)) this.usernames.set(id, username);
304 const out = new Map<string, MemberProfile>();
305 for (const p of principals) {
306 if (p.kind === "user") {
307 const username = this.usernames.get(p.id) ?? null;
308 const person = username ? people.get(username) : undefined;
309 out.set(principalKey(p), {
310 ...p,
311 name: username ?? "ghost",
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar312 display_username: person?.display_username ?? null,
313 // Their display name, else their username as they wrote it
314 // (`memberName` reads this the same way).
315 display_name: person?.name?.trim() || person?.display_username || username || "Former member",
Chat and workspace agents: channels, DMs and named agents you talk to316 avatar: person?.avatar ?? null,
317 role: null,
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)318 title: null,
319 avatar_seed: null,
Chat and workspace agents: channels, DMs and named agents you talk to320 });
321 } else {
322 const agent = agents.get(p.id) ?? null;
323 out.set(principalKey(p), {
324 ...p,
325 name: agent?.handle ?? p.id,
326 display_name: agent?.display_name ?? "Former agent",
327 avatar: agent?.avatar ?? null,
328 role: agent?.role ?? null,
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)329 title: agent?.title || null,
330 avatar_seed: agent?.avatar_seed ?? null,
Chat and workspace agents: channels, DMs and named agents you talk to331 });
332 }
333 }
334 return out;
335 }
336
337 private async profile(slug: string, workspace: Workspace, key: string): Promise<MemberProfile> {
338 return (await this.profiles(slug, workspace, [key])).get(key)!;
339 }
340
341 /** Whether `principal` may be added to a conversation in this workspace. */
342 private async belongs(slug: string, workspace: Workspace, principal: Principal): Promise<boolean> {
343 if (principal.kind === "agent") return !!(await this.liveAgent(workspace, principal.id));
344 if (!this.usernames.has(principal.id)) {
345 const named = await identityClient(this.env.IDENTITY).usernames([principal.id]);
346 for (const [id, username] of Object.entries(named)) this.usernames.set(id, username);
347 }
348 const username = this.usernames.get(principal.id);
349 return !!username && (await this.members(slug, workspace)).has(username);
350 }
351
352 /**
353 * The viewer's workspace, checked: they must belong to it, as in every
354 * other service.
355 */
356 private async viewerWorkspace(slug: string, viewer: Viewer): Promise<Result<Workspace>> {
357 if (!viewer) return fail("unauthenticated", "Sign in to use chat.");
358 if (!slug || !isMember(viewer, slug)) return fail("forbidden", "Only members of a workspace can use its chat.");
359 const workspace = await this.workspace(slug);
360 return workspace ? ok(workspace) : fail("not_found", "No such workspace.");
361 }
362
363 /**
364 * A channel the viewer may read (`read`: any public one in their
365 * workspace, or one they are in) or write in (`member`: one they are in).
366 * A private channel or direct message they are not in is not found, so
367 * its existence does not leak.
368 */
369 private async place(
370 slug: string,
371 channelId: string,
372 viewer: Viewer,
373 need: "read" | "member",
374 ): Promise<Result<Place>> {
375 const found = await this.viewerWorkspace(slug, viewer);
376 if (!found.ok) return found;
377 const workspace = found.value;
378 const [channel, member] = await Promise.all([
379 this.db
380 .prepare("SELECT * FROM channels WHERE id = ? AND workspace_id = ?")
381 .bind(String(channelId ?? ""), workspace.id)
382 .first<ChannelRow>(),
383 this.db
384 .prepare("SELECT * FROM channel_members WHERE channel_id = ? AND principal = ?")
385 .bind(String(channelId ?? ""), userKey(viewer!))
386 .first<MemberRow>(),
387 ]);
388 if (!channel) return fail("not_found", "No such channel.");
389 const open = channel.kind === "channel" && !channel.private;
390 if (!member && !open) return fail("not_found", "No such channel.");
391 if (!member && need === "member") return fail("forbidden", `Join #${channel.name} first.`);
392 return ok({ slug: slug.toLowerCase(), workspace, channel, member });
393 }
394
395 private room(channelId: string) {
396 return this.env.ROOMS.get(this.env.ROOMS.idFromName(channelId));
397 }
398
399 /** Tells everyone looking at a channel, after the answer is sent. */
400 private broadcast(channelId: string, event: ChatLiveEvent, except: string | null = null): void {
401 this.defer(
402 this.room(channelId)
403 .broadcast(event, except)
404 .catch((error: unknown) => console.error("chat could not broadcast to", channelId, error)),
405 );
406 }
407
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)408 /**
409 * Messages as they go out, with their reactions. `me` (a member key) is
410 * the viewer an answer is for; null for what everyone in a room gets,
411 * where no reaction is anyone's own.
412 */
413 private async toMessages(slug: string, workspace: Workspace, rows: MessageRow[], me: string | null = null): Promise<ChatMessage[]> {
414 const reactions = await this.reactionsOf(rows.filter((r) => !r.deleted_at).map((r) => r.id), me);
415 const reactors = [...reactions.values()].flatMap((list) => list.flatMap((r) => r.by));
416 const profiles = await this.profiles(slug, workspace, [...rows.map((r) => r.author), ...reactors]);
Chat and workspace agents: channels, DMs and named agents you talk to417 return rows.map((row) => {
418 const gone = !!row.deleted_at;
419 return {
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)420 reactions: gone
421 ? []
422 : (reactions.get(row.id) ?? []).map((r) => ({ ...r, by: r.by.map((key) => profiles.get(key)!).filter(Boolean) })),
Chat and workspace agents: channels, DMs and named agents you talk to423 id: row.id,
424 channel_id: row.channel_id,
425 author: profiles.get(row.author)!,
426 kind: row.kind,
427 body: gone ? "" : row.body,
428 card: gone || !row.card ? null : (JSON.parse(row.card) as MessageCard),
429 thread_root: row.thread_root,
430 reply_count: row.reply_count,
431 last_reply_at: row.last_reply_at,
432 created_at: row.created_at,
433 edited_at: row.edited_at,
434 deleted_at: row.deleted_at,
435 };
436 });
437 }
438
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)439 /** Reactions on these messages, counted (src/emoji.ts). One read, by the reactions table's key. */
440 private async reactionsOf(ids: string[], me: string | null): Promise<Map<string, ReactionTally[]>> {
441 if (!ids.length) return new Map();
442 const rows = await this.db
443 .prepare(
444 "SELECT message_id, emoji, principal, created_at FROM reactions WHERE message_id IN (SELECT value FROM json_each(?))",
445 )
446 .bind(JSON.stringify(ids))
447 .all<ReactionRow>();
448 return tallyReactions(rows.results, me);
449 }
450
Chat and workspace agents: channels, DMs and named agents you talk to451 private async messageRow(channelId: string, id: string): Promise<MessageRow | null> {
452 return this.db.prepare("SELECT * FROM messages WHERE id = ? AND channel_id = ?").bind(String(id ?? ""), channelId).first<MessageRow>();
453 }
454
455 /** Sends a message as it now is to everyone looking at its channel. */
456 private rebroadcast(place: Place, id: string): void {
457 this.defer(
458 (async () => {
459 const row = await this.messageRow(place.channel.id, id);
460 if (!row) return;
461 const [message] = await this.toMessages(place.slug, place.workspace, [row]);
462 await this.room(place.channel.id).broadcast({ type: "message.updated", message });
463 })().catch((error) => console.error("chat could not rebroadcast", id, error)),
464 );
465 }
466
467 // ── The sidebar ─────────────────────────────────────────────────────────
468
469 /**
470 * Puts a person in the workspace's #general, once. A new workspace has
471 * no channels, so the first sidebar anyone in it asks for creates
472 * #general; and everyone who asks for the sidebar is put in it the first
473 * time, so a new workspace has somewhere to talk and a new member lands
474 * where everyone is. Someone who leaves it is not put back
475 * (`general_joined`). A private channel someone named `general` is never
476 * joined this way.
477 */
478 private async ensureGeneral(workspace: Workspace, me: string): Promise<void> {
479 const seen = await this.db
480 .prepare("SELECT 1 FROM general_joined WHERE workspace_id = ? AND principal = ?")
481 .bind(workspace.id, me)
482 .first();
483 if (seen) return;
484 const at = now();
485 await this.db
486 .prepare(
487 "INSERT OR IGNORE INTO channels (id, workspace_id, kind, name, topic, private, created_by, created_at) VALUES (?, ?, 'channel', ?, ?, 0, ?, ?)",
488 )
489 .bind(newId("chn"), workspace.id, GENERAL, "Anything and everything for the whole workspace.", me, at)
490 .run();
491 const general = await this.db
492 .prepare("SELECT * FROM channels WHERE workspace_id = ? AND name = ?")
493 .bind(workspace.id, GENERAL)
494 .first<ChannelRow>();
495 const statements = [
496 this.db
497 .prepare("INSERT OR IGNORE INTO general_joined (workspace_id, principal, joined_at) VALUES (?, ?, ?)")
498 .bind(workspace.id, me, at),
499 ];
Chat controls, public profiles, shadcn selects, and no Docs tab in a project500 // The workspace's default channels (#general unless its owners chose
501 // others): public and not archived only, whatever was kept.
502 const settings = await this.settings(workspace, general?.id ?? null);
503 const defaults = settings.default_channels.length
504 ? await this.db
505 .prepare(
506 "SELECT * FROM channels WHERE workspace_id = ? AND kind = 'channel' AND private = 0 AND archived_at IS NULL AND id IN (SELECT value FROM json_each(?))",
507 )
508 .bind(workspace.id, JSON.stringify(settings.default_channels))
509 .all<ChannelRow>()
510 : { results: [] as ChannelRow[] };
511 for (const channel of defaults.results) {
512 statements.push(this.joinStatement(channel.id, me, channel.created_by === me ? "owner" : "member", at));
Chat and workspace agents: channels, DMs and named agents you talk to513 }
514 await this.db.batch(statements);
515 }
516
517 /**
518 * Adds a member. Someone joining starts with everything already said
519 * read, so a long channel does not greet them with its whole history as
520 * unread.
521 */
522 private joinStatement(channelId: string, principal: string, role: "owner" | "member", at: string): D1PreparedStatement {
523 return this.db
524 .prepare(
525 "INSERT OR IGNORE INTO channel_members (channel_id, principal, role, last_read_id, joined_at) VALUES (?1, ?2, ?3, (SELECT MAX(id) FROM messages WHERE channel_id = ?1), ?4)",
526 )
527 .bind(channelId, principal, role, at);
528 }
529
530 async sidebar(a: { workspace: string; viewer: Viewer }): Promise<Result<ChatSidebar>> {
531 const found = await this.viewerWorkspace(a.workspace, a.viewer);
532 if (!found.ok) return found;
533 const workspace = found.value;
534 const slug = a.workspace.toLowerCase();
535 const me = userKey(a.viewer!);
536 await this.ensureGeneral(workspace, me);
537
538 const [joined, unread, dmOthers, browsable] = await Promise.all([
539 this.db
540 .prepare(
541 `SELECT c.*, m.starred, m.muted, m.last_read_id
542 FROM channel_members m JOIN channels c ON c.id = m.channel_id
543 WHERE m.principal = ? AND c.workspace_id = ? AND c.archived_at IS NULL`,
544 )
545 .bind(me, workspace.id)
546 .all<ChannelRow & { starred: number; muted: number; last_read_id: string | null }>(),
547 this.db
548 .prepare(
549 `SELECT msg.channel_id, msg.id, msg.author, msg.mentions
550 FROM channel_members m
551 JOIN channels c ON c.id = m.channel_id
552 JOIN messages msg ON msg.channel_id = m.channel_id AND msg.id > COALESCE(m.last_read_id, '')
553 WHERE m.principal = ?1 AND c.workspace_id = ?2 AND c.archived_at IS NULL
554 AND msg.deleted_at IS NULL AND msg.author != ?1
555 LIMIT ${MAX_UNREAD_ROWS}`,
556 )
557 .bind(me, workspace.id)
558 .all<UnreadRow>(),
559 this.db
560 .prepare(
561 `SELECT o.channel_id, o.principal
562 FROM channel_members m
563 JOIN channels c ON c.id = m.channel_id AND c.kind = 'dm'
564 JOIN channel_members o ON o.channel_id = m.channel_id AND o.principal != m.principal
565 WHERE m.principal = ? AND c.workspace_id = ? AND c.archived_at IS NULL
566 ORDER BY o.joined_at, o.principal`,
567 )
568 .bind(me, workspace.id)
569 .all<{ channel_id: string; principal: string }>(),
570 this.db
571 .prepare(
572 `SELECT COUNT(*) AS n FROM channels c
573 WHERE c.workspace_id = ? AND c.kind = 'channel' AND c.private = 0 AND c.archived_at IS NULL
574 AND NOT EXISTS (SELECT 1 FROM channel_members m WHERE m.channel_id = c.id AND m.principal = ?)`,
575 )
576 .bind(workspace.id, me)
577 .first<{ n: number }>(),
578 ]);
579
580 const others = new Map<string, string[]>();
581 for (const row of dmOthers.results) others.set(row.channel_id, [...(others.get(row.channel_id) ?? []), row.principal]);
582 const profiles = await this.profiles(slug, workspace, [me, ...dmOthers.results.map((r) => r.principal)]);
583 const self = profiles.get(me) ?? null;
584 const counts = tally(
585 unread.results,
586 me,
587 self?.name ?? a.viewer!.username,
588 new Map(joined.results.map((row) => [row.id, row.last_read_id])),
589 );
590
591 const entries: ChatSidebarEntry[] = joined.results.map((row) => {
592 const faces = (others.get(row.id) ?? []).map((key) => profiles.get(key)!).filter(Boolean);
593 const count = counts.get(row.id) ?? { unread: 0, mentions: 0 };
594 return {
595 channel: toChannel(row),
596 title: row.kind === "dm" ? dmTitle(faces, self) : (row.name ?? ""),
597 others: row.kind === "dm" ? faces.slice(0, DM_FACES) : [],
598 starred: !!row.starred,
599 muted: !!row.muted,
600 unread: count.unread,
601 mentions: count.mentions,
602 };
603 });
Chat controls, public profiles, shadcn selects, and no Docs tab in a project604 const settings = await this.settings(workspace);
605 return ok({ entries: sidebarOrder(entries), browsable: browsable?.n ?? 0, can: permissionsFor(settings, roleOf(a.viewer, slug)) });
Chat and workspace agents: channels, DMs and named agents you talk to606 }
607
608 // ── Channels ────────────────────────────────────────────────────────────
609
Chat controls, public profiles, shadcn selects, and no Docs tab in a project610 async channel(a: { workspace: string; channel_id: string; viewer: Viewer }): Promise<Result<ChannelDetail>> {
Chat and workspace agents: channels, DMs and named agents you talk to611 const found = await this.place(a.workspace, a.channel_id, a.viewer, "read");
612 if (!found.ok) return found;
Chat controls, public profiles, shadcn selects, and no Docs tab in a project613 const { slug, workspace, channel, member } = found.value;
614 const [rows, settings] = await Promise.all([
615 this.db
616 .prepare("SELECT * FROM channel_members WHERE channel_id = ? ORDER BY joined_at, principal")
617 .bind(channel.id)
618 .all<MemberRow>(),
619 this.settings(workspace),
620 ]);
Chat and workspace agents: channels, DMs and named agents you talk to621 const profiles = await this.profiles(slug, workspace, rows.results.map((r) => r.principal));
622 return ok({
623 channel: toChannel(channel),
Chat controls, public profiles, shadcn selects, and no Docs tab in a project624 can_manage: channel.kind === "channel" && mayManageChannel(settings, roleOf(a.viewer, slug), member?.role ?? null),
Chat and workspace agents: channels, DMs and named agents you talk to625 members: rows.results.map((row) => ({
626 channel_id: row.channel_id,
627 member: profiles.get(row.principal)!,
628 role: row.role,
629 starred: !!row.starred,
630 muted: !!row.muted,
631 last_read_id: row.last_read_id,
632 joined_at: row.joined_at,
633 })),
634 });
635 }
636
637 /** A channel by its name, as the site's URLs name them; read like `channel`. */
Chat controls, public profiles, shadcn selects, and no Docs tab in a project638 async channelByName(a: { workspace: string; name: string; viewer: Viewer }): Promise<Result<ChannelDetail>> {
Chat and workspace agents: channels, DMs and named agents you talk to639 const found = await this.viewerWorkspace(a.workspace, a.viewer);
640 if (!found.ok) return found;
641 const named = channelName(a.name ?? "");
642 if (!named.ok) return fail("not_found", "No such channel.");
643 const row = await this.db
644 .prepare("SELECT id FROM channels WHERE workspace_id = ? AND name = ?")
645 .bind(found.value.id, named.name)
646 .first<{ id: string }>();
647 if (!row) return fail("not_found", "No such channel.");
648 // A private channel the viewer is not in stays not found there.
649 return this.channel({ workspace: a.workspace, channel_id: row.id, viewer: a.viewer });
650 }
651
Chat controls, public profiles, shadcn selects, and no Docs tab in a project652 /**
653 * Every public channel, and the private ones the viewer is in; a private
654 * one they are not in stays unseen. With `archived`, the archived ones.
655 */
656 async browse(a: { workspace: string; viewer: Viewer; archived?: boolean }): Promise<Result<Channel[]>> {
Chat and workspace agents: channels, DMs and named agents you talk to657 const found = await this.viewerWorkspace(a.workspace, a.viewer);
658 if (!found.ok) return found;
659 const rows = await this.db
660 .prepare(
Chat controls, public profiles, shadcn selects, and no Docs tab in a project661 `SELECT * FROM channels c
662 WHERE c.workspace_id = ?1 AND c.kind = 'channel'
663 AND (c.archived_at IS NULL) = (?3 = 0)
664 AND (c.private = 0 OR EXISTS (SELECT 1 FROM channel_members m WHERE m.channel_id = c.id AND m.principal = ?2))
665 ORDER BY c.name`,
Chat and workspace agents: channels, DMs and named agents you talk to666 )
Chat controls, public profiles, shadcn selects, and no Docs tab in a project667 .bind(found.value.id, userKey(a.viewer!), a.archived === true ? 1 : 0)
Chat and workspace agents: channels, DMs and named agents you talk to668 .all<ChannelRow>();
669 return ok(rows.results.map(toChannel));
670 }
671
672 async createChannel(a: { workspace: string; viewer: Viewer; input: NewChannel }): Promise<Result<Channel>> {
673 const found = await this.viewerWorkspace(a.workspace, a.viewer);
674 if (!found.ok) return found;
675 const workspace = found.value;
676 const named = channelName(a.input?.name ?? "");
677 if (!named.ok) return fail("invalid", named.message);
678 const topic = typeof a.input?.topic === "string" ? a.input.topic.trim() : "";
679 if (topic.length > MAX_TOPIC) return fail("invalid", `A topic is at most ${MAX_TOPIC} characters.`);
Chat controls, public profiles, shadcn selects, and no Docs tab in a project680 const isPrivate = !!a.input?.private;
681 if (!mayCreateChannel(await this.settings(workspace), roleOf(a.viewer, a.workspace), isPrivate)) {
682 return fail("forbidden", `Only owners can create ${isPrivate ? "private" : "public"} channels in this workspace.`);
683 }
Chat and workspace agents: channels, DMs and named agents you talk to684 const taken = await this.db
685 .prepare("SELECT 1 FROM channels WHERE workspace_id = ? AND name = ?")
686 .bind(workspace.id, named.name)
687 .first();
688 if (taken) return fail("conflict", `#${named.name} already exists.`);
689 const me = userKey(a.viewer!);
690 const row: ChannelRow = {
691 id: newId("chn"),
692 workspace_id: workspace.id,
693 kind: "channel",
694 name: named.name,
695 topic: topic || null,
Chat controls, public profiles, shadcn selects, and no Docs tab in a project696 private: isPrivate ? 1 : 0,
Chat and workspace agents: channels, DMs and named agents you talk to697 dm_key: null,
698 created_by: me,
699 created_at: now(),
700 archived_at: null,
701 last_message_at: null,
702 };
703 try {
704 await this.db.batch([
705 this.db
706 .prepare(
707 "INSERT INTO channels (id, workspace_id, kind, name, topic, private, created_by, created_at) VALUES (?, ?, 'channel', ?, ?, ?, ?, ?)",
708 )
709 .bind(row.id, row.workspace_id, row.name, row.topic, row.private, me, row.created_at),
710 this.joinStatement(row.id, me, "owner", row.created_at),
711 ]);
712 } catch (error) {
713 if (String(error).includes("UNIQUE")) return fail("conflict", `#${named.name} already exists.`);
714 throw error;
715 }
716 return ok(toChannel(row));
717 }
718
719 async openDm(a: { workspace: string; viewer: Viewer; members: Principal[] }): Promise<Result<Channel>> {
720 const found = await this.viewerWorkspace(a.workspace, a.viewer);
721 if (!found.ok) return found;
722 const workspace = found.value;
723 const slug = a.workspace.toLowerCase();
724 const me = userKey(a.viewer!);
725 const asked = Array.isArray(a.members) ? a.members : [];
726 const principals: Principal[] = [];
727 for (const member of asked) {
728 const p = member && parsePrincipalKey(`${member.kind}:${member.id}`);
729 if (!p) return fail("invalid", "Each member is a person or an agent, by id.");
730 principals.push(p);
731 }
732 const members = dmMembers(me, principals.map(principalKey));
733 if (members.length > MAX_DM_MEMBERS) {
734 return fail("invalid", `A direct message has at most ${MAX_DM_MEMBERS} people and agents. Make a private channel instead.`);
735 }
736 const key = dmKey(members);
737 const existing = await this.db
738 .prepare("SELECT * FROM channels WHERE workspace_id = ? AND dm_key = ?")
739 .bind(workspace.id, key)
740 .first<ChannelRow>();
741 if (existing) return ok(toChannel(existing));
742
743 for (const member of members) {
744 if (member === me) continue;
745 const p = parsePrincipalKey(member)!;
746 if (!(await this.belongs(slug, workspace, p))) {
747 return fail("not_found", p.kind === "agent" ? "No such agent in this workspace." : "That person is not in this workspace.");
748 }
749 }
750 const at = now();
751 await this.db
752 .prepare(
753 "INSERT OR IGNORE INTO channels (id, workspace_id, kind, private, dm_key, created_by, created_at) VALUES (?, ?, 'dm', 1, ?, ?, ?)",
754 )
755 .bind(newId("chn"), workspace.id, key, me, at)
756 .run();
757 // Read back by key: if two people opened it at once, both get the one that won.
758 const channel = await this.db
759 .prepare("SELECT * FROM channels WHERE workspace_id = ? AND dm_key = ?")
760 .bind(workspace.id, key)
761 .first<ChannelRow>();
762 if (!channel) return fail("conflict", "The direct message could not be opened. Try again.");
763 await this.db.batch(members.map((member) => this.joinStatement(channel.id, member, "member", at)));
764 return ok(toChannel(channel));
765 }
766
767 async join(a: { workspace: string; channel_id: string; viewer: Viewer }): Promise<Result<null>> {
768 const found = await this.place(a.workspace, a.channel_id, a.viewer, "read");
769 if (!found.ok) return found;
770 const { channel, member } = found.value;
771 if (member) return ok(null);
772 if (channel.archived_at) return fail("invalid", "This channel is archived.");
773 await this.joinStatement(channel.id, userKey(a.viewer!), "member", now()).run();
774 return ok(null);
775 }
776
777 async leave(a: { workspace: string; channel_id: string; viewer: Viewer }): Promise<Result<null>> {
778 const found = await this.place(a.workspace, a.channel_id, a.viewer, "read");
779 if (!found.ok) return found;
780 const { channel, member } = found.value;
781 if (!member) return ok(null);
782 if (channel.kind === "dm") return fail("invalid", "A direct message can't be left. Mute it instead.");
783 const me = userKey(a.viewer!);
784 await this.db.prepare("DELETE FROM channel_members WHERE channel_id = ? AND principal = ?").bind(channel.id, me).run();
785 // Out of a private channel, they may no longer read it, live either.
786 if (channel.private) {
787 this.defer(this.room(channel.id).drop(me).catch((error: unknown) => console.error("chat could not drop", me, error)));
788 }
789 return ok(null);
790 }
791
792 async invite(a: { workspace: string; channel_id: string; viewer: Viewer; member: Principal }): Promise<Result<null>> {
793 const found = await this.place(a.workspace, a.channel_id, a.viewer, "member");
794 if (!found.ok) return found;
795 const { slug, workspace, channel } = found.value;
796 if (channel.kind === "dm") return fail("invalid", "People can't be added to a direct message. Start a new one with everyone in it.");
797 if (channel.archived_at) return fail("invalid", "This channel is archived.");
798 const p = a.member && parsePrincipalKey(`${a.member.kind}:${a.member.id}`);
799 if (!p) return fail("invalid", "Invite a person or an agent, by id.");
800 if (!(await this.belongs(slug, workspace, p))) {
801 return fail("not_found", p.kind === "agent" ? "No such agent in this workspace." : "That person is not in this workspace.");
802 }
803 await this.joinStatement(channel.id, principalKey(p), "member", now()).run();
804 return ok(null);
805 }
806
807 async setPreferences(a: {
808 workspace: string;
809 channel_id: string;
810 viewer: Viewer;
811 prefs: { starred?: boolean; muted?: boolean };
812 }): Promise<Result<null>> {
813 const found = await this.place(a.workspace, a.channel_id, a.viewer, "member");
814 if (!found.ok) return found;
815 const starred = typeof a.prefs?.starred === "boolean" ? (a.prefs.starred ? 1 : 0) : null;
816 const muted = typeof a.prefs?.muted === "boolean" ? (a.prefs.muted ? 1 : 0) : null;
817 await this.db
818 .prepare(
819 "UPDATE channel_members SET starred = COALESCE(?, starred), muted = COALESCE(?, muted) WHERE channel_id = ? AND principal = ?",
820 )
821 .bind(starred, muted, found.value.channel.id, userKey(a.viewer!))
822 .run();
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)823 // Notify: the badge counts the conversation again, or leaves it out, in every tab.
824 if (muted !== null) {
825 this.defer(
826 notifyMuted(this.env.NOTIFY, { slug: found.value.slug, channel_id: found.value.channel.id, user_id: a.viewer!.id, muted: !!muted }).catch((error) =>
827 console.error("chat could not notify a mute", error),
828 ),
829 );
830 }
Chat and workspace agents: channels, DMs and named agents you talk to831 return ok(null);
832 }
833
Chat controls, public profiles, shadcn selects, and no Docs tab in a project834 // ── What owners decide (src/settings.ts) ────────────────────────────────
835
836 /** The row of a workspace's chat settings, read once per request. */
837 private settingsRow(workspace: Workspace): Promise<SettingsRow | null> {
838 let found = this.settingsRows.get(workspace.id);
839 if (!found) {
840 found = this.db
841 .prepare("SELECT emoji_upload, public_channels, private_channels, manage_channels, default_channels FROM chat_settings WHERE workspace_id = ?")
842 .bind(workspace.id)
843 .first<SettingsRow>();
844 this.settingsRows.set(workspace.id, found);
845 }
846 return found;
847 }
848
849 /**
850 * A workspace's chat settings. Default channels never chosen are
851 * #general, looked up unless `generalId` is given.
852 */
853 private async settings(workspace: Workspace, generalId?: string | null): Promise<ChatSettings> {
854 const row = await this.settingsRow(workspace);
855 if (row?.default_channels != null || generalId !== undefined) return settingsOf(row, generalId ?? null);
856 const general = await this.db
857 .prepare("SELECT id FROM channels WHERE workspace_id = ? AND name = ? AND kind = 'channel'")
858 .bind(workspace.id, GENERAL)
859 .first<{ id: string }>();
860 return settingsOf(row, general?.id ?? null);
861 }
862
863 async chatSettings(a: { workspace: string; viewer: Viewer }): Promise<Result<ChatSettingsView>> {
864 const found = await this.viewerWorkspace(a.workspace, a.viewer);
865 if (!found.ok) return found;
866 const workspace = found.value;
867 const [settings, channels] = await Promise.all([
868 this.settings(workspace),
869 this.db
870 .prepare("SELECT * FROM channels WHERE workspace_id = ? AND kind = 'channel' AND private = 0 AND archived_at IS NULL ORDER BY name")
871 .bind(workspace.id)
872 .all<ChannelRow>(),
873 ]);
874 return ok({ settings, can: permissionsFor(settings, roleOf(a.viewer, a.workspace)), channels: channels.results.map(toChannel) });
875 }
876
877 async setChatSettings(a: { workspace: string; viewer: Viewer; change: Partial<ChatSettings> }): Promise<Result<ChatSettings>> {
878 const found = await this.viewerWorkspace(a.workspace, a.viewer);
879 if (!found.ok) return found;
880 const workspace = found.value;
881 if (roleOf(a.viewer, a.workspace) !== "owner") return fail("forbidden", "Only owners can change the workspace's chat settings.");
882 const checked = settingsChange(a.change);
883 if (!checked.ok) return fail("invalid", checked.message);
884 const change = checked.change;
885 if (change.default_channels) {
886 // Only the workspace's public channels that are not archived: a private
887 // one would put people somewhere they were never invited.
888 const rows = await this.db
889 .prepare(
890 "SELECT id FROM channels WHERE workspace_id = ? AND kind = 'channel' AND private = 0 AND archived_at IS NULL AND id IN (SELECT value FROM json_each(?))",
891 )
892 .bind(workspace.id, JSON.stringify(change.default_channels))
893 .all<{ id: string }>();
894 const open = new Set(rows.results.map((r) => r.id));
895 if (change.default_channels.some((id) => !open.has(id))) return fail("invalid", "Default channels must be public channels that are not archived.");
896 }
897 const next: ChatSettings = { ...(await this.settings(workspace)), ...change };
898 const row = rowFor(next);
899 await this.db
900 .prepare(
901 `INSERT INTO chat_settings (workspace_id, emoji_upload, public_channels, private_channels, manage_channels, default_channels)
902 VALUES (?1, ?2, ?3, ?4, ?5, ?6)
903 ON CONFLICT (workspace_id) DO UPDATE SET emoji_upload = ?2, public_channels = ?3, private_channels = ?4, manage_channels = ?5, default_channels = ?6`,
904 )
905 .bind(workspace.id, row.emoji_upload, row.public_channels, row.private_channels, row.manage_channels, row.default_channels)
906 .run();
907 this.settingsRows.delete(workspace.id);
908 return ok(next);
909 }
910
911 /**
912 * Renames, archives or unarchives a channel, or changes its topic. The
913 * workspace's `manage_channels` setting says who may do the first three;
914 * any member may change the topic. #general stays #general, and stays.
915 */
916 async updateChannel(a: { workspace: string; channel_id: string; viewer: Viewer; change: ChannelChange }): Promise<Result<Channel>> {
917 // Archived or not, a channel is found the same way; a private one only by its members.
918 const found = await this.place(a.workspace, a.channel_id, a.viewer, "read");
919 if (!found.ok) return found;
920 const { slug, workspace, channel, member } = found.value;
921 if (channel.kind === "dm") return fail("invalid", "A direct message has no name or topic to change.");
922 const change = a.change ?? {};
923 const next: ChannelRow = { ...channel };
924 if (change.name !== undefined || change.archived !== undefined) {
925 const settings = await this.settings(workspace);
926 if (!mayManageChannel(settings, roleOf(a.viewer, slug), member?.role ?? null)) {
927 return fail(
928 "forbidden",
929 settings.manage_channels === "owners"
930 ? "Only workspace owners can rename or archive channels here."
931 : "Only this channel's owners and workspace owners can rename or archive it.",
932 );
933 }
934 if (channel.name === GENERAL) return fail("invalid", "#general is where everyone is: it can't be renamed or archived.");
935 }
936 if (change.name !== undefined) {
937 const named = channelName(String(change.name ?? ""));
938 if (!named.ok) return fail("invalid", named.message);
939 if (named.name !== channel.name) {
940 const taken = await this.db
941 .prepare("SELECT 1 FROM channels WHERE workspace_id = ? AND name = ? AND id != ?")
942 .bind(workspace.id, named.name, channel.id)
943 .first();
944 if (taken || named.name === GENERAL) return fail("conflict", `#${named.name} already exists.`);
945 }
946 next.name = named.name;
947 }
948 if (change.topic !== undefined) {
949 if (!member) return fail("forbidden", `Join #${channel.name} first.`);
950 const topic = typeof change.topic === "string" ? change.topic.trim() : "";
951 if (topic.length > MAX_TOPIC) return fail("invalid", `A topic is at most ${MAX_TOPIC} characters.`);
952 next.topic = topic || null;
953 }
954 if (change.archived !== undefined) {
955 next.archived_at = change.archived ? (channel.archived_at ?? now()) : null;
956 } else if (channel.archived_at && (change.name !== undefined || change.topic !== undefined)) {
957 return fail("invalid", "This channel is archived. Unarchive it first.");
958 }
959 try {
960 await this.db
961 .prepare("UPDATE channels SET name = ?, topic = ?, archived_at = ? WHERE id = ?")
962 .bind(next.name, next.topic, next.archived_at, channel.id)
963 .run();
964 } catch (error) {
965 if (String(error).includes("UNIQUE")) return fail("conflict", `#${next.name} already exists.`);
966 throw error;
967 }
968 const updated = toChannel(next);
969 this.broadcast(channel.id, { type: "channel.updated", channel: updated });
970 return ok(updated);
971 }
972
Chat and workspace agents: channels, DMs and named agents you talk to973 // ── Messages ────────────────────────────────────────────────────────────
974
975 /**
976 * Newest first, a page at a time. With `thread_root`, that thread's
977 * replies, and the message they reply to as the oldest once the page
978 * reaches the start of the thread; without, the channel's top-level
979 * messages. A deleted message stays only while replies hang off it.
980 */
981 async messages(a: {
982 workspace: string;
983 channel_id: string;
984 viewer: Viewer;
985 before?: string | null;
986 after?: string | null;
987 limit?: number | null;
988 thread_root?: string | null;
989 }): Promise<Result<MessagePage>> {
990 const found = await this.place(a.workspace, a.channel_id, a.viewer, "read");
991 if (!found.ok) return found;
992 const { slug, workspace, channel } = found.value;
993 const size = pageSize(a.limit);
994 // Ids are lowercase letters, digits and `_`, so `~` sorts after every
995 // one: the first page reads from the newest as a range of the index.
996 const before = typeof a.before === "string" && a.before ? a.before : "~";
997 const root = typeof a.thread_root === "string" && a.thread_root ? a.thread_root : null;
998 if (typeof a.after === "string" && a.after) {
999 // Catching up after a reconnect: what came after, oldest first. Deleted
1000 // ones too, so the client drops them; read one past the page to know
1001 // whether there is more.
1002 const newer = root
1003 ? await this.db
1004 .prepare("SELECT * FROM messages WHERE thread_root = ? AND channel_id = ? AND id > ? ORDER BY id LIMIT ?")
1005 .bind(root, channel.id, a.after, size + 1)
1006 .all<MessageRow>()
1007 : await this.db
1008 .prepare("SELECT * FROM messages WHERE channel_id = ? AND thread_root IS NULL AND id > ? ORDER BY id LIMIT ?")
1009 .bind(channel.id, a.after, size + 1)
1010 .all<MessageRow>();
1011 const page = pageOf(newer.results, size);
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)1012 return ok({ messages: await this.toMessages(slug, workspace, page.rows, userKey(a.viewer!)), older: null, newer: page.older });
Chat and workspace agents: channels, DMs and named agents you talk to1013 }
1014 const rows = root
1015 ? await this.db
1016 .prepare(
1017 `SELECT * FROM messages WHERE thread_root = ?1 AND channel_id = ?2 AND id < ?3
1018 ORDER BY id DESC LIMIT ?4`,
1019 )
1020 .bind(root, channel.id, before, size + 1)
1021 .all<MessageRow>()
1022 : await this.db
1023 .prepare(
1024 `SELECT * FROM messages
1025 WHERE channel_id = ?1 AND thread_root IS NULL AND id < ?2
1026 AND (deleted_at IS NULL OR reply_count > 0)
1027 ORDER BY id DESC LIMIT ?3`,
1028 )
1029 .bind(channel.id, before, size + 1)
1030 .all<MessageRow>();
1031 const page = pageOf(rows.results, size);
1032 let list = page.rows;
1033 if (root && page.older === null) {
1034 const first = await this.messageRow(channel.id, root);
1035 if (first) list = [...list, first];
1036 }
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)1037 return ok({ messages: await this.toMessages(slug, workspace, list, userKey(a.viewer!)), older: page.older });
Chat and workspace agents: channels, DMs and named agents you talk to1038 }
1039
1040 /**
1041 * Writes a message and everything that follows from it: the thread's
1042 * reply count, the channel's last activity, the author's own read mark,
1043 * the meter; then, after answering, tells the room and wakes the agents
1044 * it is for.
1045 */
1046 private async write(
1047 place: Place,
1048 author: string,
1049 input: { body: string; card: MessageCard | null; thread_root: string | null },
1050 chain: Chain<AskerAccess>,
1051 ): Promise<Result<ChatMessage>> {
1052 const { channel, workspace } = place;
1053 if (channel.archived_at) return fail("invalid", "This channel is archived.");
1054 let threadRoot: string | null = null;
1055 if (input.thread_root) {
1056 const root = await this.messageRow(channel.id, input.thread_root);
1057 if (!root || (root.deleted_at && !root.reply_count)) return fail("not_found", "No such message to reply to.");
1058 // A reply to a reply goes in the same thread.
1059 threadRoot = root.thread_root ?? root.id;
1060 }
1061 const at = now();
1062 const handles = mentionedHandles(input.body);
1063 const row: MessageRow = {
1064 id: newId("msg"),
1065 channel_id: channel.id,
1066 author,
1067 kind: input.card ? "card" : "text",
1068 body: input.body,
1069 card: input.card ? JSON.stringify(input.card) : null,
1070 mentions: mentionsColumn(handles),
1071 thread_root: threadRoot,
1072 reply_count: 0,
1073 last_reply_at: null,
1074 created_at: at,
1075 edited_at: null,
1076 deleted_at: null,
1077 };
1078 const statements = [
1079 this.db
1080 .prepare(
1081 "INSERT INTO messages (id, channel_id, author, kind, body, card, mentions, thread_root, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
1082 )
1083 .bind(row.id, row.channel_id, author, row.kind, row.body, row.card, row.mentions, threadRoot, at),
1084 this.db.prepare("UPDATE channels SET last_message_at = ? WHERE id = ?").bind(at, channel.id),
1085 // What you wrote, you have read.
1086 this.db
1087 .prepare("UPDATE channel_members SET last_read_id = ? WHERE channel_id = ? AND principal = ?")
1088 .bind(row.id, channel.id, author),
1089 this.db
1090 .prepare(
1091 `INSERT INTO chat_meter (workspace_id, day, messages, bytes) VALUES (?, ?, 1, ?)
1092 ON CONFLICT (workspace_id, day) DO UPDATE SET messages = messages + 1, bytes = bytes + excluded.bytes`,
1093 )
1094 .bind(workspace.id, meterDay(at), bytesOf(row.body) + bytesOf(row.card ?? "")),
1095 ];
1096 if (threadRoot) {
1097 statements.push(
1098 this.db
1099 .prepare("UPDATE messages SET reply_count = reply_count + 1, last_reply_at = ? WHERE id = ?")
1100 .bind(at, threadRoot),
1101 );
1102 }
1103 await this.db.batch(statements);
1104
1105 const [message] = await this.toMessages(place.slug, workspace, [row]);
1106 this.broadcast(channel.id, { type: "message.created", message });
1107 if (threadRoot) this.rebroadcast(place, threadRoot);
1108 this.defer(
1109 this.wake(place, row, handles, chain).catch((error) => console.error("chat could not hand", row.id, "to agents", error)),
1110 );
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)1111 // Notify: counts for everyone in the conversation, a notification for those it is for.
1112 this.defer(
1113 notifyMessage(this.db, this.env.NOTIFY, (keys) => this.profiles(place.slug, workspace, keys), { slug: place.slug, channel, row, handles }).catch(
1114 (error) => console.error("chat could not notify about", row.id, error),
1115 ),
1116 );
Chat and workspace agents: channels, DMs and named agents you talk to1117 return ok(message);
1118 }
1119
1120 /** Hands a new message to the agents it is for (src/delivery.ts). */
1121 private async wake(place: Place, row: MessageRow, handles: string[], chain: Chain<AskerAccess>): Promise<void> {
1122 const { channel, workspace } = place;
1123 // Only an agent's message mentioning someone, or a person's, can wake anyone.
1124 if (row.author.startsWith("agent:") && !handles.length) return;
1125 if (channel.kind === "channel" && !handles.length) return;
1126 const members = await this.db
1127 .prepare("SELECT principal FROM channel_members WHERE channel_id = ? AND principal LIKE 'agent:%'")
1128 .bind(channel.id)
1129 .all<{ principal: string }>();
1130 const ids = members.results.map((m) => m.principal.slice("agent:".length));
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)1131 let found = await this.agentsById(ids);
1132 const orchestratorIsMember = [...found.values()].some((agent) => !!agent?.builtin && agent.workspace_id === workspace.id && !agent.archived_at);
1133 if (addsOrchestrator({ channelKind: channel.kind, mentioned: handles, orchestratorIsMember })) {
1134 // Mentioning @g1t brings it in: every workspace has it, nobody invites it.
1135 const builtin = await workspaceAgentsClient(this.env.AGENTS)
1136 .builtin(place.slug, workspace.id)
1137 .catch((error: unknown) => {
1138 console.error("chat could not find @g1t for", place.slug, error);
1139 return null;
1140 });
1141 if (builtin?.ok) {
1142 await this.joinStatement(channel.id, `agent:${builtin.value.id}`, "member", now()).run();
1143 this.agents.set(builtin.value.id, builtin.value);
1144 ids.push(builtin.value.id);
1145 found = await this.agentsById(ids);
1146 }
1147 }
Chat and workspace agents: channels, DMs and named agents you talk to1148 if (!ids.length) return;
1149 const agents = [...found.values()].filter(
1150 (agent): agent is WorkspaceAgent => !!agent && agent.workspace_id === workspace.id && !agent.archived_at,
1151 );
1152 const wakes = deliveries({
1153 author: row.author,
1154 hops: chain.hops,
1155 channelKind: channel.kind,
1156 agents: agents.map((agent) => ({ id: agent.id, handle: agent.handle })),
1157 mentioned: handles,
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)1158 notTo: sender(chain.chain),
Chat and workspace agents: channels, DMs and named agents you talk to1159 });
1160 const client = workspaceAgentsClient(this.env.AGENTS);
1161 await Promise.all(
1162 wakes.map((wake) =>
1163 client
1164 .deliver(
1165 delivery(
1166 {
1167 workspace: place.slug,
1168 workspace_id: workspace.id,
1169 channel_id: channel.id,
1170 channel_kind: channel.kind,
1171 channel_name: channel.kind === "dm" ? null : channel.name,
1172 },
1173 wake,
1174 row,
1175 chain,
1176 ) satisfies AgentDelivery,
1177 )
1178 .then((result) => {
1179 if (!result.ok) console.error("agents refused delivery to", wake.agent_id, result.error.message);
1180 })
1181 .catch((error) => console.error("chat could not deliver to", wake.agent_id, error)),
1182 ),
1183 );
1184 }
1185
1186 async post(a: { workspace: string; channel_id: string; viewer: Viewer; message: PostMessage }): Promise<Result<ChatMessage>> {
1187 const found = await this.place(a.workspace, a.channel_id, a.viewer, "read");
1188 if (!found.ok) return found;
1189 const body = messageBody(a.message?.body);
1190 if (!body.ok) return fail("invalid", body.message);
1191 const me = userKey(a.viewer!);
1192 let place = found.value;
1193 if (!place.member) {
1194 // Saying something in a public channel joins it, as reading does not.
1195 if (place.channel.archived_at) return fail("invalid", "This channel is archived.");
1196 await this.joinStatement(place.channel.id, me, "member", now()).run();
1197 place = { ...place, member: { channel_id: place.channel.id, principal: me } as MemberRow };
1198 }
1199 return this.write(
1200 place,
1201 me,
1202 { body: body.body, card: null, thread_root: a.message?.thread_root ?? null },
1203 // A person's message starts a chain.
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)1204 { hops: 0, asked_by: a.viewer!.id, asker: askerAccess(a.viewer!, a.workspace), chain: [] },
Chat and workspace agents: channels, DMs and named agents you talk to1205 );
1206 }
1207
1208 async edit(a: { workspace: string; channel_id: string; viewer: Viewer; id: string; body: string }): Promise<Result<ChatMessage>> {
1209 const found = await this.place(a.workspace, a.channel_id, a.viewer, "read");
1210 if (!found.ok) return found;
1211 const place = found.value;
1212 const row = await this.messageRow(place.channel.id, a.id);
1213 if (!row || row.deleted_at) return fail("not_found", "No such message.");
1214 if (row.author !== userKey(a.viewer!)) return fail("forbidden", "Only its author can edit a message.");
1215 const body = messageBody(a.body, !!row.card);
1216 if (!body.ok) return fail("invalid", body.message);
1217 const at = now();
1218 const mentions = mentionsColumn(mentionedHandles(body.body));
1219 await this.db
1220 .prepare("UPDATE messages SET body = ?, mentions = ?, edited_at = ? WHERE id = ?")
1221 .bind(body.body, mentions, at, row.id)
1222 .run();
1223 const [message] = await this.toMessages(place.slug, place.workspace, [{ ...row, body: body.body, mentions, edited_at: at }]);
1224 this.broadcast(place.channel.id, { type: "message.updated", message });
1225 return ok(message);
1226 }
1227
1228 /** Deletes a message, keeping its place so its thread still hangs together. Its author or a workspace owner may. */
1229 async remove(a: { workspace: string; channel_id: string; viewer: Viewer; id: string }): Promise<Result<null>> {
1230 const found = await this.place(a.workspace, a.channel_id, a.viewer, "read");
1231 if (!found.ok) return found;
1232 const place = found.value;
1233 const row = await this.messageRow(place.channel.id, a.id);
1234 if (!row || row.deleted_at) return fail("not_found", "No such message.");
1235 if (row.author !== userKey(a.viewer!) && !isOwner(a.viewer, a.workspace)) {
1236 return fail("forbidden", "Only its author or a workspace owner can delete a message.");
1237 }
1238 const statements = [
1239 this.db
1240 .prepare("UPDATE messages SET deleted_at = ?, body = '', card = NULL, mentions = '' WHERE id = ?")
1241 .bind(now(), row.id),
1242 ];
1243 if (row.thread_root) {
1244 statements.push(
1245 this.db.prepare("UPDATE messages SET reply_count = MAX(reply_count - 1, 0) WHERE id = ?").bind(row.thread_root),
1246 );
1247 }
1248 await this.db.batch(statements);
1249 this.broadcast(place.channel.id, { type: "message.deleted", channel_id: place.channel.id, id: row.id });
1250 if (row.thread_root) this.rebroadcast(place, row.thread_root);
1251 return ok(null);
1252 }
1253
1254 async markRead(a: { workspace: string; channel_id: string; viewer: Viewer; id: string }): Promise<Result<null>> {
1255 const found = await this.place(a.workspace, a.channel_id, a.viewer, "read");
1256 if (!found.ok) return found;
1257 const { channel, member } = found.value;
1258 // Someone reading a public channel they have not joined keeps no read state.
1259 if (!member) return ok(null);
1260 const id = typeof a.id === "string" ? a.id : "";
1261 if (!id) return fail("invalid", "Say which message was read.");
1262 // Only forward: reading an old thread does not mark newer messages unread.
1263 const changed = await this.db
1264 .prepare(
1265 "UPDATE channel_members SET last_read_id = ?1 WHERE channel_id = ?2 AND principal = ?3 AND (last_read_id IS NULL OR last_read_id < ?1)",
1266 )
1267 .bind(id, channel.id, member.principal)
1268 .run();
1269 if (changed.meta.changes) {
1270 this.broadcast(channel.id, {
1271 type: "read",
1272 channel_id: channel.id,
1273 principal: { kind: "user", id: a.viewer!.id },
1274 last_read_id: id,
1275 });
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)1276 // Notify: the read drops the counts in every tab of theirs.
1277 this.defer(
1278 notifyRead(this.db, this.env.NOTIFY, { slug: found.value.slug, channel_id: channel.id, user_id: a.viewer!.id, username: a.viewer!.username, last_read_id: id }).catch(
1279 (error) => console.error("chat could not notify a read", error),
1280 ),
1281 );
Chat and workspace agents: channels, DMs and named agents you talk to1282 }
1283 return ok(null);
1284 }
1285
1286 // ── Agents ──────────────────────────────────────────────────────────────
1287
1288 /** The channel and agent for an agent's call: the agent must be of the workspace and in the channel. */
1289 private async agentPlace(slug: string, channelId: string, agentId: string): Promise<Result<{ place: Place; agent: WorkspaceAgent }>> {
1290 const workspace = await this.workspace(String(slug ?? ""));
1291 if (!workspace) return fail("not_found", "No such workspace.");
1292 const agent = await this.liveAgent(workspace, String(agentId ?? ""));
1293 if (!agent) return fail("not_found", "No such agent in this workspace.");
1294 const key = principalKey({ kind: "agent", id: agent.id });
1295 const [channel, member] = await Promise.all([
1296 this.db
1297 .prepare("SELECT * FROM channels WHERE id = ? AND workspace_id = ?")
1298 .bind(String(channelId ?? ""), workspace.id)
1299 .first<ChannelRow>(),
1300 this.db.prepare("SELECT * FROM channel_members WHERE channel_id = ? AND principal = ?").bind(String(channelId ?? ""), key).first<MemberRow>(),
1301 ]);
1302 if (!channel) return fail("not_found", "No such channel.");
1303 if (!member) return fail("forbidden", "The agent is not a member of this channel.");
1304 return ok({ place: { slug: slug.toLowerCase(), workspace, channel, member }, agent });
1305 }
1306
1307 async postAsAgent(a: { workspace: string; channel_id: string; agent_id: string; message: AgentPostMessage }): Promise<Result<ChatMessage>> {
1308 const found = await this.agentPlace(a.workspace, a.channel_id, a.agent_id);
1309 if (!found.ok) return found;
1310 const { place, agent } = found.value;
1311 const card = a.message?.card == null ? null : cleanCard(a.message.card);
1312 if (a.message?.card != null && !card) return fail("invalid", "A card needs a kind and a title.");
1313 const body = messageBody(a.message?.body ?? "", !!card);
1314 if (!body.ok) return fail("invalid", body.message);
1315 const hops = typeof a.message?.hops === "number" && a.message.hops >= 0 ? Math.floor(a.message.hops) : 0;
1316 const askedBy = typeof a.message?.asked_by === "string" && a.message.asked_by ? a.message.asked_by : agent.created_by;
1317 return this.write(
1318 place,
1319 principalKey({ kind: "agent", id: agent.id }),
1320 { body: body.body, card, thread_root: a.message?.thread_root ?? null },
1321 // The asker carries on from the delivery the agent is answering;
1322 // without one, agents it wakes treat the asker as unable to change code.
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)1323 { hops, asked_by: askedBy, asker: cleanAsker(a.message?.asker), chain: chainFor(a.message?.chain, agent.id) },
Chat and workspace agents: channels, DMs and named agents you talk to1324 );
1325 }
1326
1327 /**
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar1328 * A person presses an action on a card. They must be able to read the
1329 * conversation; the card must offer the action; its owner (agents)
1330 * decides whether this person may, does it, and updates the card.
1331 */
1332 async cardAction(a: {
1333 workspace: string;
1334 channel_id: string;
1335 viewer: Viewer;
1336 message_id: string;
1337 action_id: string;
1338 input?: string | null;
1339 }): Promise<Result<CardActionResult>> {
1340 const found = await this.place(a.workspace, a.channel_id, a.viewer, "read");
1341 if (!found.ok) return found;
1342 const place = found.value;
1343 const row = await this.messageRow(place.channel.id, String(a.message_id ?? ""));
1344 if (!row || row.deleted_at || !row.card) return fail("not_found", "No such card.");
1345 const card = JSON.parse(row.card) as MessageCard;
1346 const action = card.actions?.find((x) => x.id === a.action_id);
1347 if (!action || action.href || card.owner !== "agents") return fail("invalid", "That card has no such action.");
1348 const input = typeof a.input === "string" ? a.input.slice(0, 4000) : null;
1349 if (action.input && !input?.trim()) return fail("invalid", `${action.input.label || "A value"} is needed.`);
1350 return workspaceAgentsClient(this.env.AGENTS).cardAction({
1351 workspace: place.slug,
1352 channel_id: place.channel.id,
1353 message_id: row.id,
1354 viewer: a.viewer!,
1355 card: { kind: card.kind, ref: card.ref ?? null },
1356 action_id: action.id,
1357 input,
1358 });
1359 }
1360
1361 /**
Chat controls, public profiles, shadcn selects, and no Docs tab in a project1362 * Changes a message the agent posted (a session's live card, say): its
1363 * body, its card, or both. Only the agent's own messages; it wakes
1364 * nobody, and everyone in the conversation sees it change.
1365 */
1366 async updateAsAgent(a: {
1367 workspace: string;
1368 channel_id: string;
1369 agent_id: string;
1370 id: string;
1371 change: { body?: string; card?: MessageCard | null };
1372 }): Promise<Result<ChatMessage>> {
1373 const found = await this.agentPlace(a.workspace, a.channel_id, a.agent_id);
1374 if (!found.ok) return found;
1375 const { place, agent } = found.value;
1376 const row = await this.messageRow(place.channel.id, String(a.id ?? ""));
1377 if (!row || row.deleted_at) return fail("not_found", "No such message.");
1378 if (row.author !== principalKey({ kind: "agent", id: agent.id })) return fail("forbidden", "An agent can change only its own messages.");
1379 const change = a.change ?? {};
1380 const card = change.card === undefined ? (row.card ? (JSON.parse(row.card) as MessageCard) : null) : change.card === null ? null : cleanCard(change.card);
1381 if (change.card && !card) return fail("invalid", "A card needs a kind and a title.");
1382 const body = messageBody(change.body === undefined ? row.body : change.body, !!card);
1383 if (!body.ok) return fail("invalid", body.message);
1384 const cardJson = card ? JSON.stringify(card) : null;
1385 const at = now();
1386 // A card's live state is not an edit a person made: no "edited" mark for it.
1387 const edited = change.body !== undefined && change.body !== row.body ? at : row.edited_at;
1388 await this.db
1389 .prepare("UPDATE messages SET body = ?, card = ?, kind = ?, edited_at = ? WHERE id = ?")
1390 .bind(body.body, cardJson, card ? "card" : "text", edited, row.id)
1391 .run();
1392 const [message] = await this.toMessages(place.slug, place.workspace, [{ ...row, body: body.body, card: cardJson, kind: card ? "card" : "text", edited_at: edited }]);
1393 this.broadcast(place.channel.id, { type: "message.updated", message });
1394 return ok(message);
1395 }
1396
1397 /**
Chat and workspace agents: channels, DMs and named agents you talk to1398 * What an agent reads before replying, oldest first: a thread (its root,
1399 * then its latest replies), or the channel's latest top-level messages.
1400 * Only where the agent is a member, so it reads only what was said where
1401 * it was invited. Deleted messages are left out, save a thread's root.
1402 */
1403 async historyForAgent(a: {
1404 workspace: string;
1405 channel_id: string;
1406 agent_id: string;
1407 thread_root?: string | null;
1408 limit?: number | null;
1409 }): Promise<Result<ChatMessage[]>> {
1410 const found = await this.agentPlace(a.workspace, a.channel_id, a.agent_id);
1411 if (!found.ok) return found;
1412 const { place } = found.value;
1413 const size = historySize(a.limit);
1414 if (typeof a.thread_root === "string" && a.thread_root) {
1415 const asked = await this.messageRow(place.channel.id, a.thread_root);
1416 if (!asked) return fail("not_found", "No such thread.");
1417 // Asked from a reply: its whole thread.
1418 const root = asked.thread_root ? await this.messageRow(place.channel.id, asked.thread_root) : asked;
1419 if (!root) return fail("not_found", "No such thread.");
1420 const replies = await this.db
1421 .prepare(
1422 "SELECT * FROM messages WHERE thread_root = ? AND channel_id = ? AND deleted_at IS NULL ORDER BY id DESC LIMIT ?",
1423 )
1424 .bind(root.id, place.channel.id, Math.max(0, size - 1))
1425 .all<MessageRow>();
1426 return ok(await this.toMessages(place.slug, place.workspace, historyOf(replies.results, root)));
1427 }
1428 const rows = await this.db
1429 .prepare(
1430 "SELECT * FROM messages WHERE channel_id = ? AND thread_root IS NULL AND id < '~' AND deleted_at IS NULL ORDER BY id DESC LIMIT ?",
1431 )
1432 .bind(place.channel.id, size)
1433 .all<MessageRow>();
1434 return ok(await this.toMessages(place.slug, place.workspace, historyOf(rows.results)));
1435 }
1436
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)1437 // ── What an agent may read (src/audience.ts) ───────────────────────────
1438
1439 /** The people in a conversation, by user id. */
1440 private async peopleIn(channelId: string): Promise<string[]> {
1441 const rows = await this.db
1442 .prepare("SELECT principal FROM channel_members WHERE channel_id = ? AND principal LIKE 'user:%'")
1443 .bind(channelId)
1444 .all<{ principal: string }>();
1445 return rows.results.map((row) => row.principal.slice("user:".length));
1446 }
1447
1448 /** A conversation of this workspace and who reads it, worked out here, never taken from a caller. */
1449 private async audienceOf(slug: string, channelId: string): Promise<Result<{ workspace: Workspace; channel: ChannelRow; audience: ChatAudience }>> {
1450 const workspace = await this.workspace(String(slug ?? "").toLowerCase());
1451 if (!workspace) return fail("not_found", "No such workspace.");
1452 const channel = await this.db
1453 .prepare("SELECT * FROM channels WHERE id = ? AND workspace_id = ?")
1454 .bind(String(channelId ?? ""), workspace.id)
1455 .first<ChannelRow>();
1456 if (!channel) return fail("not_found", "No such conversation.");
1457 const people = await this.peopleIn(channel.id);
1458 return ok({ workspace, channel, audience: { kind: audienceKind(channel), member_user_ids: people, member_count: people.length } });
1459 }
1460
1461 async audience(a: { workspace: string; channel_id: string }): Promise<Result<ChatAudience>> {
1462 const found = await this.audienceOf(a.workspace, a.channel_id);
1463 return found.ok ? ok(found.value.audience) : found;
1464 }
1465
1466 /**
1467 * The conversations of the workspace the audience of `channelId` may all
1468 * read: public channels, and the ones every person in it is in (a DM only
1469 * with exactly them). At most 500, most recently active first.
1470 */
1471 private async readableFor(workspace: Workspace, audience: ChatAudience): Promise<Map<string, ChannelRow>> {
1472 const people = audience.member_user_ids;
1473 const rows = isShared({ kind: audience.kind, user_ids: people }) || !people.length
1474 ? await this.db
1475 .prepare("SELECT * FROM channels WHERE workspace_id = ? AND kind = 'channel' AND private = 0 ORDER BY last_message_at DESC LIMIT 500")
1476 .bind(workspace.id)
1477 .all<ChannelRow>()
1478 : await this.db
1479 .prepare(
1480 `SELECT * FROM channels WHERE workspace_id = ?1 AND (
1481 (kind = 'channel' AND private = 0)
1482 OR id IN (SELECT channel_id FROM channel_members WHERE principal IN (${people.map((_, i) => `?${i + 2}`).join(", ")})
1483 GROUP BY channel_id HAVING COUNT(DISTINCT principal) = ${people.length})
1484 ) ORDER BY last_message_at DESC LIMIT 500`,
1485 )
1486 .bind(workspace.id, ...people.map((id) => `user:${id}`))
1487 .all<ChannelRow>();
1488 const out = new Map<string, ChannelRow>();
1489 for (const channel of rows.results) {
1490 // The query finds candidates; the rule decides, a DM's people included.
1491 const target = { kind: channel.kind, private: channel.private, user_ids: channel.kind === "channel" && !channel.private ? [] : await this.peopleIn(channel.id) };
1492 if (readableBy(target, { kind: audience.kind, user_ids: people })) out.set(channel.id, channel);
1493 }
1494 return out;
1495 }
1496
1497 private async found(slug: string, workspace: Workspace, channels: Map<string, ChannelRow>, rows: MessageRow[]): Promise<AgentFoundMessage[]> {
1498 const messages = await this.toMessages(slug, workspace, rows);
1499 return messages.map((message) => {
1500 const channel = channels.get(message.channel_id)!;
1501 return { channel_id: channel.id, channel: channel.kind === "dm" ? null : channel.name, message };
1502 });
1503 }
1504
1505 async searchForAgent(a: { workspace: string; channel_id: string; query: string; limit?: number | null }): Promise<Result<AgentFoundMessage[]>> {
1506 const found = await this.audienceOf(a.workspace, a.channel_id);
1507 if (!found.ok) return found;
1508 const pattern = likePattern(a.query);
1509 if (!pattern) return fail("invalid", "Search for at least two characters.");
1510 const { workspace, audience } = found.value;
1511 const channels = await this.readableFor(workspace, audience);
1512 if (!channels.size) return ok([]);
1513 const ids = [...channels.keys()];
1514 const limit = Math.min(20, Math.max(1, Math.floor(Number(a.limit) || 20)));
1515 const rows = await this.db
1516 .prepare(
1517 `SELECT * FROM messages WHERE channel_id IN (${ids.map(() => "?").join(", ")}) AND deleted_at IS NULL AND body LIKE ? ESCAPE '\\'
1518 ORDER BY id DESC LIMIT ?`,
1519 )
1520 .bind(...ids, pattern, limit)
1521 .all<MessageRow>();
1522 return ok(await this.found(a.workspace.toLowerCase(), workspace, channels, rows.results));
1523 }
1524
1525 async threadForAgent(a: { workspace: string; channel_id: string; target_channel_id: string; id: string }): Promise<Result<AgentFoundMessage[]>> {
1526 const found = await this.audienceOf(a.workspace, a.channel_id);
1527 if (!found.ok) return found;
1528 const { workspace, audience } = found.value;
1529 // The same answer for a conversation that is not there and one the audience may not read.
1530 const hidden = fail("not_found", "Not available in this conversation.");
1531 const target = await this.db
1532 .prepare("SELECT * FROM channels WHERE id = ? AND workspace_id = ?")
1533 .bind(String(a.target_channel_id ?? ""), workspace.id)
1534 .first<ChannelRow>();
1535 if (!target) return hidden;
1536 const people = target.kind === "channel" && !target.private ? [] : await this.peopleIn(target.id);
1537 if (!readableBy({ kind: target.kind, private: target.private, user_ids: people }, { kind: audience.kind, user_ids: audience.member_user_ids })) return hidden;
1538 const asked = await this.messageRow(target.id, String(a.id ?? ""));
1539 if (!asked) return hidden;
1540 const root = asked.thread_root ? await this.messageRow(target.id, asked.thread_root) : asked;
1541 if (!root) return hidden;
1542 const replies = await this.db
1543 .prepare("SELECT * FROM messages WHERE thread_root = ? AND channel_id = ? AND deleted_at IS NULL ORDER BY id DESC LIMIT 49")
1544 .bind(root.id, target.id)
1545 .all<MessageRow>();
1546 const rows = historyOf(replies.results, root).filter((row) => !row.deleted_at);
1547 return ok(await this.found(a.workspace.toLowerCase(), workspace, new Map([[target.id, target]]), rows));
1548 }
1549
Chat and workspace agents: channels, DMs and named agents you talk to1550 async agentTyping(a: { workspace: string; channel_id: string; agent_id: string }): Promise<Result<null>> {
1551 const found = await this.agentPlace(a.workspace, a.channel_id, a.agent_id);
1552 if (!found.ok) return found;
1553 const { place, agent } = found.value;
1554 const key = principalKey({ kind: "agent", id: agent.id });
1555 const member = await this.profile(place.slug, place.workspace, key);
1556 this.broadcast(place.channel.id, {
1557 type: "typing",
1558 channel_id: place.channel.id,
1559 member,
1560 until: new Date(Date.now() + AGENT_TYPING_MS).toISOString(),
1561 });
1562 return ok(null);
1563 }
1564
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)1565 // ── Reactions (src/emoji.ts) ────────────────────────────────────────────
1566
1567 /** One message's reactions now, as `me` sees them. */
1568 private async reactionsFor(place: Place, messageId: string, me: string): Promise<ChatReaction[]> {
1569 const list = (await this.reactionsOf([messageId], me)).get(messageId) ?? [];
1570 const profiles = await this.profiles(place.slug, place.workspace, list.flatMap((r) => r.by));
1571 return list.map((r) => ({ ...r, by: r.by.map((key) => profiles.get(key)!).filter(Boolean) }));
1572 }
1573
1574 /**
1575 * Adds or takes back `who`'s reaction. Each member reacts once with each
1576 * emoji, a message holds at most 50 different ones, and a workspace's own
1577 * emoji must exist to be used (taking one back never needs it to). The
1578 * room hears of each change.
1579 */
1580 private async reactTo(place: Place, who: string, messageId: unknown, input: unknown, remove: boolean): Promise<Result<ChatReaction[]>> {
1581 const parsed = reactionEmoji(input);
1582 if (!parsed.ok) return fail("invalid", parsed.message);
1583 const row = await this.messageRow(place.channel.id, String(messageId ?? ""));
1584 if (!row || row.deleted_at) return fail("not_found", "No such message.");
1585 let changed = 0;
1586 if (remove) {
1587 const done = await this.db
1588 .prepare("DELETE FROM reactions WHERE message_id = ? AND principal = ? AND emoji = ?")
1589 .bind(row.id, who, parsed.emoji)
1590 .run();
1591 changed = done.meta.changes;
1592 } else {
1593 if (parsed.custom) {
1594 const known = await this.db
1595 .prepare("SELECT 1 FROM custom_emoji WHERE workspace_id = ? AND name = ? AND deleted_at IS NULL")
1596 .bind(place.workspace.id, parsed.custom)
1597 .first();
1598 if (!known) return fail("not_found", `This workspace has no :${parsed.custom}: emoji.`);
1599 }
1600 const kinds = await this.db.prepare("SELECT DISTINCT emoji FROM reactions WHERE message_id = ?").bind(row.id).all<{ emoji: string }>();
1601 if (!roomForReaction(new Set(kinds.results.map((k) => k.emoji)), parsed.emoji)) {
1602 return fail("invalid", `A message can have at most ${MAX_REACTIONS_PER_MESSAGE} different reactions.`);
1603 }
1604 const done = await this.db
1605 .prepare("INSERT OR IGNORE INTO reactions (message_id, principal, emoji, created_at) VALUES (?, ?, ?, ?)")
1606 .bind(row.id, who, parsed.emoji, now())
1607 .run();
1608 changed = done.meta.changes;
1609 }
1610 if (changed) {
1611 const member = await this.profile(place.slug, place.workspace, who);
1612 this.broadcast(place.channel.id, {
1613 type: remove ? "reaction.removed" : "reaction.added",
1614 channel_id: place.channel.id,
1615 message_id: row.id,
1616 emoji: parsed.emoji,
1617 member,
1618 });
1619 }
1620 return ok(await this.reactionsFor(place, row.id, who));
1621 }
1622
1623 async react(a: { workspace: string; channel_id: string; viewer: Viewer; message_id: string; emoji: string }): Promise<Result<ChatReaction[]>> {
1624 const found = await this.place(a.workspace, a.channel_id, a.viewer, "read");
1625 if (!found.ok) return found;
1626 return this.reactTo(found.value, userKey(a.viewer!), a.message_id, a.emoji, false);
1627 }
1628
1629 async unreact(a: { workspace: string; channel_id: string; viewer: Viewer; message_id: string; emoji: string }): Promise<Result<ChatReaction[]>> {
1630 const found = await this.place(a.workspace, a.channel_id, a.viewer, "read");
1631 if (!found.ok) return found;
1632 return this.reactTo(found.value, userKey(a.viewer!), a.message_id, a.emoji, true);
1633 }
1634
1635 /** An agent's reaction counts like anyone's; it must be in the channel. */
1636 async reactAsAgent(a: {
1637 workspace: string;
1638 channel_id: string;
1639 agent_id: string;
1640 message_id: string;
1641 emoji: string;
1642 remove?: boolean;
1643 }): Promise<Result<ChatReaction[]>> {
1644 const found = await this.agentPlace(a.workspace, a.channel_id, a.agent_id);
1645 if (!found.ok) return found;
1646 const { place, agent } = found.value;
1647 return this.reactTo(place, principalKey({ kind: "agent", id: agent.id }), a.message_id, a.emoji, a.remove === true);
1648 }
1649
1650 // ── A workspace's own emoji (src/emoji.ts) ──────────────────────────────
1651
1652 private async emojiUpload(workspace: Workspace): Promise<EmojiUpload> {
Chat controls, public profiles, shadcn selects, and no Docs tab in a project1653 return settingsOf(await this.settingsRow(workspace)).emoji_upload;
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)1654 }
1655
1656 private async toEmoji(slug: string, workspace: Workspace, rows: EmojiRow[]): Promise<CustomEmoji[]> {
1657 const profiles = await this.profiles(slug, workspace, rows.map((r) => r.created_by));
1658 return rows.map((row) => ({
1659 name: row.name,
1660 alias_of: row.alias_of,
1661 file: row.file,
1662 content_type: row.content_type,
1663 bytes: row.bytes,
1664 created_by: profiles.get(row.created_by)!,
1665 created_at: row.created_at,
1666 }));
1667 }
1668
1669 private liveEmoji(workspace: Workspace, name: string): Promise<EmojiRow | null> {
1670 return this.db
1671 .prepare("SELECT * FROM custom_emoji WHERE workspace_id = ? AND name = ? AND deleted_at IS NULL")
1672 .bind(workspace.id, name)
1673 .first<EmojiRow>();
1674 }
1675
1676 async listEmoji(a: { workspace: string; viewer: Viewer }): Promise<Result<EmojiList>> {
1677 const found = await this.viewerWorkspace(a.workspace, a.viewer);
1678 if (!found.ok) return found;
1679 const workspace = found.value;
1680 const [rows, setting] = await Promise.all([
1681 this.db
1682 .prepare("SELECT * FROM custom_emoji WHERE workspace_id = ? AND deleted_at IS NULL ORDER BY name")
1683 .bind(workspace.id)
1684 .all<EmojiRow>(),
1685 this.emojiUpload(workspace),
1686 ]);
1687 const role = roleOf(a.viewer, a.workspace);
1688 return ok({
1689 emoji: await this.toEmoji(a.workspace.toLowerCase(), workspace, rows.results),
1690 emoji_upload: setting,
1691 can_upload: mayUpload(setting, role),
1692 can_manage: role === "owner",
1693 });
1694 }
1695
1696 /** Who may add one: checked against the workspace's setting. */
1697 private async mayAdd(workspace: Workspace, viewer: Viewer, slug: string): Promise<Result<null>> {
1698 const setting = await this.emojiUpload(workspace);
1699 if (!mayUpload(setting, roleOf(viewer, slug))) return fail("forbidden", "Only owners can add emoji in this workspace.");
1700 return ok(null);
1701 }
1702
1703 async addEmoji(a: { workspace: string; viewer: Viewer; name: string; file: EmojiFile }): Promise<Result<CustomEmoji>> {
1704 const found = await this.viewerWorkspace(a.workspace, a.viewer);
1705 if (!found.ok) return found;
1706 const workspace = found.value;
1707 const allowed = await this.mayAdd(workspace, a.viewer, a.workspace);
1708 if (!allowed.ok) return allowed;
1709 const named = emojiName(a.name);
1710 if (!named.ok) return fail("invalid", named.message);
1711 if (await this.liveEmoji(workspace, named.name)) return fail("conflict", `:${named.name}: is already taken.`);
1712 const bytes = fromBase64(a.file?.data);
1713 if (!bytes) return fail("invalid", "Choose a PNG, GIF or WebP image of at most 256 KB.");
1714 const checked = emojiImage(bytes);
1715 if (!checked.ok) return fail("invalid", checked.message);
1716 const file = await sha256(bytes);
1717 // Kept by its hash, so the same image stored twice is one file; its
1718 // type is the one read from its bytes (usercontent serves only that).
1719 await this.env.AVATARS.put(`emoji/${file}`, bytes, { metadata: { contentType: checked.image.content_type } });
1720 const row: EmojiRow = {
1721 workspace_id: workspace.id,
1722 name: named.name,
1723 alias_of: null,
1724 file,
1725 content_type: checked.image.content_type,
1726 bytes: bytes.length,
1727 created_by: userKey(a.viewer!),
1728 created_at: now(),
1729 deleted_at: null,
1730 };
1731 const added = await this.insertEmoji(row);
1732 if (!added.ok) return added;
1733 const [emoji] = await this.toEmoji(a.workspace.toLowerCase(), workspace, [row]);
1734 return ok(emoji!);
1735 }
1736
1737 private async insertEmoji(row: EmojiRow): Promise<Result<null>> {
1738 try {
1739 await this.db
1740 .prepare(
1741 "INSERT INTO custom_emoji (workspace_id, name, alias_of, file, content_type, bytes, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
1742 )
1743 .bind(row.workspace_id, row.name, row.alias_of, row.file, row.content_type, row.bytes, row.created_by, row.created_at)
1744 .run();
1745 return ok(null);
1746 } catch (error) {
1747 if (String(error).includes("UNIQUE")) return fail("conflict", `:${row.name}: is already taken.`);
1748 throw error;
1749 }
1750 }
1751
1752 async aliasEmoji(a: { workspace: string; viewer: Viewer; name: string; target: string }): Promise<Result<CustomEmoji>> {
1753 const found = await this.viewerWorkspace(a.workspace, a.viewer);
1754 if (!found.ok) return found;
1755 const workspace = found.value;
1756 const allowed = await this.mayAdd(workspace, a.viewer, a.workspace);
1757 if (!allowed.ok) return allowed;
1758 const named = emojiName(a.name);
1759 if (!named.ok) return fail("invalid", named.message);
1760 const targetName = String(a.target ?? "").trim().replace(/^:+|:+$/g, "").toLowerCase();
1761 let target = await this.liveEmoji(workspace, targetName);
1762 // An alias of an alias names the emoji itself, so removing one never strands another.
1763 if (target?.alias_of) target = await this.liveEmoji(workspace, target.alias_of);
1764 if (!target) return fail("not_found", `This workspace has no :${targetName}: emoji.`);
1765 if (await this.liveEmoji(workspace, named.name)) return fail("conflict", `:${named.name}: is already taken.`);
1766 const row: EmojiRow = { ...target, name: named.name, alias_of: target.name, created_by: userKey(a.viewer!), created_at: now(), deleted_at: null };
1767 const added = await this.insertEmoji(row);
1768 if (!added.ok) return added;
1769 const [emoji] = await this.toEmoji(a.workspace.toLowerCase(), workspace, [row]);
1770 return ok(emoji!);
1771 }
1772
1773 async removeEmoji(a: { workspace: string; viewer: Viewer; name: string }): Promise<Result<null>> {
1774 const found = await this.viewerWorkspace(a.workspace, a.viewer);
1775 if (!found.ok) return found;
1776 const workspace = found.value;
1777 const name = String(a.name ?? "").trim().replace(/^:+|:+$/g, "").toLowerCase();
1778 const row = await this.liveEmoji(workspace, name);
1779 if (!row) return fail("not_found", `This workspace has no :${name}: emoji.`);
1780 if (!mayRemove(row.created_by, userKey(a.viewer!), roleOf(a.viewer, a.workspace))) {
1781 return fail("forbidden", "Only whoever added an emoji, or an owner, can remove it.");
1782 }
1783 // An emoji goes with its aliases; an alias goes alone.
1784 await this.db
1785 .prepare(
1786 "UPDATE custom_emoji SET deleted_at = ?1 WHERE workspace_id = ?2 AND deleted_at IS NULL AND (name = ?3 OR (?4 = 0 AND alias_of = ?3))",
1787 )
1788 .bind(now(), workspace.id, row.name, row.alias_of ? 1 : 0)
1789 .run();
1790 // Its image goes once nothing live shows it, in any workspace.
1791 this.defer(
1792 (async () => {
1793 const used = await this.db.prepare("SELECT 1 FROM custom_emoji WHERE file = ? AND deleted_at IS NULL LIMIT 1").bind(row.file).first();
1794 if (!used) await this.env.AVATARS.delete(`emoji/${row.file}`);
1795 })().catch((error) => console.error("chat could not forget emoji", row.file, error)),
1796 );
1797 return ok(null);
1798 }
1799
1800 async setEmojiUpload(a: { workspace: string; viewer: Viewer; value: EmojiUpload }): Promise<Result<EmojiUpload>> {
1801 const found = await this.viewerWorkspace(a.workspace, a.viewer);
1802 if (!found.ok) return found;
1803 if (roleOf(a.viewer, a.workspace) !== "owner") return fail("forbidden", "Only owners can change who adds emoji.");
1804 if (a.value !== "members" && a.value !== "admins") return fail("invalid", "Choose members or owners.");
Chat controls, public profiles, shadcn selects, and no Docs tab in a project1805 // The same setting as Settings → Chat (`setChatSettings`).
1806 const set = await this.setChatSettings({ workspace: a.workspace, viewer: a.viewer, change: { emoji_upload: a.value } });
1807 return set.ok ? ok(set.value.emoji_upload) : set;
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)1808 }
1809
Chat and workspace agents: channels, DMs and named agents you talk to1810 // ── The live socket ─────────────────────────────────────────────────────
1811
1812 /**
1813 * `GET /live?workspace=<slug>&channel=<id>`, upgraded to a WebSocket. The
1814 * viewer comes in CHAT_VIEWER_HEADER, set by the site after checking the
1815 * session; trusted only because this Worker is reachable through service
1816 * bindings alone (`workers_dev` is off and it has no routes). Checked
1817 * like any read, then handed to the channel's room.
1818 */
1819 async live(request: Request): Promise<Response> {
1820 if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") {
1821 return new Response("Expected a WebSocket upgrade\n", { status: 426 });
1822 }
1823 let viewer: Viewer = null;
1824 try {
1825 viewer = JSON.parse(request.headers.get(CHAT_VIEWER_HEADER) ?? "null") as Viewer;
1826 } catch {
1827 viewer = null;
1828 }
1829 if (!viewer?.id) return new Response("Sign in to use chat\n", { status: 401 });
1830 const url = new URL(request.url);
1831 const channelId = url.searchParams.get("channel") ?? "";
1832 let slug = (url.searchParams.get("workspace") ?? "").toLowerCase();
1833 if (!slug) {
1834 // Not named: whichever of the viewer's workspaces holds the channel.
1835 const row = await this.db.prepare("SELECT workspace_id FROM channels WHERE id = ?").bind(channelId).first<{ workspace_id: string }>();
1836 if (row) {
1837 const theirs = await Promise.all((viewer.workspaces ?? []).map((m) => this.workspace(m.slug)));
1838 slug = theirs.find((w) => w?.id === row.workspace_id)?.slug.toLowerCase() ?? "";
1839 }
1840 }
1841 const found = await this.place(slug, channelId, viewer, "read");
1842 if (!found.ok) return new Response(`${found.error.message}\n`, { status: found.error.code === "forbidden" ? 403 : 404 });
1843 const { workspace, channel } = found.value;
1844 const who: RoomMember = { channel_id: channel.id, member: await this.profile(slug, workspace, userKey(viewer)) };
1845 const headers = new Headers(request.headers);
1846 headers.delete(CHAT_VIEWER_HEADER);
1847 headers.set(ROOM_MEMBER_HEADER, JSON.stringify(who));
1848 return this.room(channel.id).fetch(new Request(request.url, { method: "GET", headers }));
1849 }
1850}
1851
1852/** One RPC method's answer. */
1853async function answer(service: Chat, method: string, args: any): Promise<Response> {
1854 switch (method) {
1855 case "sidebar":
1856 return Response.json(await service.sidebar(args));
1857 case "channel":
1858 return Response.json(await service.channel(args));
1859 case "channel_by_name":
1860 return Response.json(await service.channelByName(args));
1861 case "browse":
1862 return Response.json(await service.browse(args));
1863 case "create_channel":
1864 return Response.json(await service.createChannel(args));
Chat controls, public profiles, shadcn selects, and no Docs tab in a project1865 case "update_channel":
1866 return Response.json(await service.updateChannel(args));
1867 case "chat_settings":
1868 return Response.json(await service.chatSettings(args));
1869 case "set_chat_settings":
1870 return Response.json(await service.setChatSettings(args));
Chat and workspace agents: channels, DMs and named agents you talk to1871 case "open_dm":
1872 return Response.json(await service.openDm(args));
1873 case "join":
1874 return Response.json(await service.join(args));
1875 case "leave":
1876 return Response.json(await service.leave(args));
1877 case "invite":
1878 return Response.json(await service.invite(args));
1879 case "messages":
1880 return Response.json(await service.messages(args));
1881 case "post":
1882 return Response.json(await service.post(args));
1883 case "edit":
1884 return Response.json(await service.edit(args));
1885 case "remove":
1886 return Response.json(await service.remove(args));
1887 case "mark_read":
1888 return Response.json(await service.markRead(args));
1889 case "set_preferences":
1890 return Response.json(await service.setPreferences(args));
1891 case "post_as_agent":
1892 return Response.json(await service.postAsAgent(args));
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar1893 case "card_action":
1894 return Response.json(await service.cardAction(args));
Chat controls, public profiles, shadcn selects, and no Docs tab in a project1895 case "update_as_agent":
1896 return Response.json(await service.updateAsAgent(args));
Chat and workspace agents: channels, DMs and named agents you talk to1897 case "agent_typing":
1898 return Response.json(await service.agentTyping(args));
1899 case "history_for_agent":
1900 return Response.json(await service.historyForAgent(args));
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)1901 case "audience":
1902 return Response.json(await service.audience(args));
1903 case "search_for_agent":
1904 return Response.json(await service.searchForAgent(args));
1905 case "thread_for_agent":
1906 return Response.json(await service.threadForAgent(args));
1907 case "react":
1908 return Response.json(await service.react(args));
1909 case "unreact":
1910 return Response.json(await service.unreact(args));
1911 case "react_as_agent":
1912 return Response.json(await service.reactAsAgent(args));
1913 case "list_emoji":
1914 return Response.json(await service.listEmoji(args));
1915 case "add_emoji":
1916 return Response.json(await service.addEmoji(args));
1917 case "alias_emoji":
1918 return Response.json(await service.aliasEmoji(args));
1919 case "remove_emoji":
1920 return Response.json(await service.removeEmoji(args));
1921 case "set_emoji_upload":
1922 return Response.json(await service.setEmojiUpload(args));
Chat and workspace agents: channels, DMs and named agents you talk to1923 default:
1924 return new Response("Unknown method\n", { status: 404 });
1925 }
1926}
1927
1928export default {
1929 async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
1930 const url = new URL(request.url);
1931 if (request.method === "GET" && url.pathname === "/live") {
1932 return new Chat(env, (work) => ctx.waitUntil(work)).live(request);
1933 }
1934 const match = url.pathname.match(/^\/rpc\/([a-z_]+)$/);
1935 if (request.method !== "POST" || !match) return new Response("Not found\n", { status: 404 });
1936 // A replica near the caller when it asks for one (@g1t/contracts d1.ts).
1937 const opened = openD1(env.DB, request);
1938 const service = new Chat(Object.create(env, { DB: { value: opened.db } }) as Env, (work) => ctx.waitUntil(work));
1939 const args = (await request.json().catch(() => ({}))) as any;
1940 return opened.finish(await answer(service, match[1], args));
1941 },
1942} satisfies ExportedHandler<Env>;

This file's history is long; its oldest lines are credited to the oldest commit read.