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