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