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