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