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