Skip to content
1,821 linesCodeBlameRaw
1import {
2 Archive,
3 ArchiveRestore,
4 ChevronLeft,
5 Copy,
6 Ellipsis,
7 FileText,
8 Link2,
9 Pencil,
10 Trash2,
11 BellOff,
12 Hash,
13 Lock,
14 MessageSquareText,
15 PanelRight,
16 Star,
17 UserPlus,
18 Users,
19 X,
20} from "lucide-react";
21import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
22import { Link, useNavigate, useRouteLoaderData, useSearchParams } from "react-router";
23
24import {
25 type Channel,
26 type ChannelChange,
27 type ChannelMember,
28 type ChatLiveEvent,
29 type ChatMessage,
30 type MemberProfile,
31 type Result,
32 hasCodeAccess,
33 shownUsername,
34} from "@g1t/contracts";
35
36import { useChatData, useChatSend, useChatSidebar } from "./actions";
37import { CardBox, CardToasts, showToast } from "./card";
38import { Composer } from "./composer";
39import { type LiveState, useChatLive } from "./live";
40import { AgentPill, MemberAvatar } from "./marks";
41import { PersonStatusEmoji, PersonStatusLine, PresenceSummary, WithPresence } from "../presence";
42import { CardContext, MemberCard, type PersonCard, personCard } from "./profile-card";
43
44import { localTime } from "../../lib/time-zone";
45import { BottomSheet, SheetRow, useBack, useSwipeBack } from "../mobile";
46import { MessageText, type TextContext } from "./text";
47import { WriteUpDialog } from "./write-up";
48import { FolioUnfurls } from "../folios/unfurl";
49import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../ui/dropdown-menu";
50import { threadLink, writeUpAgents } from "../../lib/write-up";
51import {
52 AlertDialog,
53 AlertDialogAction,
54 AlertDialogCancel,
55 AlertDialogContent,
56 AlertDialogDescription,
57 AlertDialogFooter,
58 AlertDialogHeader,
59 AlertDialogTitle,
60} from "../ui/alert-dialog";
61import { Avatar } from "../ui/avatar";
62import { Badge } from "../ui/badge";
63import { Hint } from "../ui/hint";
64import { Button } from "../ui/button";
65import { Card } from "../ui/card";
66import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
67import { type Mentionable, type ShownMessage, channelPath, mentionNames, mergeMessages, shownHandle, shownName, timeline } from "../../lib/chat";
68import { codeAccessPath } from "../../lib/workspace-nav";
69import { conversationCache } from "./conversation-cache";
70// Reactions and the workspace's own emoji (components/emoji).
71import { EmojiProvider, useCustomEmoji } from "../emoji/context";
72import { AddReaction, QuickReactions, ReactionBar } from "../emoji/reaction-bar";
73import { useAddresses } from "../../lib/addresses";
74import { applyReactionEvent, keepMine, toggleReaction } from "../../lib/emoji";
75import type { ChannelData } from "../../routes/workspace/chat/channel";
76
77type Loaded = Extract<ChannelData, { unavailable: false }>;
78
79/** True once the page has hydrated: times and days are the reader's own from then on. */
80function useHydrated(): boolean {
81 const [hydrated, setHydrated] = useState(false);
82 useEffect(() => setHydrated(true), []);
83 return hydrated;
84}
85
86function clock(at: string, zone: string | undefined): string {
87 return new Date(at).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", timeZone: zone });
88}
89
90/** Whether the viewer uses Code in this workspace. */
91export function useCodeAccess(slug: string): boolean {
92 const root = useRouteLoaderData("root") as { user?: { workspaces?: { slug: string; code_access?: boolean }[] } | null } | undefined;
93 return hasCodeAccess(root?.user?.workspaces?.find((m) => m.slug === slug) ?? null);
94}
95
96/** The project a channel of the same name is about, for a bare `#123`. */
97function useProjectNamed(name: string | null): string | null {
98 const root = useRouteLoaderData("root") as { shell?: { repos?: { name: string }[] } } | undefined;
99 if (!name) return null;
100 return root?.shell?.repos?.some((repo) => repo.name === name) ? name : null;
101}
102
103function newClientId(): string {
104 return `tmp-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
105}
106
107/** A conversation: its header, its messages, the box to write in, and its thread and details beside it. */
108export function ChannelView({ data }: { data: Loaded }) {
109 const chatData = useChatData();
110 const slug = chatData?.slug ?? "";
111 const me = chatData?.me ?? null;
112 const send = useChatSend(slug);
113 const { sidebar, refresh } = useChatSidebar();
114 const hydrated = useHydrated();
115 const zone = hydrated ? undefined : "UTC";
116 const code = useCodeAccess(slug);
117 const project = useProjectNamed(data.channel.kind === "channel" ? data.channel.name : null);
118 const [params, setParams] = useSearchParams();
119 const threadId = params.get("thread");
120
121 const [messages, setMessages] = useState<ShownMessage[]>(() => mergeMessages([], data.messages));
122 const [older, setOlder] = useState<string | null>(data.older);
123 const [loadingOlder, setLoadingOlder] = useState(false);
124 const [thread, setThread] = useState<ShownMessage[] | null>(null);
125 // The message the open thread is under, as its page says, for when it is
126 // further back than the conversation has loaded (a long-running session's card).
127 const [threadRoot, setThreadRoot] = useState<ShownMessage | null>(null);
128 const [typing, setTyping] = useState<Map<string, { member: MemberProfile; until: number }>>(new Map());
129 const [joined, setJoined] = useState(data.joined);
130 const [members, setMembers] = useState<ChannelMember[]>(data.members);
131 // The channel as it is now: renamed, its topic changed, archived, live.
132 const [channel, setChannel] = useState<Channel>(data.channel);
133 const [canManage, setCanManage] = useState(data.can_manage === true);
134 const view = useMemo<Loaded>(() => ({ ...data, channel, title: channel.kind === "channel" ? `#${channel.name}` : data.title }), [data, channel]);
135 // The server's answer behind a cached draw: merged in by id.
136 useEffect(() => {
137 const onFresh = (event: Event) => {
138 const { key, value } = (event as CustomEvent<{ key: string; value: ChannelData }>).detail;
139 if (key !== window.location.pathname || value.unavailable || value.channel.id !== data.channel.id) return;
140 setMessages((now) => mergeMessages(now, value.messages));
141 setMembers(value.members);
142 setJoined(value.joined);
143 setChannel(value.channel);
144 setCanManage(value.can_manage === true);
145 };
146 window.addEventListener("g1t:chat-fresh", onFresh);
147 return () => window.removeEventListener("g1t:chat-fresh", onFresh);
148 }, [data.channel.id]);
149 // The cache follows what is on screen, so coming back shows the latest.
150 useEffect(() => {
151 const path = window.location.pathname;
152 const timer = setTimeout(
153 () => conversationCache.set(path, { ...view, messages: messages.filter((m) => !m.pending).slice(-80), members, joined, can_manage: canManage }),
154 400,
155 );
156 return () => clearTimeout(timer);
157 }, [view, messages, members, joined, canManage]);
158 // Renamed while open, here or by someone else: the address follows the name.
159 const navigate = useNavigate();
160 const nameShown = useRef(data.channel.name);
161 useEffect(() => {
162 if (channel.kind !== "channel" || channel.name === nameShown.current || !slug) return;
163 nameShown.current = channel.name;
164 navigate(`${channelPath(slug, channel)}${window.location.search}`, { replace: true, preventScrollReset: true });
165 }, [channel, slug, navigate]);
166 const [info, setInfo] = useState(false);
167 // A phone: back to the list, by the chevron or a swipe from the edge;
168 // and a long-pressed message's actions.
169 const back = useBack(`/${chatData?.slug ?? ""}/-/chat`);
170 const swipe = useSwipeBack(back);
171 const [actions, setActions] = useState<ShownMessage | null>(null);
172 // Someone's profile, opened from their card, beside the conversation.
173 const [profile, setProfile] = useState<string | null>(null);
174 // A message being edited: in its place on a computer (`inline`), in a sheet on a phone.
175 const [editing, setEditing] = useState<{ id: string; body: string; inline?: boolean } | null>(null);
176 const scroller = useRef<HTMLDivElement>(null);
177 const stick = useRef(true);
178
179 const entry = sidebar?.entries.find((e) => e.channel.id === data.channel.id);
180 const starred = entry?.starred ?? members.find((m) => m.member.kind === "user" && m.member.id === me?.id)?.starred ?? false;
181 const muted = entry?.muted ?? false;
182 const others = members.filter((m) => !(m.member.kind === "user" && m.member.id === me?.id)).map((m) => m.member);
183 const isDm = data.channel.kind === "dm";
184
185 // The details panel: open by default on a wide screen, as last left.
186 useEffect(() => {
187 try {
188 const saved = localStorage.getItem("chat-info");
189 setInfo(saved ? saved === "1" : window.matchMedia("(min-width: 1280px)").matches && !isDm);
190 } catch {
191 setInfo(false);
192 }
193 }, [isDm]);
194 const toggleInfo = () =>
195 setInfo((now) => {
196 try {
197 localStorage.setItem("chat-info", now ? "0" : "1");
198 } catch {
199 // Not kept: fine.
200 }
201 return !now;
202 });
203
204 const people = useMemo<Mentionable[]>(
205 () => [
206 // A personal agent is never mentioned: it answers only in its own DM.
207 ...(chatData?.agents ?? [])
208 .filter((agent) => agent.scope !== "personal")
209 .map((agent) => ({ kind: "agent" as const, name: agent.handle, display_name: agent.display_name, avatar: agent.avatar, role: agent.role })),
210 ...(chatData?.people ?? []).filter((p) => p.name !== me?.username),
211 ],
212 [chatData, me?.username],
213 );
214 const context = useMemo<TextContext>(
215 () => ({
216 slug,
217 me: me?.username ?? null,
218 agents: new Set((chatData?.agents ?? []).map((a) => a.handle.toLowerCase())),
219 // `@ana` reads as the name people know: everyone in the workspace, the viewer too.
220 names: mentionNames([...people, ...(me ? [{ kind: "user" as const, name: me.username, display_username: me.display_username, display_name: me.display_name, avatar: me.avatar }] : [])]),
221 channels: new Set((sidebar?.entries ?? []).filter((e) => e.channel.kind === "channel").map((e) => e.channel.name ?? "")),
222 project,
223 codeLink: code ? undefined : (href: string) => (/^\/[^/]+\/(?!-\/)[^/]+/.test(href) ? codeAccessPath(slug, href) : href),
224 }),
225 [slug, me, people, chatData?.agents, sidebar?.entries, project, code],
226 );
227
228 // Keeps the newest message in view while the reader is at the bottom.
229 const toBottom = useCallback((smooth = false) => {
230 const element = scroller.current;
231 if (element) element.scrollTo({ top: element.scrollHeight, behavior: smooth ? "smooth" : "auto" });
232 }, []);
233 useEffect(() => toBottom(), [toBottom]);
234 useEffect(() => {
235 if (stick.current) toBottom(true);
236 }, [messages.length, typing.size, toBottom]);
237
238 // Read up to the newest message whenever it is in view.
239 const lastRead = useRef<string | null>(null);
240 const markRead = useCallback(() => {
241 if (!joined || document.visibilityState !== "visible") return;
242 const newest = [...messages].reverse().find((m) => !m.pending);
243 if (!newest || newest.id === lastRead.current) return;
244 lastRead.current = newest.id;
245 void send({ intent: "read", channel_id: data.channel.id, id: newest.id });
246 window.dispatchEvent(new CustomEvent("g1t:chat-read", { detail: data.channel.id }));
247 }, [messages, joined, send, data.channel.id]);
248 useEffect(() => {
249 const timer = setTimeout(markRead, 400);
250 document.addEventListener("visibilitychange", markRead);
251 window.addEventListener("focus", markRead);
252 return () => {
253 clearTimeout(timer);
254 document.removeEventListener("visibilitychange", markRead);
255 window.removeEventListener("focus", markRead);
256 };
257 }, [markRead]);
258
259 // The thread open beside the conversation, from `?thread=`.
260 const loadThread = useCallback(async () => {
261 if (!threadId) return setThread(null);
262 try {
263 const response = await fetch(`/${slug}/-/chat/api?channel=${encodeURIComponent(data.channel.id)}&thread=${encodeURIComponent(threadId)}`);
264 const result = (await response.json()) as Result<{ messages: ChatMessage[]; root?: ChatMessage | null }>;
265 // The page reaches back to the message the thread is under: shown above it, not among the replies.
266 setThread(result.ok ? mergeMessages([], result.value.messages.filter((m) => m.id !== threadId && !m.deleted_at)) : []);
267 const root = result.ok ? (result.value.root ?? result.value.messages.find((m) => m.id === threadId) ?? null) : null;
268 setThreadRoot(root);
269 } catch {
270 setThread([]);
271 }
272 }, [threadId, slug, data.channel.id]);
273 useEffect(() => {
274 setThread(null);
275 setThreadRoot(null);
276 void loadThread();
277 }, [loadThread]);
278
279 // Reply counts: counted here only until the service's own count for that
280 // message arrives (a `message.updated` of it), which can come before or
281 // after the reply itself; never twice for one reply.
282 const serverCounted = useRef(new Set<string>());
283 const countedReplies = useRef(new Set<string>());
284 const countReply = useCallback((root: string, reply: string, at: string) => {
285 if (serverCounted.current.has(root) || countedReplies.current.has(reply)) return;
286 countedReplies.current.add(reply);
287 setMessages((now) => now.map((m) => (m.id === root ? { ...m, reply_count: m.reply_count + 1, last_reply_at: at } : m)));
288 }, []);
289 const latest = useRef(messages);
290 latest.current = messages;
291
292 // Everything that happens while the page is open.
293 const onEvent = useCallback(
294 (event: ChatLiveEvent) => {
295 if (event.type === "message.created" || event.type === "message.updated") {
296 const message = event.message;
297 if (message.channel_id !== data.channel.id) return;
298 if (message.thread_root) {
299 if (message.thread_root === threadId) setThread((now) => (now ? mergeMessages(now, [keepMine(now, message)]) : now));
300 if (event.type === "message.created") countReply(message.thread_root, message.id, message.created_at);
301 } else {
302 if (event.type === "message.updated") serverCounted.current.add(message.id);
303 setMessages((now) => mergeMessages(now, [keepMine(now, message)]));
304 // The open thread's root, a session's card say, changes in place at its top too.
305 if (message.id === threadId) setThreadRoot((now) => (now ? keepMine([now], message) : now));
306 }
307 // Whoever just spoke is no longer typing.
308 setTyping((now) => {
309 const key = `${message.author.kind}:${message.author.id}`;
310 if (!now.has(key)) return now;
311 const next = new Map(now);
312 next.delete(key);
313 return next;
314 });
315 } else if (event.type === "message.deleted") {
316 if (event.channel_id !== data.channel.id) return;
317 const gone = (m: ShownMessage) => (m.id === event.id ? { ...m, deleted_at: new Date().toISOString() } : m);
318 setMessages((now) => now.map(gone));
319 setThread((now) => now?.map(gone) ?? now);
320 setThreadRoot((now) => (now ? gone(now) : now));
321 } else if (event.type === "reaction.added" || event.type === "reaction.removed") {
322 if (event.channel_id !== data.channel.id) return;
323 setMessages((now) => applyReactionEvent(now, event, me?.id ?? null));
324 setThread((now) => (now ? applyReactionEvent(now, event, me?.id ?? null) : now));
325 } else if (event.type === "typing") {
326 if (event.channel_id !== data.channel.id) return;
327 if (event.member.kind === "user" && event.member.id === me?.id) return;
328 setTyping((now) => new Map(now).set(`${event.member.kind}:${event.member.id}`, { member: event.member, until: new Date(event.until).getTime() }));
329 } else if (event.type === "channel.updated") {
330 // Renamed, a new topic, archived or back: everyone looking sees it at once.
331 if (event.channel.id !== data.channel.id) return;
332 setChannel(event.channel);
333 refresh();
334 }
335 },
336 [data.channel.id, threadId, me?.id, countReply, refresh],
337 );
338 // Back after a drop: what was missed.
339 const onReconnect = useCallback(async () => {
340 const base = `/${slug}/-/chat/api?channel=${encodeURIComponent(data.channel.id)}`;
341 try {
342 // Everything after the newest message held, page by page, oldest first;
343 // what was deleted meanwhile comes back empty and is dropped.
344 let after = [...latest.current].reverse().find((m) => !m.pending)?.id ?? null;
345 for (let pages = 0; after && pages < 20; pages++) {
346 const response = await fetch(`${base}&after=${encodeURIComponent(after)}`);
347 const result = (await response.json()) as Result<{ messages: ChatMessage[]; newer?: string | null }>;
348 if (!result.ok) break;
349 const gone = new Set(result.value.messages.filter((m) => m.deleted_at).map((m) => m.id));
350 const kept = result.value.messages.filter((m) => !m.deleted_at && !m.thread_root);
351 setMessages((now) => mergeMessages(now.filter((m) => !gone.has(m.id)), kept));
352 after = result.value.newer ?? null;
353 }
354 // Edits and deletions to what was already on screen: the latest page again.
355 const response = await fetch(base);
356 const result = (await response.json()) as Result<{ messages: ChatMessage[] }>;
357 if (result.ok) {
358 const gone = new Set(result.value.messages.filter((m) => m.deleted_at).map((m) => m.id));
359 setMessages((now) => mergeMessages(now.filter((m) => !gone.has(m.id)), result.value.messages.filter((m) => !m.deleted_at)));
360 }
361 } catch {
362 // The next event, or the next return, catches up.
363 }
364 void loadThread();
365 refresh();
366 }, [slug, data.channel.id, loadThread, refresh]);
367 const live = useChatLive(slug, data.channel.id, onEvent, onReconnect);
368
369 // Typing shows until its time runs out.
370 useEffect(() => {
371 if (typing.size === 0) return;
372 const timer = setInterval(() => {
373 const now = Date.now();
374 setTyping((current) => {
375 const next = new Map([...current].filter(([, value]) => value.until > now));
376 return next.size === current.size ? current : next;
377 });
378 }, 1000);
379 return () => clearInterval(timer);
380 }, [typing.size]);
381
382 // Says "is typing" to the others, at most every few seconds.
383 const lastTyping = useRef(0);
384 const onTyping = useCallback(() => {
385 const now = Date.now();
386 if (now - lastTyping.current < 3000) return;
387 lastTyping.current = now;
388 live.send({ type: "typing", channel_id: data.channel.id });
389 }, [live, data.channel.id]);
390
391 const post = useCallback(
392 async (body: string, root: string | null) => {
393 if (!me) return;
394 const clientId = newClientId();
395 const author: MemberProfile = { kind: "user", id: me.id, name: me.username, display_username: me.display_username, display_name: me.display_name, avatar: me.avatar, role: null };
396 const pending: ShownMessage = {
397 id: clientId,
398 client_id: clientId,
399 pending: true,
400 channel_id: data.channel.id,
401 author,
402 kind: "text",
403 body,
404 card: null,
405 thread_root: root,
406 reply_count: 0,
407 last_reply_at: null,
408 created_at: new Date().toISOString(),
409 edited_at: null,
410 deleted_at: null,
411 };
412 const put = (list: ShownMessage[]) => mergeMessages(list, [pending]);
413 if (root) setThread((now) => put(now ?? []));
414 else {
415 stick.current = true;
416 setMessages(put);
417 }
418 const saved = await send<ChatMessage>({ intent: "post", channel_id: data.channel.id, body, thread_root: root });
419 const settle = (list: ShownMessage[]) =>
420 saved.ok
421 ? mergeMessages(list, [{ ...saved.value, client_id: clientId }])
422 : list.map((m) => (m.client_id === clientId ? { ...m, failed: true, pending: true } : m));
423 if (root) {
424 setThread((now) => (now ? settle(now) : now));
425 if (saved.ok) countReply(root, saved.value.id, saved.value.created_at);
426 } else setMessages(settle);
427 },
428 [me, send, data.channel.id, countReply],
429 );
430 // ── Reactions (components/emoji): shown at once, then as the service counts them.
431 const customs = useCustomEmoji(slug);
432 const { usercontent } = useAddresses();
433 const meProfile = useMemo<MemberProfile | null>(
434 () => (me ? { kind: "user", id: me.id, name: me.username, display_username: me.display_username, display_name: me.display_name, avatar: me.avatar, role: null } : null),
435 [me],
436 );
437 const react = useCallback(
438 async (messageId: string, emoji: string, on: boolean) => {
439 if (!meProfile) return;
440 const change = (to: boolean) => (list: ShownMessage[]) =>
441 list.map((m) => (m.id === messageId ? { ...m, reactions: toggleReaction(m.reactions ?? [], emoji, to, meProfile) } : m));
442 setMessages(change(on));
443 setThread((now) => (now ? change(on)(now) : now));
444 try {
445 const response = await fetch(`/${slug}/-/chat/api`, {
446 method: "POST",
447 headers: { "content-type": "application/json" },
448 body: JSON.stringify({ intent: on ? "react" : "unreact", channel_id: data.channel.id, message_id: messageId, emoji }),
449 });
450 const result = (await response.json()) as Result<ChatMessage["reactions"]>;
451 if (!result.ok) throw new Error(result.error.message);
452 const settle = (list: ShownMessage[]) => list.map((m) => (m.id === messageId ? { ...m, reactions: result.value } : m));
453 setMessages(settle);
454 setThread((now) => (now ? settle(now) : now));
455 } catch {
456 // Not saved: back as it was.
457 setMessages(change(!on));
458 setThread((now) => (now ? change(!on)(now) : now));
459 }
460 },
461 [meProfile, slug, data.channel.id],
462 );
463 const emojiContext = useMemo(
464 () => ({ customs, usercontent, me: meProfile, react: (id: string, emoji: string, on: boolean) => void react(id, emoji, on), manageHref: `/${slug}/-/emoji` }),
465 [customs, usercontent, meProfile, react, slug],
466 );
467
468 const retry = (message: ShownMessage) => {
469 const drop = (list: ShownMessage[]) => list.filter((m) => m.client_id !== message.client_id);
470 if (message.thread_root) setThread((now) => (now ? drop(now) : now));
471 else setMessages(drop);
472 void post(message.body, message.thread_root);
473 };
474
475 const loadOlder = async () => {
476 if (!older || loadingOlder) return;
477 setLoadingOlder(true);
478 const element = scroller.current;
479 const fromBottom = element ? element.scrollHeight - element.scrollTop : 0;
480 try {
481 const response = await fetch(`/${slug}/-/chat/api?channel=${encodeURIComponent(data.channel.id)}&before=${encodeURIComponent(older)}`);
482 const result = (await response.json()) as Result<{ messages: ChatMessage[]; older: string | null }>;
483 if (result.ok) {
484 stick.current = false;
485 setMessages((now) => mergeMessages(now, result.value.messages));
486 setOlder(result.value.older);
487 requestAnimationFrame(() => {
488 if (element) element.scrollTop = element.scrollHeight - fromBottom;
489 });
490 }
491 } finally {
492 setLoadingOlder(false);
493 }
494 };
495
496 const openThread = (id: string | null) => {
497 const next = new URLSearchParams(params);
498 if (id) next.set("thread", id);
499 else next.delete("thread");
500 setParams(next, { preventScrollReset: true, replace: !!threadId && !!id });
501 };
502
503 // A thread's link, to copy and to cite; and "Write this up as an artifact",
504 // which asks an agent, in the thread and as the person, for a doc about it.
505 const [writeUp, setWriteUp] = useState<string | null>(null);
506 const linkToThread = (root: string) => threadLink(window.location.origin, channelPath(slug, channel), root);
507 const copyThreadLink = (root: string) => {
508 const done = navigator.clipboard?.writeText(linkToThread(root));
509 if (!done) return showToast(false, "Couldn't copy the link here.");
510 done.then(
511 () => showToast(true, "Link to the thread copied."),
512 () => showToast(false, "Couldn't copy the link here."),
513 );
514 };
515 const writers = useMemo(() => writeUpAgents(members.map((m) => m.member)), [members]);
516 const askForWriteUp = async (body: string): Promise<string | null> => {
517 const root = writeUp;
518 if (!root) return null;
519 // The thread open already: posted as any reply is, shown at once.
520 if (root === threadId) {
521 void post(body, root);
522 return null;
523 }
524 const saved = await send<ChatMessage>({ intent: "post", channel_id: data.channel.id, body, thread_root: root });
525 if (!saved.ok) return saved.error.message;
526 openThread(root);
527 return null;
528 };
529
530 const updateChannel = useCallback(
531 async (change: ChannelChange): Promise<Result<unknown>> => {
532 const done = await send<{ channel: Channel }>({ intent: "update_channel", channel_id: data.channel.id, ...change });
533 if (done.ok) setChannel(done.value.channel);
534 return done;
535 },
536 [send, data.channel.id],
537 );
538
539 const toggleStar = async () => {
540 await send({ intent: "preferences", channel_id: data.channel.id, starred: !starred });
541 refresh();
542 };
543
544 const join = async () => {
545 const done = await send({ intent: "join", channel_id: data.channel.id });
546 if (done.ok) {
547 setJoined(true);
548 if (me) {
549 setMembers((now) => [
550 ...now,
551 {
552 channel_id: data.channel.id,
553 member: { kind: "user", id: me.id, name: me.username, display_username: me.display_username, display_name: me.display_name, avatar: me.avatar, role: null },
554 role: "member",
555 starred: false,
556 muted: false,
557 last_read_id: null,
558 joined_at: new Date().toISOString(),
559 },
560 ]);
561 }
562 }
563 };
564
565 const rows = useMemo(() => timeline(messages, new Date(), zone), [messages, zone]);
566 const mine = (message: ShownMessage) => message.author.kind === "user" && message.author.id === me?.id && !message.pending && !message.deleted_at;
567 // Editing uses the composer, started from the message's Markdown.
568 const editor = (target: { id: string; body: string }) => (
569 <Composer
570 initial={target.body}
571 placeholder="Edit message"
572 people={people}
573 autoFocus
574 compact
575 onCancel={() => setEditing(null)}
576 onSend={async (body) => {
577 setEditing(null);
578 if (body === target.body) return;
579 // Shown edited at once; put back if the service refuses.
580 const before = messages.find((m) => m.id === target.id);
581 setMessages((now) => now.map((m) => (m.id === target.id ? { ...m, body, edited_at: new Date().toISOString() } : m)));
582 const saved = await send<ChatMessage>({ intent: "edit", channel_id: data.channel.id, id: target.id, body });
583 if (saved.ok) setMessages((now) => mergeMessages(now, [saved.value]));
584 else if (before) setMessages((now) => now.map((m) => (m.id === before.id ? before : m)));
585 }}
586 />
587 );
588 const typers = [...typing.values()].map((t) => t.member);
589 const name = channel.name ?? "";
590 const archived = channel.kind === "channel" && !!channel.archived_at;
591 const placeholder = isDm
592 ? `Message ${others.map(shownName).join(", ") || "yourself"}`
593 : `Message #${name}. @ a teammate or an agent`;
594 const rootMessage = threadId ? (messages.find((m) => m.id === threadId) ?? (threadRoot?.id === threadId ? threadRoot : null)) : null;
595 const cardContext = useMemo(
596 () => ({
597 slug,
598 agents: chatData?.agents ?? [],
599 onViewProfile: (username: string) => {
600 if (threadId) openThread(null);
601 setProfile(username);
602 },
603 }),
604 // eslint-disable-next-line react-hooks/exhaustive-deps
605 [slug, chatData?.agents, threadId],
606 );
607
608 return (
609 // On a phone the conversation is the whole screen, as tall as what the
610 // keyboard leaves (components/mobile.tsx), so the composer sits on it.
611 <EmojiProvider value={emojiContext}>
612 <CardContext.Provider value={cardContext}>
613 <div
614 {...swipe}
615 className="flex h-(--page-h) min-h-0 max-md:fixed max-md:inset-x-0 max-md:top-(--vv-top,0px) max-md:z-30 max-md:h-(--vv-height,100dvh) max-md:bg-bg"
616 >
617 {/* What pressing a card's action did (components/chat/card.tsx). */}
618 <CardToasts />
619 <section aria-label={data.title} className="flex min-w-0 grow flex-col">
620 <ChannelHeader
621 onBack={back}
622 data={view}
623 others={others}
624 starred={starred}
625 muted={muted}
626 memberCount={members.length}
627 live={live.state}
628 info={info}
629 onStar={() => void toggleStar()}
630 onInfo={toggleInfo}
631 />
632 <div
633 ref={scroller}
634 onScroll={(event) => {
635 const element = event.currentTarget;
636 stick.current = element.scrollHeight - element.scrollTop - element.clientHeight < 120;
637 }}
638 className="min-h-0 grow overflow-y-auto overscroll-contain [scrollbar-gutter:stable] [scrollbar-width:thin]"
639 >
640 <div className="mx-auto flex min-h-full max-w-[56rem] flex-col justify-end px-2 pt-6 pb-3 sm:px-4">
641 {older ? (
642 <div className="flex justify-center pb-4">
643 <Button
644 type="button"
645 onClick={() => void loadOlder()}
646 variant="outline"
647 size="inline"
648 className="rounded-full px-3 py-1 text-xs text-muted font-normal"
649 >
650 {loadingOlder ? "Loading…" : "Load earlier messages"}
651 </Button>
652 </div>
653 ) : (
654 <ConversationStart data={view} others={others} />
655 )}
656 {rows.map((row) =>
657 row.kind === "day" ? (
658 <DayRule key={row.key} label={row.label} />
659 ) : (
660 <MessageRow
661 key={row.key}
662 message={row.message}
663 head={row.head}
664 zone={zone}
665 context={context}
666 code={code}
667 slug={slug}
668 onThread={() => openThread(row.message.id)}
669 onRetry={() => retry(row.message)}
670 onLongPress={() => setActions(row.message)}
671 onCopyLink={() => copyThreadLink(row.message.id)}
672 onEdit={mine(row.message) ? () => setEditing({ id: row.message.id, body: row.message.body, inline: true }) : undefined}
673 editor={editing?.inline && editing.id === row.message.id ? editor(editing) : undefined}
674 onWriteUp={joined && !archived ? () => setWriteUp(row.message.id) : undefined}
675 threadOpen={row.message.id === threadId}
676 />
677 ),
678 )}
679 </div>
680 </div>
681 <div className="shrink-0 px-2 pb-3 sm:px-4 sm:pb-4 max-md:pb-[max(0.75rem,env(safe-area-inset-bottom))] in-data-[keyboard=open]:pb-2">
682 <div className="mx-auto max-w-[56rem]">
683 <TypingLine members={typers} />
684 {archived ? (
685 <Card className="flex flex-wrap items-center justify-between gap-3 px-4 py-3">
686 <p className="flex items-center gap-2 text-sm text-muted">
687 <Archive size={15} className="shrink-0 text-faint" />
688 <span>
689 <span className="font-medium text-fg">#{name}</span> is archived. Its history stays here to read; nobody can post in it.
690 </span>
691 </p>
692 {canManage && (
693 <Button
694 type="button"
695 onClick={() => void updateChannel({ archived: false })}
696 variant="outline"
697 className="px-3 text-fg/90 hover:bg-raised"
698 >
699 <ArchiveRestore size={15} />
700 Unarchive
701 </Button>
702 )}
703 </Card>
704 ) : joined ? (
705 <Composer draftKey={data.channel.id} placeholder={placeholder} people={people} onSend={(body) => void post(body, null)} onTyping={onTyping} autoFocus />
706 ) : (
707 <Card className="flex flex-wrap items-center justify-between gap-3 px-4 py-3">
708 <p className="text-sm text-muted">
709 You're reading <span className="font-medium text-fg">#{name}</span>. Join to post and get its notifications.
710 </p>
711 <Button
712 type="button"
713 onClick={() => void join()}
714 variant="accent"
715 >
716 Join channel
717 </Button>
718 </Card>
719 )}
720 </div>
721 </div>
722 </section>
723 {threadId ? (
724 <SidePanel
725 title="Thread"
726 subtitle={isDm ? undefined : `#${name}`}
727 onClose={() => openThread(null)}
728 menu={<ThreadMenu onCopyLink={() => copyThreadLink(threadId)} onWriteUp={joined && !archived ? () => setWriteUp(threadId) : undefined} />}
729 >
730 <ThreadPanel
731 root={rootMessage}
732 replies={thread}
733 zone={zone}
734 context={context}
735 code={code}
736 slug={slug}
737 people={people}
738 joined={joined && !archived}
739 onSend={(body) => void post(body, threadId)}
740 onRetry={retry}
741 onTyping={onTyping}
742 draftKey={`${data.channel.id}:${threadId}`}
743 />
744 </SidePanel>
745 ) : profile ? (
746 <SidePanel title="Profile" onClose={() => setProfile(null)}>
747 <ProfilePanel slug={slug} username={profile} messages={messages} zone={zone} context={context} />
748 </SidePanel>
749 ) : (
750 info && (
751 <SidePanel title={isDm ? "Details" : "About this channel"} onClose={toggleInfo}>
752 <InfoPanel
753 data={view}
754 members={members}
755 slug={slug}
756 people={people}
757 onAdded={(m) => setMembers((now) => [...now, m])}
758 joined={joined}
759 canManage={canManage}
760 onUpdate={updateChannel}
761 />
762 </SidePanel>
763 )
764 )}
765 <MessageActions
766 message={actions}
767 mine={actions != null && actions.author.kind === "user" && actions.author.id === me?.id}
768 onClose={() => setActions(null)}
769 onThread={(id) => openThread(id)}
770 onCopyLink={(message) => copyThreadLink(message.thread_root ?? message.id)}
771 onWriteUp={joined && !archived ? (message) => setWriteUp(message.thread_root ?? message.id) : undefined}
772 onEdit={(message) => setEditing({ id: message.id, body: message.body })}
773 onDelete={async (message) => {
774 // Gone at once; back if the service refuses.
775 setMessages((now) => now.map((m) => (m.id === message.id ? { ...m, deleted_at: new Date().toISOString() } : m)));
776 const done = await send({ intent: "remove", channel_id: data.channel.id, id: message.id });
777 if (!done.ok) setMessages((now) => now.map((m) => (m.id === message.id ? { ...m, deleted_at: null } : m)));
778 }}
779 />
780 <WriteUpDialog
781 open={writeUp != null}
782 onOpenChange={(open) => !open && setWriteUp(null)}
783 slug={slug}
784 agents={writers}
785 link={writeUp && typeof window !== "undefined" ? linkToThread(writeUp) : ""}
786 shared={channel.kind === "dm" || channel.private}
787 onSend={askForWriteUp}
788 />
789 <BottomSheet open={editing != null && !editing.inline} onOpenChange={(open) => !open && setEditing(null)} title="Edit message">
790 {editing && !editing.inline && (
791 <div className="space-y-3 px-1 pt-1">
792 <h2 className="px-2 text-base font-semibold">Edit message</h2>
793 {editor(editing)}
794 </div>
795 )}
796 </BottomSheet>
797 </div>
798 </CardContext.Provider>
799 </EmojiProvider>
800 );
801}
802
803/** The top of a conversation: what it is, and its controls. */
804function ChannelHeader({
805 onBack,
806 data,
807 others,
808 starred,
809 muted,
810 memberCount,
811 live,
812 info,
813 onStar,
814 onInfo,
815}: {
816 /** A phone: back to the list of conversations. */
817 onBack: () => void;
818 data: Loaded;
819 others: MemberProfile[];
820 starred: boolean;
821 muted: boolean;
822 memberCount: number;
823 live: LiveState;
824 info: boolean;
825 onStar: () => void;
826 onInfo: () => void;
827}) {
828 const channel = data.channel;
829 const agentDm = channel.kind === "dm" && others.length === 1 && others[0]!.kind === "agent" ? others[0]! : null;
830 return (
831 <header data-page-head className="flex h-14 shrink-0 items-center gap-3 border-b border-line px-4 sm:px-5 max-md:h-[calc(3.5rem+env(safe-area-inset-top))] max-md:gap-2 max-md:pt-[env(safe-area-inset-top)] max-md:pl-1.5">
832 <Button variant="ghost" size="icon-bar" onClick={onBack} aria-label="Back to conversations" className="rounded-full md:hidden">
833 <ChevronLeft size={22} />
834 </Button>
835 <div className="flex min-w-0 grow items-center gap-2.5">
836 {channel.kind === "channel" ? (
837 <>
838 <h1 className="flex min-w-0 shrink-0 items-center gap-1 text-[0.9375rem] font-semibold">
839 {channel.private ? (
840 <Hint label="Private channel: only its members can find and read it">
841 <span className="flex">
842 <Lock size={15} className="text-faint" aria-label="Private channel" />
843 </span>
844 </Hint>
845 ) : (
846 <Hash size={16} className="text-faint" aria-label="Public channel" />
847 )}
848 <span className="truncate">{channel.name}</span>
849 </h1>
850 <StarButton starred={starred} onClick={onStar} />
851 {muted && (
852 <Hint label="Muted: no unread count or notifications">
853 <span className="text-faint">
854 <BellOff size={14} aria-label="Muted" />
855 </span>
856 </Hint>
857 )}
858 {channel.archived_at && (
859 <Badge tone="neutral" className="shrink-0">
860 <Archive size={11} />
861 Archived
862 </Badge>
863 )}
864 {channel.topic && (
865 <>
866 <span aria-hidden="true" className="hidden h-4 w-px shrink-0 bg-line sm:block" />
867 <p className="hidden min-w-0 truncate text-sm text-muted sm:block">{channel.topic}</p>
868 </>
869 )}
870 </>
871 ) : (
872 <>
873 <span className="flex shrink-0 -space-x-1.5">
874 {others.slice(0, 3).map((member) => (
875 <span key={`${member.kind}:${member.id}`} className="rounded-full ring-2 ring-bg">
876 <MemberAvatar member={member} size={26} presence={others.length === 1} />
877 </span>
878 ))}
879 </span>
880 <div className="min-w-0">
881 <h1 className="flex min-w-0 items-center gap-1.5 text-[0.9375rem] leading-tight font-semibold">
882 <span className="truncate">{others.map(shownName).join(", ") || "Just you"}</span>
883 {agentDm && <AgentPill />}
884 </h1>
885 {agentDm && (titleOf(agentDm) ?? agentDm.role) && (
886 <p className="truncate text-xs leading-tight text-muted">{titleOf(agentDm) ?? agentDm.role}</p>
887 )}
888 {/* One person: their status under their name, as their card has it. */}
889 {!agentDm && others.length === 1 && others[0]!.kind === "user" && <PersonStatusLine person={{ id: others[0]!.id, username: others[0]!.name }} className="truncate text-xs leading-tight text-muted" />}
890 </div>
891 <StarButton starred={starred} onClick={onStar} />
892 </>
893 )}
894 </div>
895 {live !== "open" && (
896 <span role="status" className="hidden items-center gap-1.5 text-xs text-faint sm:flex">
897 <span className="size-1.5 animate-pulse rounded-full bg-warn" />
898 {live === "reconnecting" ? "Reconnecting…" : "Connecting…"}
899 </span>
900 )}
901 {channel.kind === "channel" && (
902 <Hint label={info ? "Hide details" : "Members and details"}>
903 <Button variant="outline" size="sm" onClick={onInfo} aria-pressed={info} aria-label="Members and details" className="px-2 text-xs text-muted tabular-nums aria-pressed:border-line-strong aria-pressed:bg-raised aria-pressed:text-fg max-md:h-11">
904 <UsersStack count={memberCount} />
905 </Button>
906 </Hint>
907 )}
908 {channel.kind === "dm" && (
909 <Hint label={info ? "Hide details" : "Details"}>
910 <Button variant="ghost" size="icon-bar" onClick={onInfo} aria-pressed={info} aria-label="Details">
911 <PanelRight size={16} />
912 </Button>
913 </Hint>
914 )}
915 </header>
916 );
917}
918
919function UsersStack({ count }: { count: number }) {
920 return (
921 <>
922 <Users size={14} />
923 {count}
924 </>
925 );
926}
927
928function StarButton({ starred, onClick }: { starred: boolean; onClick: () => void }) {
929 return (
930 <Hint label={starred ? "Unstar" : "Star: keep it at the top of the sidebar"}>
931 <Button
932 variant="ghost"
933 size="icon-sm"
934 aria-label={starred ? "Unstar" : "Star"}
935 aria-pressed={starred}
936 onClick={onClick}
937 className="text-faint aria-pressed:bg-transparent aria-pressed:text-warn aria-pressed:hover:bg-raised max-md:size-10"
938 >
939 <Star size={15} fill={starred ? "currentColor" : "none"} />
940 </Button>
941 </Hint>
942 );
943}
944
945/** How a conversation begins, above its first message. */
946function ConversationStart({ data, others }: { data: Loaded; others: MemberProfile[] }) {
947 const channel = data.channel;
948 if (channel.kind === "dm") {
949 const agent = others.length === 1 && others[0]!.kind === "agent" ? others[0]! : null;
950 return (
951 <div className="px-2 pb-6 sm:px-3">
952 <div className="flex -space-x-2">
953 {others.slice(0, 4).map((m) => (
954 <span key={m.id} className="rounded-full ring-4 ring-bg">
955 <MemberAvatar member={m} size={56} />
956 </span>
957 ))}
958 </div>
959 <h2 className="mt-3 flex items-center gap-2 text-xl font-semibold tracking-tight">
960 {others.map(shownName).join(", ") || "Just you"}
961 {agent && <AgentPill />}
962 </h2>
963 <p className="mt-1 max-w-xl text-sm text-muted">
964 {agent
965 ? `${agent.role ? `${agent.role}. ` : ""}Ask a question or give it a job: it answers here, and opens a task when the work needs one.`
966 : "This is the start of your conversation."}
967 </p>
968 </div>
969 );
970 }
971 return (
972 <div className="px-2 pb-6 sm:px-3">
973 <span className="flex size-12 items-center justify-center rounded-xl bg-raised text-muted">
974 {channel.private ? <Lock size={22} /> : <Hash size={24} />}
975 </span>
976 <h2 className="mt-3 text-xl font-semibold tracking-tight">Welcome to #{channel.name}</h2>
977 <p className="mt-1 max-w-xl text-sm text-muted">{channel.topic ?? "This is the very beginning of the channel."}</p>
978 </div>
979 );
980}
981
982function DayRule({ label }: { label: string }) {
983 return (
984 <div role="separator" aria-label={label} className="relative my-3 flex items-center justify-center">
985 <span aria-hidden="true" className="absolute inset-x-2 top-1/2 h-px bg-line" />
986 <Badge className="relative bg-bg px-3 py-0.5" suppressHydrationWarning>
987 {label}
988 </Badge>
989 </div>
990 );
991}
992
993/** One message: with its author's name and avatar when it starts a group. */
994/**
995 * Someone's profile beside the conversation, opened from their card: who
996 * they are, their teams and local time, and what they said here lately.
997 * The conversation stays where it was.
998 */
999function ProfilePanel({
1000 slug,
1001 username,
1002 messages,
1003 zone,
1004 context,
1005}: {
1006 slug: string;
1007 username: string;
1008 messages: ShownMessage[];
1009 zone: string | undefined;
1010 context: TextContext;
1011}) {
1012 const [card, setCard] = useState<PersonCard | null | undefined>(undefined);
1013 useEffect(() => {
1014 let live = true;
1015 setCard(undefined);
1016 void personCard(slug, username).then((value) => {
1017 if (live) setCard(value);
1018 });
1019 return () => {
1020 live = false;
1021 };
1022 }, [slug, username]);
1023 const theirs = messages
1024 .filter((m) => m.author.kind === "user" && m.author.name === username && !m.deleted_at && m.body)
1025 .slice(-5)
1026 .reverse();
1027 const time = card ? localTime(card.timezone, Date.now()) : null;
1028 return (
1029 <div className="min-h-0 grow overflow-y-auto p-5 [scrollbar-width:thin]">
1030 <WithPresence person={{ username }} size={88}>
1031 <Avatar name={username} image={card?.avatar ?? null} size={88} />
1032 </WithPresence>
1033 <h3 className="mt-3 text-xl font-semibold tracking-tight">{card?.name?.trim() || (card ? shownUsername(card) : username)}</h3>
1034 <p className="font-mono text-sm text-muted">
1035 @{card ? shownUsername(card) : username}
1036 {card?.pronouns ? <span className="font-sans"> · {card.pronouns}</span> : null}
1037 </p>
1038 <PresenceSummary person={{ username }} className="mt-3 text-sm text-muted" />
1039 {card?.bio && <p className="mt-3 text-sm leading-relaxed text-fg-soft">{card.bio}</p>}
1040 <dl className="mt-4 space-y-2 text-sm">
1041 {card && card.teams.length > 0 && (
1042 <div className="flex gap-3">
1043 <dt className="w-20 shrink-0 text-faint">Teams</dt>
1044 <dd className="text-fg-soft">{card.teams.map((team) => team.name).join(", ")}</dd>
1045 </div>
1046 )}
1047 {time && (
1048 <div className="flex gap-3">
1049 <dt className="w-20 shrink-0 text-faint">Local time</dt>
1050 <dd className="text-fg-soft" suppressHydrationWarning>
1051 {time}
1052 </dd>
1053 </div>
1054 )}
1055 {card?.location && (
1056 <div className="flex gap-3">
1057 <dt className="w-20 shrink-0 text-faint">Location</dt>
1058 <dd className="text-fg-soft">{card.location}</dd>
1059 </div>
1060 )}
1061 {card && card.workspaces.length > 0 && (
1062 <div className="flex gap-3">
1063 <dt className="w-20 shrink-0 text-faint">Workspaces</dt>
1064 <dd className="text-fg-soft">{card.workspaces.map((w) => w.name).join(", ")}</dd>
1065 </div>
1066 )}
1067 </dl>
1068 <h4 className="mt-6 mb-2 text-xs font-medium text-faint">Lately in this conversation</h4>
1069 {theirs.length === 0 ? (
1070 <p className="text-sm text-muted">Nothing here yet.</p>
1071 ) : (
1072 <ul className="space-y-3">
1073 {theirs.map((m) => (
1074 <Card asChild key={m.id} radius="lg" className="px-3 py-2.5">
1075 <li>
1076 <p className="mb-1 text-[0.6875rem] text-faint" suppressHydrationWarning>
1077 {clock(m.created_at, zone)}
1078 </p>
1079 <div className="line-clamp-4 text-sm">
1080 <MessageText body={m.body} context={context} />
1081 </div>
1082 </li>
1083 </Card>
1084 ))}
1085 </ul>
1086 )}
1087 <Link to={`/u/${username}`} className="mt-6 inline-block text-sm text-accent hover:underline">
1088 Open their full profile
1089 </Link>
1090 </div>
1091 );
1092}
1093
1094/** An agent's title ("QA Engineer"), when the chat service sends it with the member. */
1095function titleOf(member: MemberProfile): string | null {
1096 return (member as MemberProfile & { title?: string | null }).title ?? null;
1097}
1098
1099/** A long press (half a second, without moving) on a touch screen. */
1100function useLongPress(onLongPress?: () => void) {
1101 const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
1102 const cancel = () => {
1103 if (timer.current) clearTimeout(timer.current);
1104 timer.current = null;
1105 };
1106 if (!onLongPress) return {};
1107 return {
1108 onTouchStart: () => {
1109 cancel();
1110 timer.current = setTimeout(() => {
1111 timer.current = null;
1112 if (typeof navigator !== "undefined" && "vibrate" in navigator) navigator.vibrate?.(8);
1113 onLongPress();
1114 }, 480);
1115 },
1116 onTouchMove: cancel,
1117 onTouchEnd: cancel,
1118 onTouchCancel: cancel,
1119 onContextMenu: (event: React.MouseEvent) => {
1120 // A phone's own long-press menu (copy, look up) would cover ours.
1121 if (window.matchMedia("(pointer: coarse)").matches) event.preventDefault();
1122 },
1123 };
1124}
1125
1126/** A long-pressed message's actions, in a sheet from the bottom: reply, copy, and for your own, edit and delete. */
1127function MessageActions({
1128 message,
1129 mine,
1130 onClose,
1131 onThread,
1132 onCopyLink,
1133 onWriteUp,
1134 onEdit,
1135 onDelete,
1136}: {
1137 message: ShownMessage | null;
1138 mine: boolean;
1139 onClose: () => void;
1140 onThread: (id: string) => void;
1141 onCopyLink: (message: ShownMessage) => void;
1142 /** Absent where the person cannot post (not joined, archived). */
1143 onWriteUp?: (message: ShownMessage) => void;
1144 onEdit: (message: ShownMessage) => void;
1145 onDelete: (message: ShownMessage) => void;
1146}) {
1147 const [confirm, setConfirm] = useState(false);
1148 useEffect(() => setConfirm(false), [message]);
1149 const act = (run: () => void) => () => {
1150 run();
1151 onClose();
1152 };
1153 return (
1154 <BottomSheet open={message != null} onOpenChange={(open) => !open && onClose()} title="Message actions">
1155 {message && (
1156 <>
1157 <p className="mt-1 mb-2 line-clamp-2 px-3 text-sm text-muted">
1158 <span className="font-medium text-fg-soft">{shownName(message.author)}:</span> {message.body || message.card?.title}
1159 </p>
1160 {!message.pending && <QuickReactions messageId={message.id} onDone={onClose} />}
1161 {!message.thread_root && (
1162 <SheetRow icon={<MessageSquareText />} onClick={act(() => onThread(message.id))}>
1163 Reply in thread
1164 </SheetRow>
1165 )}
1166 <SheetRow icon={<Copy />} onClick={act(() => void navigator.clipboard?.writeText(message.body || message.card?.title || ""))}>
1167 Copy text
1168 </SheetRow>
1169 {!message.pending && (
1170 <SheetRow icon={<Link2 />} onClick={act(() => onCopyLink(message))}>
1171 Copy link to thread
1172 </SheetRow>
1173 )}
1174 {!message.pending && onWriteUp && (
1175 <SheetRow icon={<FileText />} onClick={act(() => onWriteUp(message))}>
1176 Write this up as an artifact
1177 </SheetRow>
1178 )}
1179 {mine && message.kind === "text" && (
1180 <SheetRow icon={<Pencil />} onClick={act(() => onEdit(message))}>
1181 Edit
1182 </SheetRow>
1183 )}
1184 {mine &&
1185 (confirm ? (
1186 <SheetRow icon={<Trash2 className="text-danger" />} onClick={act(() => onDelete(message))}>
1187 <span className="text-danger">Delete for everyone</span>
1188 </SheetRow>
1189 ) : (
1190 <SheetRow icon={<Trash2 />} onClick={() => setConfirm(true)}>
1191 Delete
1192 </SheetRow>
1193 ))}
1194 </>
1195 )}
1196 </BottomSheet>
1197 );
1198}
1199
1200function MessageRow({
1201 message,
1202 head,
1203 zone,
1204 context,
1205 code,
1206 slug,
1207 onThread,
1208 onRetry,
1209 onLongPress,
1210 onCopyLink,
1211 onWriteUp,
1212 threadOpen,
1213 inThread,
1214 onEdit,
1215 editor,
1216}: {
1217 /** Your own message: "Edit message" in the "⋯", and the composer in its place while editing. */
1218 onEdit?: () => void;
1219 editor?: ReactNode;
1220 /** A phone: a long press opens the message's actions. */
1221 onLongPress?: () => void;
1222 /** The hover toolbar's "⋯": copy the link to its thread, write it up in Docs. */
1223 onCopyLink?: () => void;
1224 onWriteUp?: () => void;
1225 message: ShownMessage;
1226 head: boolean;
1227 zone: string | undefined;
1228 context: TextContext;
1229 code: boolean;
1230 slug: string;
1231 onThread?: () => void;
1232 onRetry: () => void;
1233 threadOpen?: boolean;
1234 inThread?: boolean;
1235}) {
1236 const author = message.author;
1237 const time = clock(message.created_at, zone);
1238 const press = useLongPress(onLongPress);
1239 return (
1240 <article
1241 {...press}
1242 aria-label={`${shownName(author)}, ${time}`}
1243 className={`group/message relative flex gap-3 rounded-lg px-2 transition-colors hover:bg-surface/70 sm:px-3 max-md:min-h-11 max-md:[-webkit-touch-callout:none] max-md:active:bg-surface/70 ${head ? "mt-3 pt-1 pb-1" : "py-0.5"} ${
1244 threadOpen ? "bg-accent/[0.06] hover:bg-accent/[0.08]" : ""
1245 }`}
1246 >
1247 <div className="w-9 shrink-0">
1248 {head ? (
1249 <MemberCard member={author} className="mt-0.5 block rounded-full" label={`${shownName(author)}'s card`}>
1250 <MemberAvatar member={author} size={36} />
1251 </MemberCard>
1252 ) : (
1253 <time
1254 dateTime={message.created_at}
1255 suppressHydrationWarning
1256 className="mt-1 block text-right text-[0.625rem] leading-5 text-faint tabular-nums opacity-0 group-hover/message:opacity-100"
1257 >
1258 {time.replace(/\s?[AP]M$/, "")}
1259 </time>
1260 )}
1261 </div>
1262 <div className="min-w-0 grow">
1263 {head && (
1264 <div className="flex items-baseline gap-2">
1265 <MemberCard member={author} className="truncate text-[0.9375rem] font-semibold text-fg hover:underline">
1266 {shownName(author)}
1267 </MemberCard>
1268 {author.kind === "user" && <PersonStatusEmoji person={{ id: author.id, username: author.name }} size={14} className="self-center" />}
1269 {author.kind === "agent" && <AgentPill className="self-center" />}
1270 <time dateTime={message.created_at} suppressHydrationWarning className="shrink-0 text-xs text-faint tabular-nums">
1271 {time}
1272 </time>
1273 </div>
1274 )}
1275 <div className={message.pending && !message.failed ? "opacity-60" : undefined}>
1276 {editor ? <div className="py-1">{editor}</div> : message.body && <MessageText body={message.body} context={context} />}
1277 {!editor && message.body && !message.pending && <FolioUnfurls slug={slug} body={message.body} />}
1278 {message.card && (
1279 <CardBox
1280 card={message.card}
1281 slug={slug}
1282 code={code}
1283 channelId={message.channel_id}
1284 messageId={message.id}
1285 inert={!!message.pending || !!message.deleted_at}
1286 />
1287 )}
1288 {message.edited_at && <span className="text-[0.6875rem] text-faint"> (edited)</span>}
1289 </div>
1290 {!message.pending && !message.deleted_at && <ReactionBar messageId={message.id} reactions={message.reactions} />}
1291 {message.failed && (
1292 <p className="mt-0.5 text-xs text-danger">
1293 Not sent.{" "}
1294 <Button type="button" onClick={onRetry} variant="link" size="inline" className="text-inherit underline underline-offset-2 hover:text-fg">
1295 Try again
1296 </Button>
1297 </p>
1298 )}
1299 {!inThread && message.reply_count > 0 && onThread && (
1300 <Button
1301 type="button"
1302 onClick={onThread}
1303 variant="ghost"
1304 size="inline"
1305 className="mt-1 -ml-1 flex gap-2 px-1 py-0.5 text-[0.8125rem] text-accent"
1306 >
1307 <MessageSquareText size={14} />
1308 {message.reply_count} {message.reply_count === 1 ? "reply" : "replies"}
1309 {message.last_reply_at && (
1310 <span className="font-normal text-faint" suppressHydrationWarning>
1311 Last reply {clock(message.last_reply_at, zone)}
1312 </span>
1313 )}
1314 </Button>
1315 )}
1316 </div>
1317 {!inThread && onThread && !message.pending && (
1318 <Card radius="lg" className="absolute -top-3 right-3 hidden p-0.5 shadow-lg shadow-black/30 group-hover/message:flex group-focus-within/message:flex has-[[data-state=open]]:flex">
1319 <AddReaction messageId={message.id} />
1320 <Hint label="Reply in thread">
1321 <Button
1322 type="button"
1323 aria-label="Reply in thread"
1324 onClick={onThread}
1325 variant="ghost"
1326 size="icon-xs"
1327 >
1328 <MessageSquareText size={15} />
1329 </Button>
1330 </Hint>
1331 {onCopyLink && <MoreActions onCopyLink={onCopyLink} onWriteUp={onWriteUp} onEdit={onEdit} />}
1332 </Card>
1333 )}
1334 </article>
1335 );
1336}
1337
1338/** A message's "⋯" in its hover toolbar. */
1339function MoreActions({ onCopyLink, onWriteUp, onEdit }: { onCopyLink: () => void; onWriteUp?: () => void; onEdit?: () => void }) {
1340 return (
1341 <DropdownMenu>
1342 <Hint label="More actions">
1343 <DropdownMenuTrigger
1344 aria-label="More actions"
1345 className="flex size-7 items-center justify-center rounded-md text-muted outline-none hover:bg-raised hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/50"
1346 >
1347 <Ellipsis size={15} />
1348 </DropdownMenuTrigger>
1349 </Hint>
1350 <DropdownMenuContent align="end">
1351 {onEdit && (
1352 <DropdownMenuItem onSelect={onEdit}>
1353 <Pencil />
1354 Edit message
1355 </DropdownMenuItem>
1356 )}
1357 <ThreadMenuItems onCopyLink={onCopyLink} onWriteUp={onWriteUp} />
1358 </DropdownMenuContent>
1359 </DropdownMenu>
1360 );
1361}
1362
1363function ThreadMenuItems({ onCopyLink, onWriteUp }: { onCopyLink: () => void; onWriteUp?: () => void }) {
1364 return (
1365 <>
1366 <DropdownMenuItem onSelect={onCopyLink}>
1367 <Link2 />
1368 Copy link to thread
1369 </DropdownMenuItem>
1370 {onWriteUp && (
1371 <DropdownMenuItem onSelect={onWriteUp}>
1372 <FileText />
1373 Write this up as an artifact
1374 </DropdownMenuItem>
1375 )}
1376 </>
1377 );
1378}
1379
1380/** The thread panel's "⋯", in its header. */
1381function ThreadMenu({ onCopyLink, onWriteUp }: { onCopyLink: () => void; onWriteUp?: () => void }) {
1382 return (
1383 <DropdownMenu>
1384 <Hint label="Thread actions">
1385 <DropdownMenuTrigger asChild>
1386 <Button variant="ghost" size="icon-lg" aria-label="Thread actions" className="rounded-full active:bg-raised lg:size-7 lg:rounded-md">
1387 <Ellipsis size={16} />
1388 </Button>
1389 </DropdownMenuTrigger>
1390 </Hint>
1391 <DropdownMenuContent align="end">
1392 <ThreadMenuItems onCopyLink={onCopyLink} onWriteUp={onWriteUp} />
1393 </DropdownMenuContent>
1394 </DropdownMenu>
1395 );
1396}
1397
1398/** "reviewer is typing…", for whoever is. */
1399/** What an agent working on a reply says it is doing: one phrase per agent, so each sounds like itself. */
1400const AGENT_DOING = ["is reading the thread", "is thinking it through", "is looking into it", "is reading the diff", "is checking the details", "is drafting a reply"];
1401
1402function agentDoing(id: string): string {
1403 let hash = 0;
1404 for (const char of id) hash = (hash * 31 + char.charCodeAt(0)) | 0;
1405 return AGENT_DOING[Math.abs(hash) % AGENT_DOING.length]!;
1406}
1407
1408function TypingLine({ members }: { members: MemberProfile[] }) {
1409 const names = members.map(shownName);
1410 const who = names.length === 1 ? names[0] : names.length === 2 ? `${names[0]} and ${names[1]}` : names.length > 2 ? "Several people" : "";
1411 return (
1412 <div aria-live="polite" className="flex h-6 items-center gap-1.5 px-1 text-xs text-muted">
1413 {who && (
1414 <>
1415 <span aria-hidden="true" className="flex gap-0.5">
1416 {[0, 1, 2].map((i) => (
1417 <span key={i} className="size-1 animate-bounce rounded-full bg-accent" style={{ animationDelay: `${i * 140}ms` }} />
1418 ))}
1419 </span>
1420 <span>
1421 <span className="font-medium text-fg-soft">{who}</span>{" "}
1422 {names.length === 1 ? (members[0]!.kind === "agent" ? agentDoing(members[0]!.id) : "is typing") : "are typing"}…
1423 </span>
1424 </>
1425 )}
1426 </div>
1427 );
1428}
1429
1430/**
1431 * A panel beside the conversation: a thread, or the details. On a phone it
1432 * is a screen of its own, pushed over the conversation, as tall as what
1433 * the keyboard leaves.
1434 */
1435function SidePanel({ title, subtitle, onClose, menu, children }: { title: string; subtitle?: string; onClose: () => void; menu?: ReactNode; children: ReactNode }) {
1436 const swipe = useSwipeBack(onClose);
1437 return (
1438 <aside
1439 {...swipe}
1440 aria-label={title}
1441 className="fixed inset-0 z-40 flex flex-col bg-bg lg:static lg:z-auto lg:w-[22rem] lg:shrink-0 lg:border-l lg:border-line xl:w-[24rem] max-md:top-(--vv-top,0px) max-md:bottom-auto max-md:h-(--vv-height,100dvh) max-md:pb-[env(safe-area-inset-bottom)]"
1442 >
1443 <div className="flex h-14 shrink-0 items-center gap-2 border-b border-line px-4 max-md:h-[calc(3.5rem+env(safe-area-inset-top))] max-md:pt-[env(safe-area-inset-top)] max-md:pl-1.5">
1444 <Button type="button" onClick={onClose} aria-label="Back" variant="ghost" size="icon-lg" className="rounded-full active:bg-raised lg:hidden">
1445 <ChevronLeft size={22} />
1446 </Button>
1447 <h2 className="text-[0.9375rem] font-semibold">{title}</h2>
1448 {subtitle && <span className="truncate text-sm text-faint">{subtitle}</span>}
1449 <div className="ml-auto flex shrink-0 items-center gap-1">
1450 {menu}
1451 <Button type="button" onClick={onClose} aria-label="Close" variant="ghost" size="inline" className="hidden p-1 text-faint lg:block">
1452 <X size={16} />
1453 </Button>
1454 </div>
1455 </div>
1456 <div className="flex min-h-0 grow flex-col">{children}</div>
1457 </aside>
1458 );
1459}
1460
1461function ThreadPanel({
1462 root,
1463 replies,
1464 zone,
1465 context,
1466 code,
1467 slug,
1468 people,
1469 joined,
1470 onSend,
1471 onRetry,
1472 onTyping,
1473 draftKey,
1474}: {
1475 root: ShownMessage | null;
1476 replies: ShownMessage[] | null;
1477 zone: string | undefined;
1478 context: TextContext;
1479 code: boolean;
1480 slug: string;
1481 people: Mentionable[];
1482 joined: boolean;
1483 onSend: (body: string) => void;
1484 onRetry: (message: ShownMessage) => void;
1485 /** Says "is typing" to the others in the channel, as the main composer does. */
1486 onTyping: () => void;
1487 draftKey: string;
1488}) {
1489 const rows = useMemo(() => timeline(replies ?? [], new Date(), zone).filter((row) => row.kind === "message"), [replies, zone]);
1490 return (
1491 <>
1492 <div className="min-h-0 grow overflow-y-auto px-1 py-3 [scrollbar-width:thin]">
1493 {root ? (
1494 <MessageRow message={root} head zone={zone} context={context} code={code} slug={slug} onRetry={() => onRetry(root)} inThread />
1495 ) : (
1496 replies != null && <p className="px-4 py-2 text-sm text-faint">The message this thread is under is further back.</p>
1497 )}
1498 <div className="my-2 flex items-center gap-3 px-4 text-xs text-faint">
1499 {replies == null ? "Loading replies…" : `${replies.filter((r) => !r.deleted_at).length} ${replies.length === 1 ? "reply" : "replies"}`}
1500 <span className="h-px grow bg-line" />
1501 </div>
1502 {rows.map((row) =>
1503 row.kind === "message" ? (
1504 <MessageRow
1505 key={row.key}
1506 message={row.message}
1507 head={row.head}
1508 zone={zone}
1509 context={context}
1510 code={code}
1511 slug={slug}
1512 onRetry={() => onRetry(row.message)}
1513 inThread
1514 />
1515 ) : null,
1516 )}
1517 </div>
1518 {joined && (
1519 <div className="shrink-0 p-3">
1520 <Composer draftKey={draftKey} placeholder="Reply…" people={people} onSend={onSend} onTyping={onTyping} compact autoFocus />
1521 </div>
1522 )}
1523 </>
1524 );
1525}
1526
1527/**
1528 * A channel's name, topic and visibility, and changing them: any member
1529 * edits the topic; renaming and archiving are for whoever the workspace's
1530 * chat settings allow (`canManage`). #general keeps its name and stays.
1531 */
1532function ChannelAbout({
1533 channel,
1534 joined,
1535 canManage,
1536 onUpdate,
1537}: {
1538 channel: Channel;
1539 joined: boolean;
1540 canManage: boolean;
1541 onUpdate: (change: ChannelChange) => Promise<Result<unknown>>;
1542}) {
1543 const [editing, setEditing] = useState<"name" | "topic" | null>(null);
1544 const [value, setValue] = useState("");
1545 const [error, setError] = useState<string | null>(null);
1546 const [busy, setBusy] = useState(false);
1547 const [confirm, setConfirm] = useState(false);
1548 const general = channel.name === "general";
1549 const archived = !!channel.archived_at;
1550 const mayRename = canManage && !general && !archived;
1551 const mayTopic = joined && !archived;
1552 const save = async (change: ChannelChange) => {
1553 setBusy(true);
1554 setError(null);
1555 const done = await onUpdate(change);
1556 setBusy(false);
1557 if (!done.ok) return setError(done.error.message);
1558 setEditing(null);
1559 setConfirm(false);
1560 };
1561 const start = (field: "name" | "topic") => {
1562 setEditing(field);
1563 setValue(field === "name" ? (channel.name ?? "") : (channel.topic ?? ""));
1564 setError(null);
1565 };
1566 const edit = (field: "name" | "topic") => (
1567 <form
1568 className="mt-1.5 space-y-2"
1569 onSubmit={(event) => {
1570 event.preventDefault();
1571 void save(field === "name" ? { name: value } : { topic: value });
1572 }}
1573 >
1574 <span className="flex h-9 items-center gap-1.5 rounded-md border border-line bg-bg px-2.5 focus-within:border-accent-dim">
1575 {field === "name" && (channel.private ? <Lock size={13} className="text-faint" /> : <Hash size={13} className="text-faint" />)}
1576 <input
1577 autoFocus
1578 value={value}
1579 onChange={(event) => setValue(event.target.value)}
1580 onKeyDown={(event) => event.key === "Escape" && setEditing(null)}
1581 maxLength={field === "name" ? 80 : 250}
1582 placeholder={field === "name" ? "channel-name" : "What this channel is for"}
1583 aria-label={field === "name" ? "Channel name" : "Topic"}
1584 autoComplete="off"
1585 data-1p-ignore
1586 className="min-w-0 grow bg-transparent text-sm outline-none placeholder:text-faint"
1587 />
1588 </span>
1589 <div className="flex justify-end gap-1.5">
1590 <Button type="button" onClick={() => setEditing(null)} variant="ghost" size="xs" className="px-2.5 font-normal">
1591 Cancel
1592 </Button>
1593 <Button type="submit" disabled={busy} variant="accent" size="xs" className="px-2.5">
1594 {busy ? "Saving…" : "Save"}
1595 </Button>
1596 </div>
1597 </form>
1598 );
1599 const editButton = (field: "name" | "topic", label: string) => (
1600 <Button
1601 type="button"
1602 onClick={() => start(field)}
1603 aria-label={label}
1604 variant="ghost"
1605 size="inline"
1606 className="rounded px-1.5 py-0.5 text-xs opacity-0 transition-opacity group-hover/about:opacity-100 focus-visible:opacity-100 [@media(hover:none)]:opacity-100 font-normal"
1607 >
1608 Edit
1609 </Button>
1610 );
1611 return (
1612 <>
1613 <Card asChild className="group/about p-3.5">
1614 <section>
1615 <div className="flex items-center justify-between">
1616 <h3 className="text-xs font-medium text-faint">Name</h3>
1617 {mayRename && editing !== "name" && editButton("name", "Rename channel")}
1618 </div>
1619 {editing === "name" ? edit("name") : <p className="mt-1 text-sm text-fg-soft">#{channel.name}</p>}
1620 <div className="mt-3 flex items-center justify-between">
1621 <h3 className="text-xs font-medium text-faint">Topic</h3>
1622 {mayTopic && editing !== "topic" && editButton("topic", "Edit topic")}
1623 </div>
1624 {editing === "topic" ? edit("topic") : <p className="mt-1 text-sm text-fg-soft">{channel.topic || "No topic yet."}</p>}
1625 <h3 className="mt-3 text-xs font-medium text-faint">Visibility</h3>
1626 <p className="mt-1 flex items-center gap-1.5 text-sm text-fg-soft">
1627 {channel.private ? <Lock size={13} /> : <Hash size={13} />}
1628 {channel.private ? "Private: only its members can find and read it" : "Public: anyone in the workspace can read and join"}
1629 </p>
1630 {error && <p className="mt-2 text-xs text-danger">{error}</p>}
1631 </section>
1632 </Card>
1633 {canManage && !general && (
1634 <button
1635 type="button"
1636 onClick={() => (archived ? void save({ archived: false }) : setConfirm(true))}
1637 disabled={busy}
1638 className="mt-2 flex h-8 w-full items-center justify-center gap-2 rounded-md text-[0.8125rem] text-muted transition-colors hover:bg-raised hover:text-fg disabled:opacity-50"
1639 >
1640 {archived ? <ArchiveRestore size={14} /> : <Archive size={14} />}
1641 {archived ? "Unarchive channel" : "Archive channel"}
1642 </button>
1643 )}
1644 <AlertDialog open={confirm} onOpenChange={setConfirm}>
1645 <AlertDialogContent>
1646 <AlertDialogHeader>
1647 <AlertDialogTitle>Archive #{channel.name}?</AlertDialogTitle>
1648 <AlertDialogDescription>
1649 It leaves everyone&apos;s sidebar and nobody can post in it. Its history stays readable, and it can be unarchived from Browse
1650 channels, Archived.
1651 </AlertDialogDescription>
1652 </AlertDialogHeader>
1653 <AlertDialogFooter>
1654 <AlertDialogCancel>Cancel</AlertDialogCancel>
1655 <AlertDialogAction
1656 onClick={(event) => {
1657 event.preventDefault();
1658 void save({ archived: true });
1659 }}
1660 disabled={busy}
1661 >
1662 {busy ? "Archiving…" : "Archive"}
1663 </AlertDialogAction>
1664 </AlertDialogFooter>
1665 </AlertDialogContent>
1666 </AlertDialog>
1667 </>
1668 );
1669}
1670
1671function InfoPanel({
1672 data,
1673 members,
1674 slug,
1675 people,
1676 onAdded,
1677 joined,
1678 canManage,
1679 onUpdate,
1680}: {
1681 data: Loaded;
1682 members: ChannelMember[];
1683 slug: string;
1684 people: Mentionable[];
1685 onAdded: (member: ChannelMember) => void;
1686 joined: boolean;
1687 /** Whether the viewer may rename, archive and unarchive it. */
1688 canManage: boolean;
1689 onUpdate: (change: ChannelChange) => Promise<Result<unknown>>;
1690}) {
1691 const channel = data.channel;
1692 const agents = members.filter((m) => m.member.kind === "agent");
1693 const humans = members.filter((m) => m.member.kind === "user");
1694 return (
1695 <div className="min-h-0 grow overflow-y-auto p-4 [scrollbar-width:thin]">
1696 {channel.kind === "channel" && <ChannelAbout channel={channel} joined={joined} canManage={canManage} onUpdate={onUpdate} />}
1697 {agents.length > 0 && (
1698 <section className="mt-5">
1699 <h3 className="mb-2 px-1 text-xs font-medium text-faint">Agents · {agents.length}</h3>
1700 <div className="flex flex-wrap gap-1.5">
1701 {agents.map(({ member }) => (
1702 <Hint key={member.id} label={member.role ?? undefined}>
1703 <MemberCard
1704 member={member}
1705 className="flex h-7 items-center gap-1.5 rounded-full border border-accent/25 bg-accent/10 pr-2.5 pl-1 text-[0.8125rem] font-medium text-accent transition-colors hover:bg-accent/15"
1706 >
1707 <MemberAvatar member={member} size={20} />
1708 {shownName(member)}
1709 </MemberCard>
1710 </Hint>
1711 ))}
1712 </div>
1713 </section>
1714 )}
1715 <section className="mt-5">
1716 <div className="mb-1 flex items-center justify-between px-1">
1717 <h3 className="text-xs font-medium text-faint">People · {humans.length}</h3>
1718 {joined && channel.kind === "channel" && <AddMember slug={slug} channelId={channel.id} people={people} members={members} onAdded={onAdded} />}
1719 </div>
1720 <ul className="space-y-px">
1721 {humans.map(({ member, role }) => (
1722 <li key={member.id}>
1723 <MemberCard member={member} className="flex w-full items-center gap-2.5 rounded-md px-1 py-1.5 transition-colors hover:bg-raised/60 max-md:min-h-11">
1724 <MemberAvatar member={member} size={26} presence />
1725 <span className="min-w-0 grow truncate text-sm">
1726 {shownName(member)}
1727 {shownName(member) !== shownHandle(member) && <span className="ml-1.5 text-faint">@{shownHandle(member)}</span>}
1728 <PersonStatusEmoji person={{ id: member.id, username: member.name }} size={13} className="ml-1.5 align-[-2px]" inert />
1729 </span>
1730 {role === "owner" && <span className="text-[0.6875rem] text-faint">Owner</span>}
1731 </MemberCard>
1732 </li>
1733 ))}
1734 </ul>
1735 </section>
1736 </div>
1737 );
1738}
1739
1740/** Adding a person or an agent to the channel. An invite lets them read; it never grants write. */
1741function AddMember({
1742 slug,
1743 channelId,
1744 people,
1745 members,
1746 onAdded,
1747}: {
1748 slug: string;
1749 channelId: string;
1750 people: Mentionable[];
1751 members: ChannelMember[];
1752 onAdded: (member: ChannelMember) => void;
1753}) {
1754 const [open, setOpen] = useState(false);
1755 const [query, setQuery] = useState("");
1756 const [error, setError] = useState<string | null>(null);
1757 const send = useChatSend(slug);
1758 const chatData = useChatData();
1759 const inside = new Set(members.map((m) => `${m.member.kind}:${m.member.name.toLowerCase()}`));
1760 const q = query.trim().toLowerCase().replace(/^@/, "");
1761 const options = people
1762 .filter((p) => !inside.has(`${p.kind}:${p.name.toLowerCase()}`))
1763 .filter((p) => !q || p.name.toLowerCase().includes(q) || p.display_name.toLowerCase().includes(q))
1764 .slice(0, 8);
1765 const add = async (person: Mentionable) => {
1766 const agent = person.kind === "agent" ? chatData?.agents.find((a) => a.handle === person.name) : null;
1767 const key = agent ? `agent:${agent.id}` : `user:${person.name}`;
1768 setError(null);
1769 const done = await send({ intent: "invite", channel_id: channelId, member: key });
1770 if (!done.ok) return setError(done.error.message);
1771 onAdded({
1772 channel_id: channelId,
1773 member: { kind: person.kind, id: agent?.id ?? person.name, name: person.name, display_username: person.display_username ?? null, display_name: person.display_name, avatar: person.avatar, role: person.role ?? null },
1774 role: "member",
1775 starred: false,
1776 muted: false,
1777 last_read_id: null,
1778 joined_at: new Date().toISOString(),
1779 });
1780 setOpen(false);
1781 };
1782 return (
1783 <Popover open={open} onOpenChange={setOpen}>
1784 <PopoverTrigger asChild>
1785 <Button type="button" variant="ghost" size="inline" className="flex gap-1 px-1.5 py-0.5 text-xs font-normal">
1786 <UserPlus size={13} />
1787 Add
1788 </Button>
1789 </PopoverTrigger>
1790 <PopoverContent align="end" className="w-72 p-1.5">
1791 <input
1792 autoFocus
1793 value={query}
1794 onChange={(event) => setQuery(event.target.value)}
1795 placeholder="Add a person or an agent"
1796 aria-label="Add a person or an agent"
1797 autoComplete="off"
1798 data-1p-ignore
1799 className="mb-1 h-8 w-full rounded-md border border-line bg-bg px-2.5 text-sm outline-none placeholder:text-faint focus:border-accent-dim"
1800 />
1801 {options.length === 0 && <p className="px-2 py-3 text-xs text-faint">Everyone who matches is already here.</p>}
1802 {options.map((person) => (
1803 <button
1804 key={`${person.kind}:${person.name}`}
1805 type="button"
1806 onClick={() => void add(person)}
1807 className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm hover:bg-line"
1808 >
1809 <MemberAvatar member={person} size={20} />
1810 <span className="min-w-0 grow truncate">{person.display_name}</span>
1811 {person.kind === "agent" && <AgentPill />}
1812 </button>
1813 ))}
1814 {error && <p className="px-2 pt-1 text-xs text-danger">{error}</p>}
1815 <p className="border-t border-line px-2 pt-1.5 pb-0.5 text-[0.6875rem] leading-snug text-faint">
1816 Adding an agent lets it read this channel. It never grants it write access.
1817 </p>
1818 </PopoverContent>
1819 </Popover>
1820 );
1821}