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