Skip to content
1,819 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 { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../ui/dropdown-menu";
49import { threadLink, writeUpAgents } from "../../lib/write-up";
50import {
51 AlertDialog,
52 AlertDialogAction,
53 AlertDialogCancel,
54 AlertDialogContent,
55 AlertDialogDescription,
56 AlertDialogFooter,
57 AlertDialogHeader,
58 AlertDialogTitle,
59} from "../ui/alert-dialog";
60import { Badge } from "../ui/badge";
61import { Hint } from "../ui/hint";
62import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
63import { type Mentionable, type ShownMessage, channelPath, mentionNames, mergeMessages, shownHandle, shownName, timeline } from "../../lib/chat";
64import { codeAccessPath } from "../../lib/workspace-nav";
65import { conversationCache } from "./conversation-cache";
66// Reactions and the workspace's own emoji (components/emoji).
67import { EmojiProvider, useCustomEmoji } from "../emoji/context";
68import { AddReaction, QuickReactions, ReactionBar } from "../emoji/reaction-bar";
69import { useAddresses } from "../../lib/addresses";
70import { applyReactionEvent, keepMine, toggleReaction } from "../../lib/emoji";
71import type { ChannelData } from "../../routes/workspace/chat/channel";
72
73type Loaded = Extract<ChannelData, { unavailable: false }>;
74
75/** True once the page has hydrated: times and days are the reader's own from then on. */
76function useHydrated(): boolean {
77 const [hydrated, setHydrated] = useState(false);
78 useEffect(() => setHydrated(true), []);
79 return hydrated;
80}
81
82function clock(at: string, zone: string | undefined): string {
83 return new Date(at).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", timeZone: zone });
84}
85
86/** Whether the viewer uses Code in this workspace (docs/WORKSPACE.md, "Members without Code"). */
87export function useCodeAccess(slug: string): boolean {
88 const root = useRouteLoaderData("root") as { user?: { workspaces?: { slug: string; code_access?: boolean }[] } | null } | undefined;
89 return hasCodeAccess(root?.user?.workspaces?.find((m) => m.slug === slug) ?? null);
90}
91
92/** The project a channel of the same name is about, for a bare `#123`. */
93function useProjectNamed(name: string | null): string | null {
94 const root = useRouteLoaderData("root") as { shell?: { repos?: { name: string }[] } } | undefined;
95 if (!name) return null;
96 return root?.shell?.repos?.some((repo) => repo.name === name) ? name : null;
97}
98
99function newClientId(): string {
100 return `tmp-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
101}
102
103/** A conversation: its header, its messages, the box to write in, and its thread and details beside it. */
104export function ChannelView({ data }: { data: Loaded }) {
105 const chatData = useChatData();
106 const slug = chatData?.slug ?? "";
107 const me = chatData?.me ?? null;
108 const send = useChatSend(slug);
109 const { sidebar, refresh } = useChatSidebar();
110 const hydrated = useHydrated();
111 const zone = hydrated ? undefined : "UTC";
112 const code = useCodeAccess(slug);
113 const project = useProjectNamed(data.channel.kind === "channel" ? data.channel.name : null);
114 const [params, setParams] = useSearchParams();
115 const threadId = params.get("thread");
116
117 const [messages, setMessages] = useState<ShownMessage[]>(() => mergeMessages([], data.messages));
118 const [older, setOlder] = useState<string | null>(data.older);
119 const [loadingOlder, setLoadingOlder] = useState(false);
120 const [thread, setThread] = useState<ShownMessage[] | null>(null);
121 // The message the open thread is under, as its page says, for when it is
122 // further back than the conversation has loaded (a long-running session's card).
123 const [threadRoot, setThreadRoot] = useState<ShownMessage | null>(null);
124 const [typing, setTyping] = useState<Map<string, { member: MemberProfile; until: number }>>(new Map());
125 const [joined, setJoined] = useState(data.joined);
126 const [members, setMembers] = useState<ChannelMember[]>(data.members);
127 // The channel as it is now: renamed, its topic changed, archived, live.
128 const [channel, setChannel] = useState<Channel>(data.channel);
129 const [canManage, setCanManage] = useState(data.can_manage === true);
130 const view = useMemo<Loaded>(() => ({ ...data, channel, title: channel.kind === "channel" ? `#${channel.name}` : data.title }), [data, channel]);
131 // The server's answer behind a cached draw: merged in by id.
132 useEffect(() => {
133 const onFresh = (event: Event) => {
134 const { key, value } = (event as CustomEvent<{ key: string; value: ChannelData }>).detail;
135 if (key !== window.location.pathname || value.unavailable || value.channel.id !== data.channel.id) return;
136 setMessages((now) => mergeMessages(now, value.messages));
137 setMembers(value.members);
138 setJoined(value.joined);
139 setChannel(value.channel);
140 setCanManage(value.can_manage === true);
141 };
142 window.addEventListener("g1t:chat-fresh", onFresh);
143 return () => window.removeEventListener("g1t:chat-fresh", onFresh);
144 }, [data.channel.id]);
145 // The cache follows what is on screen, so coming back shows the latest.
146 useEffect(() => {
147 const path = window.location.pathname;
148 const timer = setTimeout(
149 () => conversationCache.set(path, { ...view, messages: messages.filter((m) => !m.pending).slice(-80), members, joined, can_manage: canManage }),
150 400,
151 );
152 return () => clearTimeout(timer);
153 }, [view, messages, members, joined, canManage]);
154 // Renamed while open, here or by someone else: the address follows the name.
155 const navigate = useNavigate();
156 const nameShown = useRef(data.channel.name);
157 useEffect(() => {
158 if (channel.kind !== "channel" || channel.name === nameShown.current || !slug) return;
159 nameShown.current = channel.name;
160 navigate(`${channelPath(slug, channel)}${window.location.search}`, { replace: true, preventScrollReset: true });
161 }, [channel, slug, navigate]);
162 const [info, setInfo] = useState(false);
163 // A phone: back to the list, by the chevron or a swipe from the edge;
164 // and a long-pressed message's actions.
165 const back = useBack(`/${chatData?.slug ?? ""}/-/chat`);
166 const swipe = useSwipeBack(back);
167 const [actions, setActions] = useState<ShownMessage | null>(null);
168 // Someone's profile, opened from their card, beside the conversation.
169 const [profile, setProfile] = useState<string | null>(null);
170 // A message being edited: in its place on a computer (`inline`), in a sheet on a phone.
171 const [editing, setEditing] = useState<{ id: string; body: string; inline?: boolean } | 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 in Docs", which
497 // asks an agent, in the thread and as the person, for a page 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 mine = (message: ShownMessage) => message.author.kind === "user" && message.author.id === me?.id && !message.pending && !message.deleted_at;
560 // Editing uses the composer, started from the message's Markdown.
561 const editor = (target: { id: string; body: string }) => (
562 <Composer
563 initial={target.body}
564 placeholder="Edit message"
565 people={people}
566 autoFocus
567 compact
568 onCancel={() => setEditing(null)}
569 onSend={async (body) => {
570 setEditing(null);
571 if (body === target.body) return;
572 // Shown edited at once; put back if the service refuses.
573 const before = messages.find((m) => m.id === target.id);
574 setMessages((now) => now.map((m) => (m.id === target.id ? { ...m, body, edited_at: new Date().toISOString() } : m)));
575 const saved = await send<ChatMessage>({ intent: "edit", channel_id: data.channel.id, id: target.id, body });
576 if (saved.ok) setMessages((now) => mergeMessages(now, [saved.value]));
577 else if (before) setMessages((now) => now.map((m) => (m.id === before.id ? before : m)));
578 }}
579 />
580 );
581 const typers = [...typing.values()].map((t) => t.member);
582 const name = channel.name ?? "";
583 const archived = channel.kind === "channel" && !!channel.archived_at;
584 const placeholder = isDm
585 ? `Message ${others.map(shownName).join(", ") || "yourself"}`
586 : `Message #${name}. @ a teammate or an agent`;
587 const rootMessage = threadId ? (messages.find((m) => m.id === threadId) ?? (threadRoot?.id === threadId ? threadRoot : null)) : null;
588 const cardContext = useMemo(
589 () => ({
590 slug,
591 agents: chatData?.agents ?? [],
592 onViewProfile: (username: string) => {
593 if (threadId) openThread(null);
594 setProfile(username);
595 },
596 }),
597 // eslint-disable-next-line react-hooks/exhaustive-deps
598 [slug, chatData?.agents, threadId],
599 );
600
601 return (
602 // On a phone the conversation is the whole screen, as tall as what the
603 // keyboard leaves (components/mobile.tsx), so the composer sits on it.
604 <EmojiProvider value={emojiContext}>
605 <CardContext.Provider value={cardContext}>
606 <div
607 {...swipe}
608 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"
609 >
610 {/* What pressing a card's action did (components/chat/card.tsx). */}
611 <CardToasts />
612 <section aria-label={data.title} className="flex min-w-0 grow flex-col">
613 <ChannelHeader
614 onBack={back}
615 data={view}
616 others={others}
617 starred={starred}
618 muted={muted}
619 memberCount={members.length}
620 live={live.state}
621 info={info}
622 onStar={() => void toggleStar()}
623 onInfo={toggleInfo}
624 />
625 <div
626 ref={scroller}
627 onScroll={(event) => {
628 const element = event.currentTarget;
629 stick.current = element.scrollHeight - element.scrollTop - element.clientHeight < 120;
630 }}
631 className="min-h-0 grow overflow-y-auto overscroll-contain [scrollbar-gutter:stable] [scrollbar-width:thin]"
632 >
633 <div className="mx-auto flex min-h-full max-w-[56rem] flex-col justify-end px-2 pt-6 pb-3 sm:px-4">
634 {older ? (
635 <div className="flex justify-center pb-4">
636 <button
637 type="button"
638 onClick={() => void loadOlder()}
639 className="rounded-full border border-line px-3 py-1 text-xs text-muted transition-colors hover:border-line-strong hover:text-fg"
640 >
641 {loadingOlder ? "Loading…" : "Load earlier messages"}
642 </button>
643 </div>
644 ) : (
645 <ConversationStart data={view} others={others} />
646 )}
647 {rows.map((row) =>
648 row.kind === "day" ? (
649 <DayRule key={row.key} label={row.label} />
650 ) : (
651 <MessageRow
652 key={row.key}
653 message={row.message}
654 head={row.head}
655 zone={zone}
656 context={context}
657 code={code}
658 slug={slug}
659 onThread={() => openThread(row.message.id)}
660 onRetry={() => retry(row.message)}
661 onLongPress={() => setActions(row.message)}
662 onCopyLink={() => copyThreadLink(row.message.id)}
663 onEdit={mine(row.message) ? () => setEditing({ id: row.message.id, body: row.message.body, inline: true }) : undefined}
664 editor={editing?.inline && editing.id === row.message.id ? editor(editing) : undefined}
665 onWriteUp={joined && !archived ? () => setWriteUp(row.message.id) : undefined}
666 threadOpen={row.message.id === threadId}
667 />
668 ),
669 )}
670 </div>
671 </div>
672 <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">
673 <div className="mx-auto max-w-[56rem]">
674 <TypingLine members={typers} />
675 {archived ? (
676 <div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-line bg-surface px-4 py-3">
677 <p className="flex items-center gap-2 text-sm text-muted">
678 <Archive size={15} className="shrink-0 text-faint" />
679 <span>
680 <span className="font-medium text-fg">#{name}</span> is archived. Its history stays here to read; nobody can post in it.
681 </span>
682 </p>
683 {canManage && (
684 <button
685 type="button"
686 onClick={() => void updateChannel({ archived: false })}
687 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"
688 >
689 <ArchiveRestore size={15} />
690 Unarchive
691 </button>
692 )}
693 </div>
694 ) : joined ? (
695 <Composer draftKey={data.channel.id} placeholder={placeholder} people={people} onSend={(body) => void post(body, null)} onTyping={onTyping} autoFocus />
696 ) : (
697 <div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-line bg-surface px-4 py-3">
698 <p className="text-sm text-muted">
699 You're reading <span className="font-medium text-fg">#{name}</span>. Join to post and get its notifications.
700 </p>
701 <button
702 type="button"
703 onClick={() => void join()}
704 className="inline-flex h-9 items-center rounded-md bg-accent px-3.5 text-sm font-medium text-bg hover:bg-accent-hover"
705 >
706 Join channel
707 </button>
708 </div>
709 )}
710 </div>
711 </div>
712 </section>
713 {threadId ? (
714 <SidePanel
715 title="Thread"
716 subtitle={isDm ? undefined : `#${name}`}
717 onClose={() => openThread(null)}
718 menu={<ThreadMenu onCopyLink={() => copyThreadLink(threadId)} onWriteUp={joined && !archived ? () => setWriteUp(threadId) : undefined} />}
719 >
720 <ThreadPanel
721 root={rootMessage}
722 replies={thread}
723 zone={zone}
724 context={context}
725 code={code}
726 slug={slug}
727 people={people}
728 joined={joined && !archived}
729 onSend={(body) => void post(body, threadId)}
730 onRetry={retry}
731 onTyping={onTyping}
732 draftKey={`${data.channel.id}:${threadId}`}
733 />
734 </SidePanel>
735 ) : profile ? (
736 <SidePanel title="Profile" onClose={() => setProfile(null)}>
737 <ProfilePanel slug={slug} username={profile} messages={messages} zone={zone} context={context} />
738 </SidePanel>
739 ) : (
740 info && (
741 <SidePanel title={isDm ? "Details" : "About this channel"} onClose={toggleInfo}>
742 <InfoPanel
743 data={view}
744 members={members}
745 slug={slug}
746 people={people}
747 onAdded={(m) => setMembers((now) => [...now, m])}
748 joined={joined}
749 canManage={canManage}
750 onUpdate={updateChannel}
751 />
752 </SidePanel>
753 )
754 )}
755 <MessageActions
756 message={actions}
757 mine={actions != null && actions.author.kind === "user" && actions.author.id === me?.id}
758 onClose={() => setActions(null)}
759 onThread={(id) => openThread(id)}
760 onCopyLink={(message) => copyThreadLink(message.thread_root ?? message.id)}
761 onWriteUp={joined && !archived ? (message) => setWriteUp(message.thread_root ?? message.id) : undefined}
762 onEdit={(message) => setEditing({ id: message.id, body: message.body })}
763 onDelete={async (message) => {
764 // Gone at once; back if the service refuses.
765 setMessages((now) => now.map((m) => (m.id === message.id ? { ...m, deleted_at: new Date().toISOString() } : m)));
766 const done = await send({ intent: "remove", channel_id: data.channel.id, id: message.id });
767 if (!done.ok) setMessages((now) => now.map((m) => (m.id === message.id ? { ...m, deleted_at: null } : m)));
768 }}
769 />
770 <WriteUpDialog
771 open={writeUp != null}
772 onOpenChange={(open) => !open && setWriteUp(null)}
773 slug={slug}
774 agents={writers}
775 link={writeUp && typeof window !== "undefined" ? linkToThread(writeUp) : ""}
776 onSend={askForWriteUp}
777 />
778 <BottomSheet open={editing != null && !editing.inline} onOpenChange={(open) => !open && setEditing(null)} title="Edit message">
779 {editing && !editing.inline && (
780 <div className="space-y-3 px-1 pt-1">
781 <h2 className="px-2 text-base font-semibold">Edit message</h2>
782 {editor(editing)}
783 </div>
784 )}
785 </BottomSheet>
786 </div>
787 </CardContext.Provider>
788 </EmojiProvider>
789 );
790}
791
792/** The top of a conversation: what it is, and its controls. */
793function ChannelHeader({
794 onBack,
795 data,
796 others,
797 starred,
798 muted,
799 memberCount,
800 live,
801 info,
802 onStar,
803 onInfo,
804}: {
805 /** A phone: back to the list of conversations. */
806 onBack: () => void;
807 data: Loaded;
808 others: MemberProfile[];
809 starred: boolean;
810 muted: boolean;
811 memberCount: number;
812 live: LiveState;
813 info: boolean;
814 onStar: () => void;
815 onInfo: () => void;
816}) {
817 const channel = data.channel;
818 const agentDm = channel.kind === "dm" && others.length === 1 && others[0]!.kind === "agent" ? others[0]! : null;
819 return (
820 <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">
821 <button
822 type="button"
823 onClick={onBack}
824 aria-label="Back to conversations"
825 className="flex size-10 shrink-0 items-center justify-center rounded-full text-muted active:bg-raised md:hidden"
826 >
827 <ChevronLeft size={22} />
828 </button>
829 <div className="flex min-w-0 grow items-center gap-2.5">
830 {channel.kind === "channel" ? (
831 <>
832 <h1 className="flex min-w-0 shrink-0 items-center gap-1 text-[0.9375rem] font-semibold">
833 {channel.private ? (
834 <Hint label="Private channel: only its members can find and read it">
835 <span className="flex">
836 <Lock size={15} className="text-faint" aria-label="Private channel" />
837 </span>
838 </Hint>
839 ) : (
840 <Hash size={16} className="text-faint" aria-label="Public channel" />
841 )}
842 <span className="truncate">{channel.name}</span>
843 </h1>
844 <StarButton starred={starred} onClick={onStar} />
845 {muted && (
846 <Hint label="Muted: no unread count or notifications">
847 <span className="text-faint">
848 <BellOff size={14} aria-label="Muted" />
849 </span>
850 </Hint>
851 )}
852 {channel.archived_at && (
853 <Badge tone="neutral" className="shrink-0">
854 <Archive size={11} />
855 Archived
856 </Badge>
857 )}
858 {channel.topic && (
859 <>
860 <span aria-hidden="true" className="hidden h-4 w-px shrink-0 bg-line sm:block" />
861 <p className="hidden min-w-0 truncate text-sm text-muted sm:block">{channel.topic}</p>
862 </>
863 )}
864 </>
865 ) : (
866 <>
867 <span className="flex shrink-0 -space-x-1.5">
868 {others.slice(0, 3).map((member) => (
869 <span key={`${member.kind}:${member.id}`} className="rounded-full ring-2 ring-bg">
870 <MemberAvatar member={member} size={26} presence={others.length === 1} />
871 </span>
872 ))}
873 </span>
874 <div className="min-w-0">
875 <h1 className="flex min-w-0 items-center gap-1.5 text-[0.9375rem] leading-tight font-semibold">
876 <span className="truncate">{others.map(shownName).join(", ") || "Just you"}</span>
877 {agentDm && <AgentPill />}
878 </h1>
879 {agentDm && (titleOf(agentDm) ?? agentDm.role) && (
880 <p className="truncate text-xs leading-tight text-muted">{titleOf(agentDm) ?? agentDm.role}</p>
881 )}
882 {/* One person: their status under their name, as their card has it. */}
883 {!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" />}
884 </div>
885 <StarButton starred={starred} onClick={onStar} />
886 </>
887 )}
888 </div>
889 {live !== "open" && (
890 <span role="status" className="hidden items-center gap-1.5 text-xs text-faint sm:flex">
891 <span className="size-1.5 animate-pulse rounded-full bg-warn" />
892 {live === "reconnecting" ? "Reconnecting…" : "Connecting…"}
893 </span>
894 )}
895 {channel.kind === "channel" && (
896 <Hint label={info ? "Hide details" : "Members and details"}>
897 <button
898 type="button"
899 onClick={onInfo}
900 aria-pressed={info}
901 aria-label="Members and details"
902 className={`flex h-8 items-center gap-1.5 rounded-md border px-2 text-xs tabular-nums transition-colors ${
903 info ? "border-line-strong bg-raised text-fg" : "border-line text-muted hover:border-line-strong hover:text-fg"
904 }`}
905 >
906 <UsersStack count={memberCount} />
907 </button>
908 </Hint>
909 )}
910 {channel.kind === "dm" && (
911 <Hint label={info ? "Hide details" : "Details"}>
912 <button
913 type="button"
914 onClick={onInfo}
915 aria-pressed={info}
916 aria-label="Details"
917 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"}`}
918 >
919 <PanelRight size={16} />
920 </button>
921 </Hint>
922 )}
923 </header>
924 );
925}
926
927function UsersStack({ count }: { count: number }) {
928 return (
929 <>
930 <Users size={14} />
931 {count}
932 </>
933 );
934}
935
936function StarButton({ starred, onClick }: { starred: boolean; onClick: () => void }) {
937 return (
938 <Hint label={starred ? "Unstar" : "Star: keep it at the top of the sidebar"}>
939 <button
940 type="button"
941 aria-label={starred ? "Unstar" : "Star"}
942 aria-pressed={starred}
943 onClick={onClick}
944 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"}`}
945 >
946 <Star size={15} fill={starred ? "currentColor" : "none"} />
947 </button>
948 </Hint>
949 );
950}
951
952/** How a conversation begins, above its first message. */
953function ConversationStart({ data, others }: { data: Loaded; others: MemberProfile[] }) {
954 const channel = data.channel;
955 if (channel.kind === "dm") {
956 const agent = others.length === 1 && others[0]!.kind === "agent" ? others[0]! : null;
957 return (
958 <div className="px-2 pb-6 sm:px-3">
959 <div className="flex -space-x-2">
960 {others.slice(0, 4).map((m) => (
961 <span key={m.id} className="rounded-full ring-4 ring-bg">
962 <MemberAvatar member={m} size={56} />
963 </span>
964 ))}
965 </div>
966 <h2 className="mt-3 flex items-center gap-2 text-xl font-semibold tracking-tight">
967 {others.map(shownName).join(", ") || "Just you"}
968 {agent && <AgentPill />}
969 </h2>
970 <p className="mt-1 max-w-xl text-sm text-muted">
971 {agent
972 ? `${agent.role ? `${agent.role}. ` : ""}Ask a question or give it a job: it answers here, and opens a task when the work needs one.`
973 : "This is the start of your conversation."}
974 </p>
975 </div>
976 );
977 }
978 return (
979 <div className="px-2 pb-6 sm:px-3">
980 <span className="flex size-12 items-center justify-center rounded-xl bg-raised text-muted">
981 {channel.private ? <Lock size={22} /> : <Hash size={24} />}
982 </span>
983 <h2 className="mt-3 text-xl font-semibold tracking-tight">Welcome to #{channel.name}</h2>
984 <p className="mt-1 max-w-xl text-sm text-muted">{channel.topic ?? "This is the very beginning of the channel."}</p>
985 </div>
986 );
987}
988
989function DayRule({ label }: { label: string }) {
990 return (
991 <div role="separator" aria-label={label} className="relative my-3 flex items-center justify-center">
992 <span aria-hidden="true" className="absolute inset-x-2 top-1/2 h-px bg-line" />
993 <span className="relative rounded-full border border-line bg-bg px-3 py-0.5 text-[0.6875rem] font-medium text-muted" suppressHydrationWarning>
994 {label}
995 </span>
996 </div>
997 );
998}
999
1000/** One message: with its author's name and avatar when it starts a group. */
1001/**
1002 * Someone's profile beside the conversation, opened from their card: who
1003 * they are, their teams and local time, and what they said here lately.
1004 * The conversation stays where it was.
1005 */
1006function ProfilePanel({
1007 slug,
1008 username,
1009 messages,
1010 zone,
1011 context,
1012}: {
1013 slug: string;
1014 username: string;
1015 messages: ShownMessage[];
1016 zone: string | undefined;
1017 context: TextContext;
1018}) {
1019 const [card, setCard] = useState<PersonCard | null | undefined>(undefined);
1020 useEffect(() => {
1021 let live = true;
1022 setCard(undefined);
1023 void personCard(slug, username).then((value) => {
1024 if (live) setCard(value);
1025 });
1026 return () => {
1027 live = false;
1028 };
1029 }, [slug, username]);
1030 const theirs = messages
1031 .filter((m) => m.author.kind === "user" && m.author.name === username && !m.deleted_at && m.body)
1032 .slice(-5)
1033 .reverse();
1034 const time = card ? localTime(card.timezone, Date.now()) : null;
1035 return (
1036 <div className="min-h-0 grow overflow-y-auto p-5 [scrollbar-width:thin]">
1037 <WithPresence person={{ username }} size={88}>
1038 <Avatar name={username} image={card?.avatar ?? null} size={88} />
1039 </WithPresence>
1040 <h3 className="mt-3 text-xl font-semibold tracking-tight">{card?.name?.trim() || (card ? shownUsername(card) : username)}</h3>
1041 <p className="font-mono text-sm text-muted">
1042 @{card ? shownUsername(card) : username}
1043 {card?.pronouns ? <span className="font-sans"> · {card.pronouns}</span> : null}
1044 </p>
1045 <PresenceSummary person={{ username }} className="mt-3 text-sm text-muted" />
1046 {card?.bio && <p className="mt-3 text-sm leading-relaxed text-fg-soft">{card.bio}</p>}
1047 <dl className="mt-4 space-y-2 text-sm">
1048 {card && card.teams.length > 0 && (
1049 <div className="flex gap-3">
1050 <dt className="w-20 shrink-0 text-faint">Teams</dt>
1051 <dd className="text-fg-soft">{card.teams.map((team) => team.name).join(", ")}</dd>
1052 </div>
1053 )}
1054 {time && (
1055 <div className="flex gap-3">
1056 <dt className="w-20 shrink-0 text-faint">Local time</dt>
1057 <dd className="text-fg-soft" suppressHydrationWarning>
1058 {time}
1059 </dd>
1060 </div>
1061 )}
1062 {card?.location && (
1063 <div className="flex gap-3">
1064 <dt className="w-20 shrink-0 text-faint">Location</dt>
1065 <dd className="text-fg-soft">{card.location}</dd>
1066 </div>
1067 )}
1068 {card && card.workspaces.length > 0 && (
1069 <div className="flex gap-3">
1070 <dt className="w-20 shrink-0 text-faint">Workspaces</dt>
1071 <dd className="text-fg-soft">{card.workspaces.map((w) => w.name).join(", ")}</dd>
1072 </div>
1073 )}
1074 </dl>
1075 <h4 className="mt-6 mb-2 text-xs font-medium text-faint">Lately in this conversation</h4>
1076 {theirs.length === 0 ? (
1077 <p className="text-sm text-muted">Nothing here yet.</p>
1078 ) : (
1079 <ul className="space-y-3">
1080 {theirs.map((m) => (
1081 <li key={m.id} className="rounded-lg border border-line bg-surface px-3 py-2.5">
1082 <p className="mb-1 text-[0.6875rem] text-faint" suppressHydrationWarning>
1083 {clock(m.created_at, zone)}
1084 </p>
1085 <div className="line-clamp-4 text-sm">
1086 <MessageText body={m.body} context={context} />
1087 </div>
1088 </li>
1089 ))}
1090 </ul>
1091 )}
1092 <Link to={`/u/${username}`} className="mt-6 inline-block text-sm text-accent hover:underline">
1093 Open their full profile
1094 </Link>
1095 </div>
1096 );
1097}
1098
1099/** An agent's title ("QA Engineer"), when the chat service sends it with the member. */
1100function titleOf(member: MemberProfile): string | null {
1101 return (member as MemberProfile & { title?: string | null }).title ?? null;
1102}
1103
1104/** A long press (half a second, without moving) on a touch screen. */
1105function useLongPress(onLongPress?: () => void) {
1106 const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
1107 const cancel = () => {
1108 if (timer.current) clearTimeout(timer.current);
1109 timer.current = null;
1110 };
1111 if (!onLongPress) return {};
1112 return {
1113 onTouchStart: () => {
1114 cancel();
1115 timer.current = setTimeout(() => {
1116 timer.current = null;
1117 if (typeof navigator !== "undefined" && "vibrate" in navigator) navigator.vibrate?.(8);
1118 onLongPress();
1119 }, 480);
1120 },
1121 onTouchMove: cancel,
1122 onTouchEnd: cancel,
1123 onTouchCancel: cancel,
1124 onContextMenu: (event: React.MouseEvent) => {
1125 // A phone's own long-press menu (copy, look up) would cover ours.
1126 if (window.matchMedia("(pointer: coarse)").matches) event.preventDefault();
1127 },
1128 };
1129}
1130
1131/** A long-pressed message's actions, in a sheet from the bottom: reply, copy, and for your own, edit and delete. */
1132function MessageActions({
1133 message,
1134 mine,
1135 onClose,
1136 onThread,
1137 onCopyLink,
1138 onWriteUp,
1139 onEdit,
1140 onDelete,
1141}: {
1142 message: ShownMessage | null;
1143 mine: boolean;
1144 onClose: () => void;
1145 onThread: (id: string) => void;
1146 onCopyLink: (message: ShownMessage) => void;
1147 /** Absent where the person cannot post (not joined, archived). */
1148 onWriteUp?: (message: ShownMessage) => void;
1149 onEdit: (message: ShownMessage) => void;
1150 onDelete: (message: ShownMessage) => void;
1151}) {
1152 const [confirm, setConfirm] = useState(false);
1153 useEffect(() => setConfirm(false), [message]);
1154 const act = (run: () => void) => () => {
1155 run();
1156 onClose();
1157 };
1158 return (
1159 <BottomSheet open={message != null} onOpenChange={(open) => !open && onClose()} title="Message actions">
1160 {message && (
1161 <>
1162 <p className="mt-1 mb-2 line-clamp-2 px-3 text-sm text-muted">
1163 <span className="font-medium text-fg-soft">{shownName(message.author)}:</span> {message.body || message.card?.title}
1164 </p>
1165 {!message.pending && <QuickReactions messageId={message.id} onDone={onClose} />}
1166 {!message.thread_root && (
1167 <SheetRow icon={<MessageSquareText />} onClick={act(() => onThread(message.id))}>
1168 Reply in thread
1169 </SheetRow>
1170 )}
1171 <SheetRow icon={<Copy />} onClick={act(() => void navigator.clipboard?.writeText(message.body || message.card?.title || ""))}>
1172 Copy text
1173 </SheetRow>
1174 {!message.pending && (
1175 <SheetRow icon={<Link2 />} onClick={act(() => onCopyLink(message))}>
1176 Copy link to thread
1177 </SheetRow>
1178 )}
1179 {!message.pending && onWriteUp && (
1180 <SheetRow icon={<FileText />} onClick={act(() => onWriteUp(message))}>
1181 Write this up in Docs
1182 </SheetRow>
1183 )}
1184 {mine && message.kind === "text" && (
1185 <SheetRow icon={<Pencil />} onClick={act(() => onEdit(message))}>
1186 Edit
1187 </SheetRow>
1188 )}
1189 {mine &&
1190 (confirm ? (
1191 <SheetRow icon={<Trash2 className="text-danger" />} onClick={act(() => onDelete(message))}>
1192 <span className="text-danger">Delete for everyone</span>
1193 </SheetRow>
1194 ) : (
1195 <SheetRow icon={<Trash2 />} onClick={() => setConfirm(true)}>
1196 Delete
1197 </SheetRow>
1198 ))}
1199 </>
1200 )}
1201 </BottomSheet>
1202 );
1203}
1204
1205function MessageRow({
1206 message,
1207 head,
1208 zone,
1209 context,
1210 code,
1211 slug,
1212 onThread,
1213 onRetry,
1214 onLongPress,
1215 onCopyLink,
1216 onWriteUp,
1217 threadOpen,
1218 inThread,
1219 onEdit,
1220 editor,
1221}: {
1222 /** Your own message: "Edit message" in the "⋯", and the composer in its place while editing. */
1223 onEdit?: () => void;
1224 editor?: ReactNode;
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 {editor ? <div className="py-1">{editor}</div> : message.body && <MessageText body={message.body} context={context} />}
1282 {message.card && (
1283 <CardBox
1284 card={message.card}
1285 slug={slug}
1286 code={code}
1287 channelId={message.channel_id}
1288 messageId={message.id}
1289 inert={!!message.pending || !!message.deleted_at}
1290 />
1291 )}
1292 {message.edited_at && <span className="text-[0.6875rem] text-faint"> (edited)</span>}
1293 </div>
1294 {!message.pending && !message.deleted_at && <ReactionBar messageId={message.id} reactions={message.reactions} />}
1295 {message.failed && (
1296 <p className="mt-0.5 text-xs text-danger">
1297 Not sent.{" "}
1298 <button type="button" onClick={onRetry} className="font-medium underline underline-offset-2 hover:text-fg">
1299 Try again
1300 </button>
1301 </p>
1302 )}
1303 {!inThread && message.reply_count > 0 && onThread && (
1304 <button
1305 type="button"
1306 onClick={onThread}
1307 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"
1308 >
1309 <MessageSquareText size={14} />
1310 {message.reply_count} {message.reply_count === 1 ? "reply" : "replies"}
1311 {message.last_reply_at && (
1312 <span className="font-normal text-faint" suppressHydrationWarning>
1313 Last reply {clock(message.last_reply_at, zone)}
1314 </span>
1315 )}
1316 </button>
1317 )}
1318 </div>
1319 {!inThread && onThread && !message.pending && (
1320 <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">
1321 <AddReaction messageId={message.id} />
1322 <Hint label="Reply in thread">
1323 <button
1324 type="button"
1325 aria-label="Reply in thread"
1326 onClick={onThread}
1327 className="flex size-7 items-center justify-center rounded-md text-muted hover:bg-raised hover:text-fg"
1328 >
1329 <MessageSquareText size={15} />
1330 </button>
1331 </Hint>
1332 {onCopyLink && <MoreActions onCopyLink={onCopyLink} onWriteUp={onWriteUp} onEdit={onEdit} />}
1333 </div>
1334 )}
1335 </article>
1336 );
1337}
1338
1339/** A message's "⋯" in its hover toolbar. */
1340function MoreActions({ onCopyLink, onWriteUp, onEdit }: { onCopyLink: () => void; onWriteUp?: () => void; onEdit?: () => void }) {
1341 return (
1342 <DropdownMenu>
1343 <Hint label="More actions">
1344 <DropdownMenuTrigger
1345 aria-label="More actions"
1346 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"
1347 >
1348 <Ellipsis size={15} />
1349 </DropdownMenuTrigger>
1350 </Hint>
1351 <DropdownMenuContent align="end">
1352 {onEdit && (
1353 <DropdownMenuItem onSelect={onEdit}>
1354 <Pencil />
1355 Edit message
1356 </DropdownMenuItem>
1357 )}
1358 <ThreadMenuItems onCopyLink={onCopyLink} onWriteUp={onWriteUp} />
1359 </DropdownMenuContent>
1360 </DropdownMenu>
1361 );
1362}
1363
1364function ThreadMenuItems({ onCopyLink, onWriteUp }: { onCopyLink: () => void; onWriteUp?: () => void }) {
1365 return (
1366 <>
1367 <DropdownMenuItem onSelect={onCopyLink}>
1368 <Link2 />
1369 Copy link to thread
1370 </DropdownMenuItem>
1371 {onWriteUp && (
1372 <DropdownMenuItem onSelect={onWriteUp}>
1373 <FileText />
1374 Write this up in Docs
1375 </DropdownMenuItem>
1376 )}
1377 </>
1378 );
1379}
1380
1381/** The thread panel's "⋯", in its header. */
1382function ThreadMenu({ onCopyLink, onWriteUp }: { onCopyLink: () => void; onWriteUp?: () => void }) {
1383 return (
1384 <DropdownMenu>
1385 <Hint label="Thread actions">
1386 <DropdownMenuTrigger
1387 aria-label="Thread actions"
1388 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"
1389 >
1390 <Ellipsis size={16} />
1391 </DropdownMenuTrigger>
1392 </Hint>
1393 <DropdownMenuContent align="end">
1394 <ThreadMenuItems onCopyLink={onCopyLink} onWriteUp={onWriteUp} />
1395 </DropdownMenuContent>
1396 </DropdownMenu>
1397 );
1398}
1399
1400/** "reviewer is typing…", for whoever is. */
1401/** What an agent working on a reply says it is doing: one phrase per agent, so each sounds like itself. */
1402const 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"];
1403
1404function agentDoing(id: string): string {
1405 let hash = 0;
1406 for (const char of id) hash = (hash * 31 + char.charCodeAt(0)) | 0;
1407 return AGENT_DOING[Math.abs(hash) % AGENT_DOING.length]!;
1408}
1409
1410function TypingLine({ members }: { members: MemberProfile[] }) {
1411 const names = members.map(shownName);
1412 const who = names.length === 1 ? names[0] : names.length === 2 ? `${names[0]} and ${names[1]}` : names.length > 2 ? "Several people" : "";
1413 return (
1414 <div aria-live="polite" className="flex h-6 items-center gap-1.5 px-1 text-xs text-muted">
1415 {who && (
1416 <>
1417 <span aria-hidden="true" className="flex gap-0.5">
1418 {[0, 1, 2].map((i) => (
1419 <span key={i} className="size-1 animate-bounce rounded-full bg-accent" style={{ animationDelay: `${i * 140}ms` }} />
1420 ))}
1421 </span>
1422 <span>
1423 <span className="font-medium text-fg-soft">{who}</span>{" "}
1424 {names.length === 1 ? (members[0]!.kind === "agent" ? agentDoing(members[0]!.id) : "is typing") : "are typing"}…
1425 </span>
1426 </>
1427 )}
1428 </div>
1429 );
1430}
1431
1432/**
1433 * A panel beside the conversation: a thread, or the details. On a phone it
1434 * is a screen of its own, pushed over the conversation, as tall as what
1435 * the keyboard leaves.
1436 */
1437function SidePanel({ title, subtitle, onClose, menu, children }: { title: string; subtitle?: string; onClose: () => void; menu?: ReactNode; children: ReactNode }) {
1438 const swipe = useSwipeBack(onClose);
1439 return (
1440 <aside
1441 {...swipe}
1442 aria-label={title}
1443 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)]"
1444 >
1445 <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">
1446 <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">
1447 <ChevronLeft size={22} />
1448 </button>
1449 <h2 className="text-[0.9375rem] font-semibold">{title}</h2>
1450 {subtitle && <span className="truncate text-sm text-faint">{subtitle}</span>}
1451 <div className="ml-auto flex shrink-0 items-center gap-1">
1452 {menu}
1453 <button type="button" onClick={onClose} aria-label="Close" className="hidden rounded-md p-1 text-faint hover:bg-raised hover:text-fg lg:block">
1454 <X size={16} />
1455 </button>
1456 </div>
1457 </div>
1458 <div className="flex min-h-0 grow flex-col">{children}</div>
1459 </aside>
1460 );
1461}
1462
1463function ThreadPanel({
1464 root,
1465 replies,
1466 zone,
1467 context,
1468 code,
1469 slug,
1470 people,
1471 joined,
1472 onSend,
1473 onRetry,
1474 onTyping,
1475 draftKey,
1476}: {
1477 root: ShownMessage | null;
1478 replies: ShownMessage[] | null;
1479 zone: string | undefined;
1480 context: TextContext;
1481 code: boolean;
1482 slug: string;
1483 people: Mentionable[];
1484 joined: boolean;
1485 onSend: (body: string) => void;
1486 onRetry: (message: ShownMessage) => void;
1487 /** Says "is typing" to the others in the channel, as the main composer does. */
1488 onTyping: () => void;
1489 draftKey: string;
1490}) {
1491 const rows = useMemo(() => timeline(replies ?? [], new Date(), zone).filter((row) => row.kind === "message"), [replies, zone]);
1492 return (
1493 <>
1494 <div className="min-h-0 grow overflow-y-auto px-1 py-3 [scrollbar-width:thin]">
1495 {root ? (
1496 <MessageRow message={root} head zone={zone} context={context} code={code} slug={slug} onRetry={() => onRetry(root)} inThread />
1497 ) : (
1498 replies != null && <p className="px-4 py-2 text-sm text-faint">The message this thread is under is further back.</p>
1499 )}
1500 <div className="my-2 flex items-center gap-3 px-4 text-xs text-faint">
1501 {replies == null ? "Loading replies…" : `${replies.filter((r) => !r.deleted_at).length} ${replies.length === 1 ? "reply" : "replies"}`}
1502 <span className="h-px grow bg-line" />
1503 </div>
1504 {rows.map((row) =>
1505 row.kind === "message" ? (
1506 <MessageRow
1507 key={row.key}
1508 message={row.message}
1509 head={row.head}
1510 zone={zone}
1511 context={context}
1512 code={code}
1513 slug={slug}
1514 onRetry={() => onRetry(row.message)}
1515 inThread
1516 />
1517 ) : null,
1518 )}
1519 </div>
1520 {joined && (
1521 <div className="shrink-0 p-3">
1522 <Composer draftKey={draftKey} placeholder="Reply…" people={people} onSend={onSend} onTyping={onTyping} compact autoFocus />
1523 </div>
1524 )}
1525 </>
1526 );
1527}
1528
1529/**
1530 * A channel's name, topic and visibility, and changing them: any member
1531 * edits the topic; renaming and archiving are for whoever the workspace's
1532 * chat settings allow (`canManage`). #general keeps its name and stays.
1533 */
1534function ChannelAbout({
1535 channel,
1536 joined,
1537 canManage,
1538 onUpdate,
1539}: {
1540 channel: Channel;
1541 joined: boolean;
1542 canManage: boolean;
1543 onUpdate: (change: ChannelChange) => Promise<Result<unknown>>;
1544}) {
1545 const [editing, setEditing] = useState<"name" | "topic" | null>(null);
1546 const [value, setValue] = useState("");
1547 const [error, setError] = useState<string | null>(null);
1548 const [busy, setBusy] = useState(false);
1549 const [confirm, setConfirm] = useState(false);
1550 const general = channel.name === "general";
1551 const archived = !!channel.archived_at;
1552 const mayRename = canManage && !general && !archived;
1553 const mayTopic = joined && !archived;
1554 const save = async (change: ChannelChange) => {
1555 setBusy(true);
1556 setError(null);
1557 const done = await onUpdate(change);
1558 setBusy(false);
1559 if (!done.ok) return setError(done.error.message);
1560 setEditing(null);
1561 setConfirm(false);
1562 };
1563 const start = (field: "name" | "topic") => {
1564 setEditing(field);
1565 setValue(field === "name" ? (channel.name ?? "") : (channel.topic ?? ""));
1566 setError(null);
1567 };
1568 const edit = (field: "name" | "topic") => (
1569 <form
1570 className="mt-1.5 space-y-2"
1571 onSubmit={(event) => {
1572 event.preventDefault();
1573 void save(field === "name" ? { name: value } : { topic: value });
1574 }}
1575 >
1576 <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">
1577 {field === "name" && (channel.private ? <Lock size={13} className="text-faint" /> : <Hash size={13} className="text-faint" />)}
1578 <input
1579 autoFocus
1580 value={value}
1581 onChange={(event) => setValue(event.target.value)}
1582 onKeyDown={(event) => event.key === "Escape" && setEditing(null)}
1583 maxLength={field === "name" ? 80 : 250}
1584 placeholder={field === "name" ? "channel-name" : "What this channel is for"}
1585 aria-label={field === "name" ? "Channel name" : "Topic"}
1586 autoComplete="off"
1587 data-1p-ignore
1588 className="min-w-0 grow bg-transparent text-sm outline-none placeholder:text-faint"
1589 />
1590 </span>
1591 <div className="flex justify-end gap-1.5">
1592 <button type="button" onClick={() => setEditing(null)} className="h-7 rounded-md px-2.5 text-xs text-muted hover:bg-raised hover:text-fg">
1593 Cancel
1594 </button>
1595 <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">
1596 {busy ? "Saving…" : "Save"}
1597 </button>
1598 </div>
1599 </form>
1600 );
1601 const editButton = (field: "name" | "topic", label: string) => (
1602 <button
1603 type="button"
1604 onClick={() => start(field)}
1605 aria-label={label}
1606 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"
1607 >
1608 Edit
1609 </button>
1610 );
1611 return (
1612 <>
1613 <section className="group/about rounded-xl border border-line bg-surface p-3.5">
1614 <div className="flex items-center justify-between">
1615 <h3 className="text-xs font-medium text-faint">Name</h3>
1616 {mayRename && editing !== "name" && editButton("name", "Rename channel")}
1617 </div>
1618 {editing === "name" ? edit("name") : <p className="mt-1 text-sm text-fg-soft">#{channel.name}</p>}
1619 <div className="mt-3 flex items-center justify-between">
1620 <h3 className="text-xs font-medium text-faint">Topic</h3>
1621 {mayTopic && editing !== "topic" && editButton("topic", "Edit topic")}
1622 </div>
1623 {editing === "topic" ? edit("topic") : <p className="mt-1 text-sm text-fg-soft">{channel.topic || "No topic yet."}</p>}
1624 <h3 className="mt-3 text-xs font-medium text-faint">Visibility</h3>
1625 <p className="mt-1 flex items-center gap-1.5 text-sm text-fg-soft">
1626 {channel.private ? <Lock size={13} /> : <Hash size={13} />}
1627 {channel.private ? "Private: only its members can find and read it" : "Public: anyone in the workspace can read and join"}
1628 </p>
1629 {error && <p className="mt-2 text-xs text-danger">{error}</p>}
1630 </section>
1631 {canManage && !general && (
1632 <button
1633 type="button"
1634 onClick={() => (archived ? void save({ archived: false }) : setConfirm(true))}
1635 disabled={busy}
1636 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"
1637 >
1638 {archived ? <ArchiveRestore size={14} /> : <Archive size={14} />}
1639 {archived ? "Unarchive channel" : "Archive channel"}
1640 </button>
1641 )}
1642 <AlertDialog open={confirm} onOpenChange={setConfirm}>
1643 <AlertDialogContent>
1644 <AlertDialogHeader>
1645 <AlertDialogTitle>Archive #{channel.name}?</AlertDialogTitle>
1646 <AlertDialogDescription>
1647 It leaves everyone&apos;s sidebar and nobody can post in it. Its history stays readable, and it can be unarchived from Browse
1648 channels, Archived.
1649 </AlertDialogDescription>
1650 </AlertDialogHeader>
1651 <AlertDialogFooter>
1652 <AlertDialogCancel>Cancel</AlertDialogCancel>
1653 <AlertDialogAction
1654 onClick={(event) => {
1655 event.preventDefault();
1656 void save({ archived: true });
1657 }}
1658 disabled={busy}
1659 >
1660 {busy ? "Archiving…" : "Archive"}
1661 </AlertDialogAction>
1662 </AlertDialogFooter>
1663 </AlertDialogContent>
1664 </AlertDialog>
1665 </>
1666 );
1667}
1668
1669function InfoPanel({
1670 data,
1671 members,
1672 slug,
1673 people,
1674 onAdded,
1675 joined,
1676 canManage,
1677 onUpdate,
1678}: {
1679 data: Loaded;
1680 members: ChannelMember[];
1681 slug: string;
1682 people: Mentionable[];
1683 onAdded: (member: ChannelMember) => void;
1684 joined: boolean;
1685 /** Whether the viewer may rename, archive and unarchive it. */
1686 canManage: boolean;
1687 onUpdate: (change: ChannelChange) => Promise<Result<unknown>>;
1688}) {
1689 const channel = data.channel;
1690 const agents = members.filter((m) => m.member.kind === "agent");
1691 const humans = members.filter((m) => m.member.kind === "user");
1692 return (
1693 <div className="min-h-0 grow overflow-y-auto p-4 [scrollbar-width:thin]">
1694 {channel.kind === "channel" && <ChannelAbout channel={channel} joined={joined} canManage={canManage} onUpdate={onUpdate} />}
1695 {agents.length > 0 && (
1696 <section className="mt-5">
1697 <h3 className="mb-2 px-1 text-xs font-medium text-faint">Agents · {agents.length}</h3>
1698 <div className="flex flex-wrap gap-1.5">
1699 {agents.map(({ member }) => (
1700 <Hint key={member.id} label={member.role ?? undefined}>
1701 <MemberCard
1702 member={member}
1703 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"
1704 >
1705 <MemberAvatar member={member} size={20} />
1706 {shownName(member)}
1707 </MemberCard>
1708 </Hint>
1709 ))}
1710 </div>
1711 </section>
1712 )}
1713 <section className="mt-5">
1714 <div className="mb-1 flex items-center justify-between px-1">
1715 <h3 className="text-xs font-medium text-faint">People · {humans.length}</h3>
1716 {joined && channel.kind === "channel" && <AddMember slug={slug} channelId={channel.id} people={people} members={members} onAdded={onAdded} />}
1717 </div>
1718 <ul className="space-y-px">
1719 {humans.map(({ member, role }) => (
1720 <li key={member.id}>
1721 <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">
1722 <MemberAvatar member={member} size={26} presence />
1723 <span className="min-w-0 grow truncate text-sm">
1724 {shownName(member)}
1725 {shownName(member) !== shownHandle(member) && <span className="ml-1.5 text-faint">@{shownHandle(member)}</span>}
1726 <PersonStatusEmoji person={{ id: member.id, username: member.name }} size={13} className="ml-1.5 align-[-2px]" inert />
1727 </span>
1728 {role === "owner" && <span className="text-[0.6875rem] text-faint">Owner</span>}
1729 </MemberCard>
1730 </li>
1731 ))}
1732 </ul>
1733 </section>
1734 </div>
1735 );
1736}
1737
1738/** Adding a person or an agent to the channel. An invite lets them read; it never grants write. */
1739function AddMember({
1740 slug,
1741 channelId,
1742 people,
1743 members,
1744 onAdded,
1745}: {
1746 slug: string;
1747 channelId: string;
1748 people: Mentionable[];
1749 members: ChannelMember[];
1750 onAdded: (member: ChannelMember) => void;
1751}) {
1752 const [open, setOpen] = useState(false);
1753 const [query, setQuery] = useState("");
1754 const [error, setError] = useState<string | null>(null);
1755 const send = useChatSend(slug);
1756 const chatData = useChatData();
1757 const inside = new Set(members.map((m) => `${m.member.kind}:${m.member.name.toLowerCase()}`));
1758 const q = query.trim().toLowerCase().replace(/^@/, "");
1759 const options = people
1760 .filter((p) => !inside.has(`${p.kind}:${p.name.toLowerCase()}`))
1761 .filter((p) => !q || p.name.toLowerCase().includes(q) || p.display_name.toLowerCase().includes(q))
1762 .slice(0, 8);
1763 const add = async (person: Mentionable) => {
1764 const agent = person.kind === "agent" ? chatData?.agents.find((a) => a.handle === person.name) : null;
1765 const key = agent ? `agent:${agent.id}` : `user:${person.name}`;
1766 setError(null);
1767 const done = await send({ intent: "invite", channel_id: channelId, member: key });
1768 if (!done.ok) return setError(done.error.message);
1769 onAdded({
1770 channel_id: channelId,
1771 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 },
1772 role: "member",
1773 starred: false,
1774 muted: false,
1775 last_read_id: null,
1776 joined_at: new Date().toISOString(),
1777 });
1778 setOpen(false);
1779 };
1780 return (
1781 <Popover open={open} onOpenChange={setOpen}>
1782 <PopoverTrigger asChild>
1783 <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">
1784 <UserPlus size={13} />
1785 Add
1786 </button>
1787 </PopoverTrigger>
1788 <PopoverContent align="end" className="w-72 p-1.5">
1789 <input
1790 autoFocus
1791 value={query}
1792 onChange={(event) => setQuery(event.target.value)}
1793 placeholder="Add a person or an agent"
1794 aria-label="Add a person or an agent"
1795 autoComplete="off"
1796 data-1p-ignore
1797 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"
1798 />
1799 {options.length === 0 && <p className="px-2 py-3 text-xs text-faint">Everyone who matches is already here.</p>}
1800 {options.map((person) => (
1801 <button
1802 key={`${person.kind}:${person.name}`}
1803 type="button"
1804 onClick={() => void add(person)}
1805 className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm hover:bg-line"
1806 >
1807 <MemberAvatar member={person} size={20} />
1808 <span className="min-w-0 grow truncate">{person.display_name}</span>
1809 {person.kind === "agent" && <AgentPill />}
1810 </button>
1811 ))}
1812 {error && <p className="px-2 pt-1 text-xs text-danger">{error}</p>}
1813 <p className="border-t border-line px-2 pt-1.5 pb-0.5 text-[0.6875rem] leading-snug text-faint">
1814 Adding an agent lets it read this channel. It never grants it write access.
1815 </p>
1816 </PopoverContent>
1817 </Popover>
1818 );
1819}