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