Skip to content
1,947 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;
Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread1033 // The message a thread is under: the oldest once the page reaches the
1034 // start, and, on the first page, as `root` however long the thread is,
1035 // so a session's card stays at the top of its thread.
1036 const firstPage = before === "~";
1037 const rootRow = root && (page.older === null || firstPage) ? await this.messageRow(channel.id, root) : null;
1038 if (rootRow && page.older === null) list = [...list, rootRow];
1039 const messages = await this.toMessages(slug, workspace, list, userKey(a.viewer!));
1040 if (!rootRow || !firstPage) return ok({ messages, older: page.older });
1041 const shown = messages.find((m) => m.id === rootRow.id) ?? (await this.toMessages(slug, workspace, [rootRow], userKey(a.viewer!)))[0] ?? null;
1042 return ok({ messages, older: page.older, root: shown });
Chat and workspace agents: channels, DMs and named agents you talk to1043 }
1044
1045 /**
1046 * Writes a message and everything that follows from it: the thread's
1047 * reply count, the channel's last activity, the author's own read mark,
1048 * the meter; then, after answering, tells the room and wakes the agents
1049 * it is for.
1050 */
1051 private async write(
1052 place: Place,
1053 author: string,
1054 input: { body: string; card: MessageCard | null; thread_root: string | null },
1055 chain: Chain<AskerAccess>,
1056 ): Promise<Result<ChatMessage>> {
1057 const { channel, workspace } = place;
1058 if (channel.archived_at) return fail("invalid", "This channel is archived.");
1059 let threadRoot: string | null = null;
1060 if (input.thread_root) {
1061 const root = await this.messageRow(channel.id, input.thread_root);
1062 if (!root || (root.deleted_at && !root.reply_count)) return fail("not_found", "No such message to reply to.");
1063 // A reply to a reply goes in the same thread.
1064 threadRoot = root.thread_root ?? root.id;
1065 }
1066 const at = now();
1067 const handles = mentionedHandles(input.body);
1068 const row: MessageRow = {
1069 id: newId("msg"),
1070 channel_id: channel.id,
1071 author,
1072 kind: input.card ? "card" : "text",
1073 body: input.body,
1074 card: input.card ? JSON.stringify(input.card) : null,
1075 mentions: mentionsColumn(handles),
1076 thread_root: threadRoot,
1077 reply_count: 0,
1078 last_reply_at: null,
1079 created_at: at,
1080 edited_at: null,
1081 deleted_at: null,
1082 };
1083 const statements = [
1084 this.db
1085 .prepare(
1086 "INSERT INTO messages (id, channel_id, author, kind, body, card, mentions, thread_root, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
1087 )
1088 .bind(row.id, row.channel_id, author, row.kind, row.body, row.card, row.mentions, threadRoot, at),
1089 this.db.prepare("UPDATE channels SET last_message_at = ? WHERE id = ?").bind(at, channel.id),
1090 // What you wrote, you have read.
1091 this.db
1092 .prepare("UPDATE channel_members SET last_read_id = ? WHERE channel_id = ? AND principal = ?")
1093 .bind(row.id, channel.id, author),
1094 this.db
1095 .prepare(
1096 `INSERT INTO chat_meter (workspace_id, day, messages, bytes) VALUES (?, ?, 1, ?)
1097 ON CONFLICT (workspace_id, day) DO UPDATE SET messages = messages + 1, bytes = bytes + excluded.bytes`,
1098 )
1099 .bind(workspace.id, meterDay(at), bytesOf(row.body) + bytesOf(row.card ?? "")),
1100 ];
1101 if (threadRoot) {
1102 statements.push(
1103 this.db
1104 .prepare("UPDATE messages SET reply_count = reply_count + 1, last_reply_at = ? WHERE id = ?")
1105 .bind(at, threadRoot),
1106 );
1107 }
1108 await this.db.batch(statements);
1109
1110 const [message] = await this.toMessages(place.slug, workspace, [row]);
1111 this.broadcast(channel.id, { type: "message.created", message });
1112 if (threadRoot) this.rebroadcast(place, threadRoot);
1113 this.defer(
1114 this.wake(place, row, handles, chain).catch((error) => console.error("chat could not hand", row.id, "to agents", error)),
1115 );
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)1116 // Notify: counts for everyone in the conversation, a notification for those it is for.
1117 this.defer(
Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread1118 notifyMessage(this.db, this.env.NOTIFY, (keys) => this.profiles(place.slug, workspace, keys), { slug: place.slug, channel, row, handles, asked_by: chain.asked_by }).catch(
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)1119 (error) => console.error("chat could not notify about", row.id, error),
1120 ),
1121 );
Chat and workspace agents: channels, DMs and named agents you talk to1122 return ok(message);
1123 }
1124
1125 /** Hands a new message to the agents it is for (src/delivery.ts). */
1126 private async wake(place: Place, row: MessageRow, handles: string[], chain: Chain<AskerAccess>): Promise<void> {
1127 const { channel, workspace } = place;
1128 // Only an agent's message mentioning someone, or a person's, can wake anyone.
1129 if (row.author.startsWith("agent:") && !handles.length) return;
1130 if (channel.kind === "channel" && !handles.length) return;
1131 const members = await this.db
1132 .prepare("SELECT principal FROM channel_members WHERE channel_id = ? AND principal LIKE 'agent:%'")
1133 .bind(channel.id)
1134 .all<{ principal: string }>();
1135 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)1136 let found = await this.agentsById(ids);
1137 const orchestratorIsMember = [...found.values()].some((agent) => !!agent?.builtin && agent.workspace_id === workspace.id && !agent.archived_at);
1138 if (addsOrchestrator({ channelKind: channel.kind, mentioned: handles, orchestratorIsMember })) {
1139 // Mentioning @g1t brings it in: every workspace has it, nobody invites it.
1140 const builtin = await workspaceAgentsClient(this.env.AGENTS)
1141 .builtin(place.slug, workspace.id)
1142 .catch((error: unknown) => {
1143 console.error("chat could not find @g1t for", place.slug, error);
1144 return null;
1145 });
1146 if (builtin?.ok) {
1147 await this.joinStatement(channel.id, `agent:${builtin.value.id}`, "member", now()).run();
1148 this.agents.set(builtin.value.id, builtin.value);
1149 ids.push(builtin.value.id);
1150 found = await this.agentsById(ids);
1151 }
1152 }
Chat and workspace agents: channels, DMs and named agents you talk to1153 if (!ids.length) return;
1154 const agents = [...found.values()].filter(
1155 (agent): agent is WorkspaceAgent => !!agent && agent.workspace_id === workspace.id && !agent.archived_at,
1156 );
1157 const wakes = deliveries({
1158 author: row.author,
1159 hops: chain.hops,
1160 channelKind: channel.kind,
1161 agents: agents.map((agent) => ({ id: agent.id, handle: agent.handle })),
1162 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)1163 notTo: sender(chain.chain),
Chat and workspace agents: channels, DMs and named agents you talk to1164 });
1165 const client = workspaceAgentsClient(this.env.AGENTS);
1166 await Promise.all(
1167 wakes.map((wake) =>
1168 client
1169 .deliver(
1170 delivery(
1171 {
1172 workspace: place.slug,
1173 workspace_id: workspace.id,
1174 channel_id: channel.id,
1175 channel_kind: channel.kind,
1176 channel_name: channel.kind === "dm" ? null : channel.name,
1177 },
1178 wake,
1179 row,
1180 chain,
1181 ) satisfies AgentDelivery,
1182 )
1183 .then((result) => {
1184 if (!result.ok) console.error("agents refused delivery to", wake.agent_id, result.error.message);
1185 })
1186 .catch((error) => console.error("chat could not deliver to", wake.agent_id, error)),
1187 ),
1188 );
1189 }
1190
1191 async post(a: { workspace: string; channel_id: string; viewer: Viewer; message: PostMessage }): Promise<Result<ChatMessage>> {
1192 const found = await this.place(a.workspace, a.channel_id, a.viewer, "read");
1193 if (!found.ok) return found;
1194 const body = messageBody(a.message?.body);
1195 if (!body.ok) return fail("invalid", body.message);
1196 const me = userKey(a.viewer!);
1197 let place = found.value;
1198 if (!place.member) {
1199 // Saying something in a public channel joins it, as reading does not.
1200 if (place.channel.archived_at) return fail("invalid", "This channel is archived.");
1201 await this.joinStatement(place.channel.id, me, "member", now()).run();
1202 place = { ...place, member: { channel_id: place.channel.id, principal: me } as MemberRow };
1203 }
1204 return this.write(
1205 place,
1206 me,
1207 { body: body.body, card: null, thread_root: a.message?.thread_root ?? null },
1208 // 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)1209 { 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 to1210 );
1211 }
1212
1213 async edit(a: { workspace: string; channel_id: string; viewer: Viewer; id: string; body: string }): Promise<Result<ChatMessage>> {
1214 const found = await this.place(a.workspace, a.channel_id, a.viewer, "read");
1215 if (!found.ok) return found;
1216 const place = found.value;
1217 const row = await this.messageRow(place.channel.id, a.id);
1218 if (!row || row.deleted_at) return fail("not_found", "No such message.");
1219 if (row.author !== userKey(a.viewer!)) return fail("forbidden", "Only its author can edit a message.");
1220 const body = messageBody(a.body, !!row.card);
1221 if (!body.ok) return fail("invalid", body.message);
1222 const at = now();
1223 const mentions = mentionsColumn(mentionedHandles(body.body));
1224 await this.db
1225 .prepare("UPDATE messages SET body = ?, mentions = ?, edited_at = ? WHERE id = ?")
1226 .bind(body.body, mentions, at, row.id)
1227 .run();
1228 const [message] = await this.toMessages(place.slug, place.workspace, [{ ...row, body: body.body, mentions, edited_at: at }]);
1229 this.broadcast(place.channel.id, { type: "message.updated", message });
1230 return ok(message);
1231 }
1232
1233 /** Deletes a message, keeping its place so its thread still hangs together. Its author or a workspace owner may. */
1234 async remove(a: { workspace: string; channel_id: string; viewer: Viewer; id: string }): Promise<Result<null>> {
1235 const found = await this.place(a.workspace, a.channel_id, a.viewer, "read");
1236 if (!found.ok) return found;
1237 const place = found.value;
1238 const row = await this.messageRow(place.channel.id, a.id);
1239 if (!row || row.deleted_at) return fail("not_found", "No such message.");
1240 if (row.author !== userKey(a.viewer!) && !isOwner(a.viewer, a.workspace)) {
1241 return fail("forbidden", "Only its author or a workspace owner can delete a message.");
1242 }
1243 const statements = [
1244 this.db
1245 .prepare("UPDATE messages SET deleted_at = ?, body = '', card = NULL, mentions = '' WHERE id = ?")
1246 .bind(now(), row.id),
1247 ];
1248 if (row.thread_root) {
1249 statements.push(
1250 this.db.prepare("UPDATE messages SET reply_count = MAX(reply_count - 1, 0) WHERE id = ?").bind(row.thread_root),
1251 );
1252 }
1253 await this.db.batch(statements);
1254 this.broadcast(place.channel.id, { type: "message.deleted", channel_id: place.channel.id, id: row.id });
1255 if (row.thread_root) this.rebroadcast(place, row.thread_root);
1256 return ok(null);
1257 }
1258
1259 async markRead(a: { workspace: string; channel_id: string; viewer: Viewer; id: string }): Promise<Result<null>> {
1260 const found = await this.place(a.workspace, a.channel_id, a.viewer, "read");
1261 if (!found.ok) return found;
1262 const { channel, member } = found.value;
1263 // Someone reading a public channel they have not joined keeps no read state.
1264 if (!member) return ok(null);
1265 const id = typeof a.id === "string" ? a.id : "";
1266 if (!id) return fail("invalid", "Say which message was read.");
1267 // Only forward: reading an old thread does not mark newer messages unread.
1268 const changed = await this.db
1269 .prepare(
1270 "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)",
1271 )
1272 .bind(id, channel.id, member.principal)
1273 .run();
1274 if (changed.meta.changes) {
1275 this.broadcast(channel.id, {
1276 type: "read",
1277 channel_id: channel.id,
1278 principal: { kind: "user", id: a.viewer!.id },
1279 last_read_id: id,
1280 });
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)1281 // Notify: the read drops the counts in every tab of theirs.
1282 this.defer(
1283 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(
1284 (error) => console.error("chat could not notify a read", error),
1285 ),
1286 );
Chat and workspace agents: channels, DMs and named agents you talk to1287 }
1288 return ok(null);
1289 }
1290
1291 // ── Agents ──────────────────────────────────────────────────────────────
1292
1293 /** The channel and agent for an agent's call: the agent must be of the workspace and in the channel. */
1294 private async agentPlace(slug: string, channelId: string, agentId: string): Promise<Result<{ place: Place; agent: WorkspaceAgent }>> {
1295 const workspace = await this.workspace(String(slug ?? ""));
1296 if (!workspace) return fail("not_found", "No such workspace.");
1297 const agent = await this.liveAgent(workspace, String(agentId ?? ""));
1298 if (!agent) return fail("not_found", "No such agent in this workspace.");
1299 const key = principalKey({ kind: "agent", id: agent.id });
1300 const [channel, member] = await Promise.all([
1301 this.db
1302 .prepare("SELECT * FROM channels WHERE id = ? AND workspace_id = ?")
1303 .bind(String(channelId ?? ""), workspace.id)
1304 .first<ChannelRow>(),
1305 this.db.prepare("SELECT * FROM channel_members WHERE channel_id = ? AND principal = ?").bind(String(channelId ?? ""), key).first<MemberRow>(),
1306 ]);
1307 if (!channel) return fail("not_found", "No such channel.");
1308 if (!member) return fail("forbidden", "The agent is not a member of this channel.");
1309 return ok({ place: { slug: slug.toLowerCase(), workspace, channel, member }, agent });
1310 }
1311
1312 async postAsAgent(a: { workspace: string; channel_id: string; agent_id: string; message: AgentPostMessage }): Promise<Result<ChatMessage>> {
1313 const found = await this.agentPlace(a.workspace, a.channel_id, a.agent_id);
1314 if (!found.ok) return found;
1315 const { place, agent } = found.value;
1316 const card = a.message?.card == null ? null : cleanCard(a.message.card);
1317 if (a.message?.card != null && !card) return fail("invalid", "A card needs a kind and a title.");
1318 const body = messageBody(a.message?.body ?? "", !!card);
1319 if (!body.ok) return fail("invalid", body.message);
1320 const hops = typeof a.message?.hops === "number" && a.message.hops >= 0 ? Math.floor(a.message.hops) : 0;
1321 const askedBy = typeof a.message?.asked_by === "string" && a.message.asked_by ? a.message.asked_by : agent.created_by;
1322 return this.write(
1323 place,
1324 principalKey({ kind: "agent", id: agent.id }),
1325 { body: body.body, card, thread_root: a.message?.thread_root ?? null },
1326 // The asker carries on from the delivery the agent is answering;
1327 // 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)1328 { 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 to1329 );
1330 }
1331
1332 /**
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar1333 * A person presses an action on a card. They must be able to read the
1334 * conversation; the card must offer the action; its owner (agents)
1335 * decides whether this person may, does it, and updates the card.
1336 */
1337 async cardAction(a: {
1338 workspace: string;
1339 channel_id: string;
1340 viewer: Viewer;
1341 message_id: string;
1342 action_id: string;
1343 input?: string | null;
1344 }): Promise<Result<CardActionResult>> {
1345 const found = await this.place(a.workspace, a.channel_id, a.viewer, "read");
1346 if (!found.ok) return found;
1347 const place = found.value;
1348 const row = await this.messageRow(place.channel.id, String(a.message_id ?? ""));
1349 if (!row || row.deleted_at || !row.card) return fail("not_found", "No such card.");
1350 const card = JSON.parse(row.card) as MessageCard;
1351 const action = card.actions?.find((x) => x.id === a.action_id);
1352 if (!action || action.href || card.owner !== "agents") return fail("invalid", "That card has no such action.");
1353 const input = typeof a.input === "string" ? a.input.slice(0, 4000) : null;
1354 if (action.input && !input?.trim()) return fail("invalid", `${action.input.label || "A value"} is needed.`);
1355 return workspaceAgentsClient(this.env.AGENTS).cardAction({
1356 workspace: place.slug,
1357 channel_id: place.channel.id,
1358 message_id: row.id,
1359 viewer: a.viewer!,
1360 card: { kind: card.kind, ref: card.ref ?? null },
1361 action_id: action.id,
1362 input,
1363 });
1364 }
1365
1366 /**
Chat controls, public profiles, shadcn selects, and no Docs tab in a project1367 * Changes a message the agent posted (a session's live card, say): its
1368 * body, its card, or both. Only the agent's own messages; it wakes
1369 * nobody, and everyone in the conversation sees it change.
1370 */
1371 async updateAsAgent(a: {
1372 workspace: string;
1373 channel_id: string;
1374 agent_id: string;
1375 id: string;
1376 change: { body?: string; card?: MessageCard | null };
1377 }): Promise<Result<ChatMessage>> {
1378 const found = await this.agentPlace(a.workspace, a.channel_id, a.agent_id);
1379 if (!found.ok) return found;
1380 const { place, agent } = found.value;
1381 const row = await this.messageRow(place.channel.id, String(a.id ?? ""));
1382 if (!row || row.deleted_at) return fail("not_found", "No such message.");
1383 if (row.author !== principalKey({ kind: "agent", id: agent.id })) return fail("forbidden", "An agent can change only its own messages.");
1384 const change = a.change ?? {};
1385 const card = change.card === undefined ? (row.card ? (JSON.parse(row.card) as MessageCard) : null) : change.card === null ? null : cleanCard(change.card);
1386 if (change.card && !card) return fail("invalid", "A card needs a kind and a title.");
1387 const body = messageBody(change.body === undefined ? row.body : change.body, !!card);
1388 if (!body.ok) return fail("invalid", body.message);
1389 const cardJson = card ? JSON.stringify(card) : null;
1390 const at = now();
1391 // A card's live state is not an edit a person made: no "edited" mark for it.
1392 const edited = change.body !== undefined && change.body !== row.body ? at : row.edited_at;
1393 await this.db
1394 .prepare("UPDATE messages SET body = ?, card = ?, kind = ?, edited_at = ? WHERE id = ?")
1395 .bind(body.body, cardJson, card ? "card" : "text", edited, row.id)
1396 .run();
1397 const [message] = await this.toMessages(place.slug, place.workspace, [{ ...row, body: body.body, card: cardJson, kind: card ? "card" : "text", edited_at: edited }]);
1398 this.broadcast(place.channel.id, { type: "message.updated", message });
1399 return ok(message);
1400 }
1401
1402 /**
Chat and workspace agents: channels, DMs and named agents you talk to1403 * What an agent reads before replying, oldest first: a thread (its root,
1404 * then its latest replies), or the channel's latest top-level messages.
1405 * Only where the agent is a member, so it reads only what was said where
1406 * it was invited. Deleted messages are left out, save a thread's root.
1407 */
1408 async historyForAgent(a: {
1409 workspace: string;
1410 channel_id: string;
1411 agent_id: string;
1412 thread_root?: string | null;
1413 limit?: number | null;
1414 }): Promise<Result<ChatMessage[]>> {
1415 const found = await this.agentPlace(a.workspace, a.channel_id, a.agent_id);
1416 if (!found.ok) return found;
1417 const { place } = found.value;
1418 const size = historySize(a.limit);
1419 if (typeof a.thread_root === "string" && a.thread_root) {
1420 const asked = await this.messageRow(place.channel.id, a.thread_root);
1421 if (!asked) return fail("not_found", "No such thread.");
1422 // Asked from a reply: its whole thread.
1423 const root = asked.thread_root ? await this.messageRow(place.channel.id, asked.thread_root) : asked;
1424 if (!root) return fail("not_found", "No such thread.");
1425 const replies = await this.db
1426 .prepare(
1427 "SELECT * FROM messages WHERE thread_root = ? AND channel_id = ? AND deleted_at IS NULL ORDER BY id DESC LIMIT ?",
1428 )
1429 .bind(root.id, place.channel.id, Math.max(0, size - 1))
1430 .all<MessageRow>();
1431 return ok(await this.toMessages(place.slug, place.workspace, historyOf(replies.results, root)));
1432 }
1433 const rows = await this.db
1434 .prepare(
1435 "SELECT * FROM messages WHERE channel_id = ? AND thread_root IS NULL AND id < '~' AND deleted_at IS NULL ORDER BY id DESC LIMIT ?",
1436 )
1437 .bind(place.channel.id, size)
1438 .all<MessageRow>();
1439 return ok(await this.toMessages(place.slug, place.workspace, historyOf(rows.results)));
1440 }
1441
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)1442 // ── What an agent may read (src/audience.ts) ───────────────────────────
1443
1444 /** The people in a conversation, by user id. */
1445 private async peopleIn(channelId: string): Promise<string[]> {
1446 const rows = await this.db
1447 .prepare("SELECT principal FROM channel_members WHERE channel_id = ? AND principal LIKE 'user:%'")
1448 .bind(channelId)
1449 .all<{ principal: string }>();
1450 return rows.results.map((row) => row.principal.slice("user:".length));
1451 }
1452
1453 /** A conversation of this workspace and who reads it, worked out here, never taken from a caller. */
1454 private async audienceOf(slug: string, channelId: string): Promise<Result<{ workspace: Workspace; channel: ChannelRow; audience: ChatAudience }>> {
1455 const workspace = await this.workspace(String(slug ?? "").toLowerCase());
1456 if (!workspace) return fail("not_found", "No such workspace.");
1457 const channel = await this.db
1458 .prepare("SELECT * FROM channels WHERE id = ? AND workspace_id = ?")
1459 .bind(String(channelId ?? ""), workspace.id)
1460 .first<ChannelRow>();
1461 if (!channel) return fail("not_found", "No such conversation.");
1462 const people = await this.peopleIn(channel.id);
1463 return ok({ workspace, channel, audience: { kind: audienceKind(channel), member_user_ids: people, member_count: people.length } });
1464 }
1465
1466 async audience(a: { workspace: string; channel_id: string }): Promise<Result<ChatAudience>> {
1467 const found = await this.audienceOf(a.workspace, a.channel_id);
1468 return found.ok ? ok(found.value.audience) : found;
1469 }
1470
1471 /**
1472 * The conversations of the workspace the audience of `channelId` may all
1473 * read: public channels, and the ones every person in it is in (a DM only
1474 * with exactly them). At most 500, most recently active first.
1475 */
1476 private async readableFor(workspace: Workspace, audience: ChatAudience): Promise<Map<string, ChannelRow>> {
1477 const people = audience.member_user_ids;
1478 const rows = isShared({ kind: audience.kind, user_ids: people }) || !people.length
1479 ? await this.db
1480 .prepare("SELECT * FROM channels WHERE workspace_id = ? AND kind = 'channel' AND private = 0 ORDER BY last_message_at DESC LIMIT 500")
1481 .bind(workspace.id)
1482 .all<ChannelRow>()
1483 : await this.db
1484 .prepare(
1485 `SELECT * FROM channels WHERE workspace_id = ?1 AND (
1486 (kind = 'channel' AND private = 0)
1487 OR id IN (SELECT channel_id FROM channel_members WHERE principal IN (${people.map((_, i) => `?${i + 2}`).join(", ")})
1488 GROUP BY channel_id HAVING COUNT(DISTINCT principal) = ${people.length})
1489 ) ORDER BY last_message_at DESC LIMIT 500`,
1490 )
1491 .bind(workspace.id, ...people.map((id) => `user:${id}`))
1492 .all<ChannelRow>();
1493 const out = new Map<string, ChannelRow>();
1494 for (const channel of rows.results) {
1495 // The query finds candidates; the rule decides, a DM's people included.
1496 const target = { kind: channel.kind, private: channel.private, user_ids: channel.kind === "channel" && !channel.private ? [] : await this.peopleIn(channel.id) };
1497 if (readableBy(target, { kind: audience.kind, user_ids: people })) out.set(channel.id, channel);
1498 }
1499 return out;
1500 }
1501
1502 private async found(slug: string, workspace: Workspace, channels: Map<string, ChannelRow>, rows: MessageRow[]): Promise<AgentFoundMessage[]> {
1503 const messages = await this.toMessages(slug, workspace, rows);
1504 return messages.map((message) => {
1505 const channel = channels.get(message.channel_id)!;
1506 return { channel_id: channel.id, channel: channel.kind === "dm" ? null : channel.name, message };
1507 });
1508 }
1509
1510 async searchForAgent(a: { workspace: string; channel_id: string; query: string; limit?: number | null }): Promise<Result<AgentFoundMessage[]>> {
1511 const found = await this.audienceOf(a.workspace, a.channel_id);
1512 if (!found.ok) return found;
1513 const pattern = likePattern(a.query);
1514 if (!pattern) return fail("invalid", "Search for at least two characters.");
1515 const { workspace, audience } = found.value;
1516 const channels = await this.readableFor(workspace, audience);
1517 if (!channels.size) return ok([]);
1518 const ids = [...channels.keys()];
1519 const limit = Math.min(20, Math.max(1, Math.floor(Number(a.limit) || 20)));
1520 const rows = await this.db
1521 .prepare(
1522 `SELECT * FROM messages WHERE channel_id IN (${ids.map(() => "?").join(", ")}) AND deleted_at IS NULL AND body LIKE ? ESCAPE '\\'
1523 ORDER BY id DESC LIMIT ?`,
1524 )
1525 .bind(...ids, pattern, limit)
1526 .all<MessageRow>();
1527 return ok(await this.found(a.workspace.toLowerCase(), workspace, channels, rows.results));
1528 }
1529
1530 async threadForAgent(a: { workspace: string; channel_id: string; target_channel_id: string; id: string }): Promise<Result<AgentFoundMessage[]>> {
1531 const found = await this.audienceOf(a.workspace, a.channel_id);
1532 if (!found.ok) return found;
1533 const { workspace, audience } = found.value;
1534 // The same answer for a conversation that is not there and one the audience may not read.
1535 const hidden = fail("not_found", "Not available in this conversation.");
1536 const target = await this.db
1537 .prepare("SELECT * FROM channels WHERE id = ? AND workspace_id = ?")
1538 .bind(String(a.target_channel_id ?? ""), workspace.id)
1539 .first<ChannelRow>();
1540 if (!target) return hidden;
1541 const people = target.kind === "channel" && !target.private ? [] : await this.peopleIn(target.id);
1542 if (!readableBy({ kind: target.kind, private: target.private, user_ids: people }, { kind: audience.kind, user_ids: audience.member_user_ids })) return hidden;
1543 const asked = await this.messageRow(target.id, String(a.id ?? ""));
1544 if (!asked) return hidden;
1545 const root = asked.thread_root ? await this.messageRow(target.id, asked.thread_root) : asked;
1546 if (!root) return hidden;
1547 const replies = await this.db
1548 .prepare("SELECT * FROM messages WHERE thread_root = ? AND channel_id = ? AND deleted_at IS NULL ORDER BY id DESC LIMIT 49")
1549 .bind(root.id, target.id)
1550 .all<MessageRow>();
1551 const rows = historyOf(replies.results, root).filter((row) => !row.deleted_at);
1552 return ok(await this.found(a.workspace.toLowerCase(), workspace, new Map([[target.id, target]]), rows));
1553 }
1554
Chat and workspace agents: channels, DMs and named agents you talk to1555 async agentTyping(a: { workspace: string; channel_id: string; agent_id: string }): Promise<Result<null>> {
1556 const found = await this.agentPlace(a.workspace, a.channel_id, a.agent_id);
1557 if (!found.ok) return found;
1558 const { place, agent } = found.value;
1559 const key = principalKey({ kind: "agent", id: agent.id });
1560 const member = await this.profile(place.slug, place.workspace, key);
1561 this.broadcast(place.channel.id, {
1562 type: "typing",
1563 channel_id: place.channel.id,
1564 member,
1565 until: new Date(Date.now() + AGENT_TYPING_MS).toISOString(),
1566 });
1567 return ok(null);
1568 }
1569
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)1570 // ── Reactions (src/emoji.ts) ────────────────────────────────────────────
1571
1572 /** One message's reactions now, as `me` sees them. */
1573 private async reactionsFor(place: Place, messageId: string, me: string): Promise<ChatReaction[]> {
1574 const list = (await this.reactionsOf([messageId], me)).get(messageId) ?? [];
1575 const profiles = await this.profiles(place.slug, place.workspace, list.flatMap((r) => r.by));
1576 return list.map((r) => ({ ...r, by: r.by.map((key) => profiles.get(key)!).filter(Boolean) }));
1577 }
1578
1579 /**
1580 * Adds or takes back `who`'s reaction. Each member reacts once with each
1581 * emoji, a message holds at most 50 different ones, and a workspace's own
1582 * emoji must exist to be used (taking one back never needs it to). The
1583 * room hears of each change.
1584 */
1585 private async reactTo(place: Place, who: string, messageId: unknown, input: unknown, remove: boolean): Promise<Result<ChatReaction[]>> {
1586 const parsed = reactionEmoji(input);
1587 if (!parsed.ok) return fail("invalid", parsed.message);
1588 const row = await this.messageRow(place.channel.id, String(messageId ?? ""));
1589 if (!row || row.deleted_at) return fail("not_found", "No such message.");
1590 let changed = 0;
1591 if (remove) {
1592 const done = await this.db
1593 .prepare("DELETE FROM reactions WHERE message_id = ? AND principal = ? AND emoji = ?")
1594 .bind(row.id, who, parsed.emoji)
1595 .run();
1596 changed = done.meta.changes;
1597 } else {
1598 if (parsed.custom) {
1599 const known = await this.db
1600 .prepare("SELECT 1 FROM custom_emoji WHERE workspace_id = ? AND name = ? AND deleted_at IS NULL")
1601 .bind(place.workspace.id, parsed.custom)
1602 .first();
1603 if (!known) return fail("not_found", `This workspace has no :${parsed.custom}: emoji.`);
1604 }
1605 const kinds = await this.db.prepare("SELECT DISTINCT emoji FROM reactions WHERE message_id = ?").bind(row.id).all<{ emoji: string }>();
1606 if (!roomForReaction(new Set(kinds.results.map((k) => k.emoji)), parsed.emoji)) {
1607 return fail("invalid", `A message can have at most ${MAX_REACTIONS_PER_MESSAGE} different reactions.`);
1608 }
1609 const done = await this.db
1610 .prepare("INSERT OR IGNORE INTO reactions (message_id, principal, emoji, created_at) VALUES (?, ?, ?, ?)")
1611 .bind(row.id, who, parsed.emoji, now())
1612 .run();
1613 changed = done.meta.changes;
1614 }
1615 if (changed) {
1616 const member = await this.profile(place.slug, place.workspace, who);
1617 this.broadcast(place.channel.id, {
1618 type: remove ? "reaction.removed" : "reaction.added",
1619 channel_id: place.channel.id,
1620 message_id: row.id,
1621 emoji: parsed.emoji,
1622 member,
1623 });
1624 }
1625 return ok(await this.reactionsFor(place, row.id, who));
1626 }
1627
1628 async react(a: { workspace: string; channel_id: string; viewer: Viewer; message_id: string; emoji: string }): Promise<Result<ChatReaction[]>> {
1629 const found = await this.place(a.workspace, a.channel_id, a.viewer, "read");
1630 if (!found.ok) return found;
1631 return this.reactTo(found.value, userKey(a.viewer!), a.message_id, a.emoji, false);
1632 }
1633
1634 async unreact(a: { workspace: string; channel_id: string; viewer: Viewer; message_id: string; emoji: string }): Promise<Result<ChatReaction[]>> {
1635 const found = await this.place(a.workspace, a.channel_id, a.viewer, "read");
1636 if (!found.ok) return found;
1637 return this.reactTo(found.value, userKey(a.viewer!), a.message_id, a.emoji, true);
1638 }
1639
1640 /** An agent's reaction counts like anyone's; it must be in the channel. */
1641 async reactAsAgent(a: {
1642 workspace: string;
1643 channel_id: string;
1644 agent_id: string;
1645 message_id: string;
1646 emoji: string;
1647 remove?: boolean;
1648 }): Promise<Result<ChatReaction[]>> {
1649 const found = await this.agentPlace(a.workspace, a.channel_id, a.agent_id);
1650 if (!found.ok) return found;
1651 const { place, agent } = found.value;
1652 return this.reactTo(place, principalKey({ kind: "agent", id: agent.id }), a.message_id, a.emoji, a.remove === true);
1653 }
1654
1655 // ── A workspace's own emoji (src/emoji.ts) ──────────────────────────────
1656
1657 private async emojiUpload(workspace: Workspace): Promise<EmojiUpload> {
Chat controls, public profiles, shadcn selects, and no Docs tab in a project1658 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)1659 }
1660
1661 private async toEmoji(slug: string, workspace: Workspace, rows: EmojiRow[]): Promise<CustomEmoji[]> {
1662 const profiles = await this.profiles(slug, workspace, rows.map((r) => r.created_by));
1663 return rows.map((row) => ({
1664 name: row.name,
1665 alias_of: row.alias_of,
1666 file: row.file,
1667 content_type: row.content_type,
1668 bytes: row.bytes,
1669 created_by: profiles.get(row.created_by)!,
1670 created_at: row.created_at,
1671 }));
1672 }
1673
1674 private liveEmoji(workspace: Workspace, name: string): Promise<EmojiRow | null> {
1675 return this.db
1676 .prepare("SELECT * FROM custom_emoji WHERE workspace_id = ? AND name = ? AND deleted_at IS NULL")
1677 .bind(workspace.id, name)
1678 .first<EmojiRow>();
1679 }
1680
1681 async listEmoji(a: { workspace: string; viewer: Viewer }): Promise<Result<EmojiList>> {
1682 const found = await this.viewerWorkspace(a.workspace, a.viewer);
1683 if (!found.ok) return found;
1684 const workspace = found.value;
1685 const [rows, setting] = await Promise.all([
1686 this.db
1687 .prepare("SELECT * FROM custom_emoji WHERE workspace_id = ? AND deleted_at IS NULL ORDER BY name")
1688 .bind(workspace.id)
1689 .all<EmojiRow>(),
1690 this.emojiUpload(workspace),
1691 ]);
1692 const role = roleOf(a.viewer, a.workspace);
1693 return ok({
1694 emoji: await this.toEmoji(a.workspace.toLowerCase(), workspace, rows.results),
1695 emoji_upload: setting,
1696 can_upload: mayUpload(setting, role),
1697 can_manage: role === "owner",
1698 });
1699 }
1700
1701 /** Who may add one: checked against the workspace's setting. */
1702 private async mayAdd(workspace: Workspace, viewer: Viewer, slug: string): Promise<Result<null>> {
1703 const setting = await this.emojiUpload(workspace);
1704 if (!mayUpload(setting, roleOf(viewer, slug))) return fail("forbidden", "Only owners can add emoji in this workspace.");
1705 return ok(null);
1706 }
1707
1708 async addEmoji(a: { workspace: string; viewer: Viewer; name: string; file: EmojiFile }): Promise<Result<CustomEmoji>> {
1709 const found = await this.viewerWorkspace(a.workspace, a.viewer);
1710 if (!found.ok) return found;
1711 const workspace = found.value;
1712 const allowed = await this.mayAdd(workspace, a.viewer, a.workspace);
1713 if (!allowed.ok) return allowed;
1714 const named = emojiName(a.name);
1715 if (!named.ok) return fail("invalid", named.message);
1716 if (await this.liveEmoji(workspace, named.name)) return fail("conflict", `:${named.name}: is already taken.`);
1717 const bytes = fromBase64(a.file?.data);
1718 if (!bytes) return fail("invalid", "Choose a PNG, GIF or WebP image of at most 256 KB.");
1719 const checked = emojiImage(bytes);
1720 if (!checked.ok) return fail("invalid", checked.message);
1721 const file = await sha256(bytes);
1722 // Kept by its hash, so the same image stored twice is one file; its
1723 // type is the one read from its bytes (usercontent serves only that).
1724 await this.env.AVATARS.put(`emoji/${file}`, bytes, { metadata: { contentType: checked.image.content_type } });
1725 const row: EmojiRow = {
1726 workspace_id: workspace.id,
1727 name: named.name,
1728 alias_of: null,
1729 file,
1730 content_type: checked.image.content_type,
1731 bytes: bytes.length,
1732 created_by: userKey(a.viewer!),
1733 created_at: now(),
1734 deleted_at: null,
1735 };
1736 const added = await this.insertEmoji(row);
1737 if (!added.ok) return added;
1738 const [emoji] = await this.toEmoji(a.workspace.toLowerCase(), workspace, [row]);
1739 return ok(emoji!);
1740 }
1741
1742 private async insertEmoji(row: EmojiRow): Promise<Result<null>> {
1743 try {
1744 await this.db
1745 .prepare(
1746 "INSERT INTO custom_emoji (workspace_id, name, alias_of, file, content_type, bytes, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
1747 )
1748 .bind(row.workspace_id, row.name, row.alias_of, row.file, row.content_type, row.bytes, row.created_by, row.created_at)
1749 .run();
1750 return ok(null);
1751 } catch (error) {
1752 if (String(error).includes("UNIQUE")) return fail("conflict", `:${row.name}: is already taken.`);
1753 throw error;
1754 }
1755 }
1756
1757 async aliasEmoji(a: { workspace: string; viewer: Viewer; name: string; target: string }): Promise<Result<CustomEmoji>> {
1758 const found = await this.viewerWorkspace(a.workspace, a.viewer);
1759 if (!found.ok) return found;
1760 const workspace = found.value;
1761 const allowed = await this.mayAdd(workspace, a.viewer, a.workspace);
1762 if (!allowed.ok) return allowed;
1763 const named = emojiName(a.name);
1764 if (!named.ok) return fail("invalid", named.message);
1765 const targetName = String(a.target ?? "").trim().replace(/^:+|:+$/g, "").toLowerCase();
1766 let target = await this.liveEmoji(workspace, targetName);
1767 // An alias of an alias names the emoji itself, so removing one never strands another.
1768 if (target?.alias_of) target = await this.liveEmoji(workspace, target.alias_of);
1769 if (!target) return fail("not_found", `This workspace has no :${targetName}: emoji.`);
1770 if (await this.liveEmoji(workspace, named.name)) return fail("conflict", `:${named.name}: is already taken.`);
1771 const row: EmojiRow = { ...target, name: named.name, alias_of: target.name, created_by: userKey(a.viewer!), created_at: now(), deleted_at: null };
1772 const added = await this.insertEmoji(row);
1773 if (!added.ok) return added;
1774 const [emoji] = await this.toEmoji(a.workspace.toLowerCase(), workspace, [row]);
1775 return ok(emoji!);
1776 }
1777
1778 async removeEmoji(a: { workspace: string; viewer: Viewer; name: string }): Promise<Result<null>> {
1779 const found = await this.viewerWorkspace(a.workspace, a.viewer);
1780 if (!found.ok) return found;
1781 const workspace = found.value;
1782 const name = String(a.name ?? "").trim().replace(/^:+|:+$/g, "").toLowerCase();
1783 const row = await this.liveEmoji(workspace, name);
1784 if (!row) return fail("not_found", `This workspace has no :${name}: emoji.`);
1785 if (!mayRemove(row.created_by, userKey(a.viewer!), roleOf(a.viewer, a.workspace))) {
1786 return fail("forbidden", "Only whoever added an emoji, or an owner, can remove it.");
1787 }
1788 // An emoji goes with its aliases; an alias goes alone.
1789 await this.db
1790 .prepare(
1791 "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))",
1792 )
1793 .bind(now(), workspace.id, row.name, row.alias_of ? 1 : 0)
1794 .run();
1795 // Its image goes once nothing live shows it, in any workspace.
1796 this.defer(
1797 (async () => {
1798 const used = await this.db.prepare("SELECT 1 FROM custom_emoji WHERE file = ? AND deleted_at IS NULL LIMIT 1").bind(row.file).first();
1799 if (!used) await this.env.AVATARS.delete(`emoji/${row.file}`);
1800 })().catch((error) => console.error("chat could not forget emoji", row.file, error)),
1801 );
1802 return ok(null);
1803 }
1804
1805 async setEmojiUpload(a: { workspace: string; viewer: Viewer; value: EmojiUpload }): Promise<Result<EmojiUpload>> {
1806 const found = await this.viewerWorkspace(a.workspace, a.viewer);
1807 if (!found.ok) return found;
1808 if (roleOf(a.viewer, a.workspace) !== "owner") return fail("forbidden", "Only owners can change who adds emoji.");
1809 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 project1810 // The same setting as Settings → Chat (`setChatSettings`).
1811 const set = await this.setChatSettings({ workspace: a.workspace, viewer: a.viewer, change: { emoji_upload: a.value } });
1812 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)1813 }
1814
Chat and workspace agents: channels, DMs and named agents you talk to1815 // ── The live socket ─────────────────────────────────────────────────────
1816
1817 /**
1818 * `GET /live?workspace=<slug>&channel=<id>`, upgraded to a WebSocket. The
1819 * viewer comes in CHAT_VIEWER_HEADER, set by the site after checking the
1820 * session; trusted only because this Worker is reachable through service
1821 * bindings alone (`workers_dev` is off and it has no routes). Checked
1822 * like any read, then handed to the channel's room.
1823 */
1824 async live(request: Request): Promise<Response> {
1825 if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") {
1826 return new Response("Expected a WebSocket upgrade\n", { status: 426 });
1827 }
1828 let viewer: Viewer = null;
1829 try {
1830 viewer = JSON.parse(request.headers.get(CHAT_VIEWER_HEADER) ?? "null") as Viewer;
1831 } catch {
1832 viewer = null;
1833 }
1834 if (!viewer?.id) return new Response("Sign in to use chat\n", { status: 401 });
1835 const url = new URL(request.url);
1836 const channelId = url.searchParams.get("channel") ?? "";
1837 let slug = (url.searchParams.get("workspace") ?? "").toLowerCase();
1838 if (!slug) {
1839 // Not named: whichever of the viewer's workspaces holds the channel.
1840 const row = await this.db.prepare("SELECT workspace_id FROM channels WHERE id = ?").bind(channelId).first<{ workspace_id: string }>();
1841 if (row) {
1842 const theirs = await Promise.all((viewer.workspaces ?? []).map((m) => this.workspace(m.slug)));
1843 slug = theirs.find((w) => w?.id === row.workspace_id)?.slug.toLowerCase() ?? "";
1844 }
1845 }
1846 const found = await this.place(slug, channelId, viewer, "read");
1847 if (!found.ok) return new Response(`${found.error.message}\n`, { status: found.error.code === "forbidden" ? 403 : 404 });
1848 const { workspace, channel } = found.value;
1849 const who: RoomMember = { channel_id: channel.id, member: await this.profile(slug, workspace, userKey(viewer)) };
1850 const headers = new Headers(request.headers);
1851 headers.delete(CHAT_VIEWER_HEADER);
1852 headers.set(ROOM_MEMBER_HEADER, JSON.stringify(who));
1853 return this.room(channel.id).fetch(new Request(request.url, { method: "GET", headers }));
1854 }
1855}
1856
1857/** One RPC method's answer. */
1858async function answer(service: Chat, method: string, args: any): Promise<Response> {
1859 switch (method) {
1860 case "sidebar":
1861 return Response.json(await service.sidebar(args));
1862 case "channel":
1863 return Response.json(await service.channel(args));
1864 case "channel_by_name":
1865 return Response.json(await service.channelByName(args));
1866 case "browse":
1867 return Response.json(await service.browse(args));
1868 case "create_channel":
1869 return Response.json(await service.createChannel(args));
Chat controls, public profiles, shadcn selects, and no Docs tab in a project1870 case "update_channel":
1871 return Response.json(await service.updateChannel(args));
1872 case "chat_settings":
1873 return Response.json(await service.chatSettings(args));
1874 case "set_chat_settings":
1875 return Response.json(await service.setChatSettings(args));
Chat and workspace agents: channels, DMs and named agents you talk to1876 case "open_dm":
1877 return Response.json(await service.openDm(args));
1878 case "join":
1879 return Response.json(await service.join(args));
1880 case "leave":
1881 return Response.json(await service.leave(args));
1882 case "invite":
1883 return Response.json(await service.invite(args));
1884 case "messages":
1885 return Response.json(await service.messages(args));
1886 case "post":
1887 return Response.json(await service.post(args));
1888 case "edit":
1889 return Response.json(await service.edit(args));
1890 case "remove":
1891 return Response.json(await service.remove(args));
1892 case "mark_read":
1893 return Response.json(await service.markRead(args));
1894 case "set_preferences":
1895 return Response.json(await service.setPreferences(args));
1896 case "post_as_agent":
1897 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 calendar1898 case "card_action":
1899 return Response.json(await service.cardAction(args));
Chat controls, public profiles, shadcn selects, and no Docs tab in a project1900 case "update_as_agent":
1901 return Response.json(await service.updateAsAgent(args));
Chat and workspace agents: channels, DMs and named agents you talk to1902 case "agent_typing":
1903 return Response.json(await service.agentTyping(args));
1904 case "history_for_agent":
1905 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)1906 case "audience":
1907 return Response.json(await service.audience(args));
1908 case "search_for_agent":
1909 return Response.json(await service.searchForAgent(args));
1910 case "thread_for_agent":
1911 return Response.json(await service.threadForAgent(args));
1912 case "react":
1913 return Response.json(await service.react(args));
1914 case "unreact":
1915 return Response.json(await service.unreact(args));
1916 case "react_as_agent":
1917 return Response.json(await service.reactAsAgent(args));
1918 case "list_emoji":
1919 return Response.json(await service.listEmoji(args));
1920 case "add_emoji":
1921 return Response.json(await service.addEmoji(args));
1922 case "alias_emoji":
1923 return Response.json(await service.aliasEmoji(args));
1924 case "remove_emoji":
1925 return Response.json(await service.removeEmoji(args));
1926 case "set_emoji_upload":
1927 return Response.json(await service.setEmojiUpload(args));
Chat and workspace agents: channels, DMs and named agents you talk to1928 default:
1929 return new Response("Unknown method\n", { status: 404 });
1930 }
1931}
1932
1933export default {
1934 async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
1935 const url = new URL(request.url);
1936 if (request.method === "GET" && url.pathname === "/live") {
1937 return new Chat(env, (work) => ctx.waitUntil(work)).live(request);
1938 }
1939 const match = url.pathname.match(/^\/rpc\/([a-z_]+)$/);
1940 if (request.method !== "POST" || !match) return new Response("Not found\n", { status: 404 });
1941 // A replica near the caller when it asks for one (@g1t/contracts d1.ts).
1942 const opened = openD1(env.DB, request);
1943 const service = new Chat(Object.create(env, { DB: { value: opened.db } }) as Env, (work) => ctx.waitUntil(work));
1944 const args = (await request.json().catch(() => ({}))) as any;
1945 return opened.finish(await answer(service, match[1], args));
1946 },
1947} satisfies ExportedHandler<Env>;

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