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