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