Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Chat and workspace agents: channels, DMs and named agents you talk to | 1 | import { |
| 2 | ArrowLeft, | |
| 3 | BellOff, | |
| 4 | CircleDot, | |
| 5 | GitPullRequest, | |
| 6 | Hash, | |
| 7 | ListChecks, | |
| 8 | Lock, | |
| 9 | MessageSquareText, | |
| 10 | PanelRight, | |
| 11 | Rocket, | |
| 12 | ShieldCheck, | |
| 13 | Sparkles, | |
| 14 | Star, | |
| 15 | UserPlus, | |
| 16 | Users, | |
| 17 | X, | |
| 18 | } from "lucide-react"; | |
| 19 | import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| 20 | import { Link, useRouteLoaderData, useSearchParams } from "react-router"; | |
| 21 | ||
| 22 | import { type ChannelMember, type ChatLiveEvent, type ChatMessage, type MemberProfile, type MessageCard, type Result, hasCodeAccess } from "@g1t/contracts"; | |
| 23 | ||
| 24 | import { useChatData, useChatSend, useChatSidebar } from "./actions"; | |
| 25 | import { Composer } from "./composer"; | |
| 26 | import { type LiveState, useChatLive } from "./live"; | |
| 27 | import { AgentPill, MemberAvatar } from "./marks"; | |
| 28 | import { MessageText, type TextContext } from "./text"; | |
| 29 | import { Badge, type BadgeTone } from "../ui/badge"; | |
| 30 | import { Hint } from "../ui/hint"; | |
| 31 | import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; | |
| 32 | import { type Mentionable, type ShownMessage, mergeMessages, shownName, timeline } from "../../lib/chat"; | |
| 33 | import { codeAccessPath } from "../../lib/workspace-nav"; | |
| 34 | import type { ChannelData } from "../../routes/workspace/chat/channel"; | |
| 35 | ||
| 36 | type Loaded = Extract<ChannelData, { unavailable: false }>; | |
| 37 | ||
| 38 | /** True once the page has hydrated: times and days are the reader's own from then on. */ | |
| 39 | function useHydrated(): boolean { | |
| 40 | const [hydrated, setHydrated] = useState(false); | |
| 41 | useEffect(() => setHydrated(true), []); | |
| 42 | return hydrated; | |
| 43 | } | |
| 44 | ||
| 45 | function clock(at: string, zone: string | undefined): string { | |
| 46 | return new Date(at).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", timeZone: zone }); | |
| 47 | } | |
| 48 | ||
| 49 | /** Whether the viewer uses Code in this workspace (docs/WORKSPACE.md, "Members without Code"). */ | |
| 50 | export function useCodeAccess(slug: string): boolean { | |
| 51 | const root = useRouteLoaderData("root") as { user?: { workspaces?: { slug: string; code_access?: boolean }[] } | null } | undefined; | |
| 52 | return hasCodeAccess(root?.user?.workspaces?.find((m) => m.slug === slug) ?? null); | |
| 53 | } | |
| 54 | ||
| 55 | /** The project a channel of the same name is about, for a bare `#123`. */ | |
| 56 | function useProjectNamed(name: string | null): string | null { | |
| 57 | const root = useRouteLoaderData("root") as { shell?: { repos?: { name: string }[] } } | undefined; | |
| 58 | if (!name) return null; | |
| 59 | return root?.shell?.repos?.some((repo) => repo.name === name) ? name : null; | |
| 60 | } | |
| 61 | ||
| 62 | function newClientId(): string { | |
| 63 | return `tmp-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; | |
| 64 | } | |
| 65 | ||
| 66 | /** A conversation: its header, its messages, the box to write in, and its thread and details beside it. */ | |
| 67 | export function ChannelView({ data }: { data: Loaded }) { | |
| 68 | const chatData = useChatData(); | |
| 69 | const slug = chatData?.slug ?? ""; | |
| 70 | const me = chatData?.me ?? null; | |
| 71 | const send = useChatSend(slug); | |
| 72 | const { sidebar, refresh } = useChatSidebar(); | |
| 73 | const hydrated = useHydrated(); | |
| 74 | const zone = hydrated ? undefined : "UTC"; | |
| 75 | const code = useCodeAccess(slug); | |
| 76 | const project = useProjectNamed(data.channel.kind === "channel" ? data.channel.name : null); | |
| 77 | const [params, setParams] = useSearchParams(); | |
| 78 | const threadId = params.get("thread"); | |
| 79 | ||
| 80 | const [messages, setMessages] = useState<ShownMessage[]>(() => mergeMessages([], data.messages)); | |
| 81 | const [older, setOlder] = useState<string | null>(data.older); | |
| 82 | const [loadingOlder, setLoadingOlder] = useState(false); | |
| 83 | const [thread, setThread] = useState<ShownMessage[] | null>(null); | |
| 84 | const [typing, setTyping] = useState<Map<string, { member: MemberProfile; until: number }>>(new Map()); | |
| 85 | const [joined, setJoined] = useState(data.joined); | |
| 86 | const [members, setMembers] = useState<ChannelMember[]>(data.members); | |
| 87 | const [info, setInfo] = useState(false); | |
| 88 | const scroller = useRef<HTMLDivElement>(null); | |
| 89 | const stick = useRef(true); | |
| 90 | ||
| 91 | const entry = sidebar?.entries.find((e) => e.channel.id === data.channel.id); | |
| 92 | const starred = entry?.starred ?? members.find((m) => m.member.kind === "user" && m.member.id === me?.id)?.starred ?? false; | |
| 93 | const muted = entry?.muted ?? false; | |
| 94 | const others = members.filter((m) => !(m.member.kind === "user" && m.member.id === me?.id)).map((m) => m.member); | |
| 95 | const isDm = data.channel.kind === "dm"; | |
| 96 | ||
| 97 | // The details panel: open by default on a wide screen, as last left. | |
| 98 | useEffect(() => { | |
| 99 | try { | |
| 100 | const saved = localStorage.getItem("chat-info"); | |
| 101 | setInfo(saved ? saved === "1" : window.matchMedia("(min-width: 1280px)").matches && !isDm); | |
| 102 | } catch { | |
| 103 | setInfo(false); | |
| 104 | } | |
| 105 | }, [isDm]); | |
| 106 | const toggleInfo = () => | |
| 107 | setInfo((now) => { | |
| 108 | try { | |
| 109 | localStorage.setItem("chat-info", now ? "0" : "1"); | |
| 110 | } catch { | |
| 111 | // Not kept: fine. | |
| 112 | } | |
| 113 | return !now; | |
| 114 | }); | |
| 115 | ||
| 116 | const people = useMemo<Mentionable[]>( | |
| 117 | () => [ | |
| 118 | ...(chatData?.agents ?? []).map((agent) => ({ kind: "agent" as const, name: agent.handle, display_name: agent.display_name, avatar: agent.avatar, role: agent.role })), | |
| 119 | ...(chatData?.people ?? []).filter((p) => p.name !== me?.username), | |
| 120 | ], | |
| 121 | [chatData, me?.username], | |
| 122 | ); | |
| 123 | const context = useMemo<TextContext>( | |
| 124 | () => ({ | |
| 125 | slug, | |
| 126 | me: me?.username ?? null, | |
| 127 | agents: new Set((chatData?.agents ?? []).map((a) => a.handle.toLowerCase())), | |
| 128 | channels: new Set((sidebar?.entries ?? []).filter((e) => e.channel.kind === "channel").map((e) => e.channel.name ?? "")), | |
| 129 | project, | |
| 130 | codeLink: code ? undefined : (href: string) => (/^\/[^/]+\/(?!-\/)[^/]+/.test(href) ? codeAccessPath(slug, href) : href), | |
| 131 | }), | |
| 132 | [slug, me?.username, chatData?.agents, sidebar?.entries, project, code], | |
| 133 | ); | |
| 134 | ||
| 135 | // Keeps the newest message in view while the reader is at the bottom. | |
| 136 | const toBottom = useCallback((smooth = false) => { | |
| 137 | const element = scroller.current; | |
| 138 | if (element) element.scrollTo({ top: element.scrollHeight, behavior: smooth ? "smooth" : "auto" }); | |
| 139 | }, []); | |
| 140 | useEffect(() => toBottom(), [toBottom]); | |
| 141 | useEffect(() => { | |
| 142 | if (stick.current) toBottom(true); | |
| 143 | }, [messages.length, typing.size, toBottom]); | |
| 144 | ||
| 145 | // Read up to the newest message whenever it is in view. | |
| 146 | const lastRead = useRef<string | null>(null); | |
| 147 | const markRead = useCallback(() => { | |
| 148 | if (!joined || document.visibilityState !== "visible") return; | |
| 149 | const newest = [...messages].reverse().find((m) => !m.pending); | |
| 150 | if (!newest || newest.id === lastRead.current) return; | |
| 151 | lastRead.current = newest.id; | |
| 152 | void send({ intent: "read", channel_id: data.channel.id, id: newest.id }); | |
| 153 | window.dispatchEvent(new CustomEvent("g1t:chat-read", { detail: data.channel.id })); | |
| 154 | }, [messages, joined, send, data.channel.id]); | |
| 155 | useEffect(() => { | |
| 156 | const timer = setTimeout(markRead, 400); | |
| 157 | document.addEventListener("visibilitychange", markRead); | |
| 158 | window.addEventListener("focus", markRead); | |
| 159 | return () => { | |
| 160 | clearTimeout(timer); | |
| 161 | document.removeEventListener("visibilitychange", markRead); | |
| 162 | window.removeEventListener("focus", markRead); | |
| 163 | }; | |
| 164 | }, [markRead]); | |
| 165 | ||
| 166 | // The thread open beside the conversation, from `?thread=`. | |
| 167 | const loadThread = useCallback(async () => { | |
| 168 | if (!threadId) return setThread(null); | |
| 169 | try { | |
| 170 | const response = await fetch(`/${slug}/-/chat/api?channel=${encodeURIComponent(data.channel.id)}&thread=${encodeURIComponent(threadId)}`); | |
| 171 | const result = (await response.json()) as Result<{ messages: ChatMessage[] }>; | |
| 172 | // The page reaches back to the message the thread is under: shown above it, not among the replies. | |
| 173 | setThread(result.ok ? mergeMessages([], result.value.messages.filter((m) => m.id !== threadId && !m.deleted_at)) : []); | |
| 174 | } catch { | |
| 175 | setThread([]); | |
| 176 | } | |
| 177 | }, [threadId, slug, data.channel.id]); | |
| 178 | useEffect(() => { | |
| 179 | setThread(null); | |
| 180 | void loadThread(); | |
| 181 | }, [loadThread]); | |
| 182 | ||
| 183 | // Reply counts: counted here only until the service's own count for that | |
| 184 | // message arrives (a `message.updated` of it), which can come before or | |
| 185 | // after the reply itself; never twice for one reply. | |
| 186 | const serverCounted = useRef(new Set<string>()); | |
| 187 | const countedReplies = useRef(new Set<string>()); | |
| 188 | const countReply = useCallback((root: string, reply: string, at: string) => { | |
| 189 | if (serverCounted.current.has(root) || countedReplies.current.has(reply)) return; | |
| 190 | countedReplies.current.add(reply); | |
| 191 | setMessages((now) => now.map((m) => (m.id === root ? { ...m, reply_count: m.reply_count + 1, last_reply_at: at } : m))); | |
| 192 | }, []); | |
| 193 | const latest = useRef(messages); | |
| 194 | latest.current = messages; | |
| 195 | ||
| 196 | // Everything that happens while the page is open. | |
| 197 | const onEvent = useCallback( | |
| 198 | (event: ChatLiveEvent) => { | |
| 199 | if (event.type === "message.created" || event.type === "message.updated") { | |
| 200 | const message = event.message; | |
| 201 | if (message.channel_id !== data.channel.id) return; | |
| 202 | if (message.thread_root) { | |
| 203 | if (message.thread_root === threadId) setThread((now) => (now ? mergeMessages(now, [message]) : now)); | |
| 204 | if (event.type === "message.created") countReply(message.thread_root, message.id, message.created_at); | |
| 205 | } else { | |
| 206 | if (event.type === "message.updated") serverCounted.current.add(message.id); | |
| 207 | setMessages((now) => mergeMessages(now, [message])); | |
| 208 | } | |
| 209 | // Whoever just spoke is no longer typing. | |
| 210 | setTyping((now) => { | |
| 211 | const key = `${message.author.kind}:${message.author.id}`; | |
| 212 | if (!now.has(key)) return now; | |
| 213 | const next = new Map(now); | |
| 214 | next.delete(key); | |
| 215 | return next; | |
| 216 | }); | |
| 217 | } else if (event.type === "message.deleted") { | |
| 218 | if (event.channel_id !== data.channel.id) return; | |
| 219 | const gone = (m: ShownMessage) => (m.id === event.id ? { ...m, deleted_at: new Date().toISOString() } : m); | |
| 220 | setMessages((now) => now.map(gone)); | |
| 221 | setThread((now) => now?.map(gone) ?? now); | |
| 222 | } else if (event.type === "typing") { | |
| 223 | if (event.channel_id !== data.channel.id) return; | |
| 224 | if (event.member.kind === "user" && event.member.id === me?.id) return; | |
| 225 | setTyping((now) => new Map(now).set(`${event.member.kind}:${event.member.id}`, { member: event.member, until: new Date(event.until).getTime() })); | |
| 226 | } | |
| 227 | }, | |
| 228 | [data.channel.id, threadId, me?.id, countReply], | |
| 229 | ); | |
| 230 | // Back after a drop: what was missed. | |
| 231 | const onReconnect = useCallback(async () => { | |
| 232 | const base = `/${slug}/-/chat/api?channel=${encodeURIComponent(data.channel.id)}`; | |
| 233 | try { | |
| 234 | // Everything after the newest message held, page by page, oldest first; | |
| 235 | // what was deleted meanwhile comes back empty and is dropped. | |
| 236 | let after = [...latest.current].reverse().find((m) => !m.pending)?.id ?? null; | |
| 237 | for (let pages = 0; after && pages < 20; pages++) { | |
| 238 | const response = await fetch(`${base}&after=${encodeURIComponent(after)}`); | |
| 239 | const result = (await response.json()) as Result<{ messages: ChatMessage[]; newer?: string | null }>; | |
| 240 | if (!result.ok) break; | |
| 241 | const gone = new Set(result.value.messages.filter((m) => m.deleted_at).map((m) => m.id)); | |
| 242 | const kept = result.value.messages.filter((m) => !m.deleted_at && !m.thread_root); | |
| 243 | setMessages((now) => mergeMessages(now.filter((m) => !gone.has(m.id)), kept)); | |
| 244 | after = result.value.newer ?? null; | |
| 245 | } | |
| 246 | // Edits and deletions to what was already on screen: the latest page again. | |
| 247 | const response = await fetch(base); | |
| 248 | const result = (await response.json()) as Result<{ messages: ChatMessage[] }>; | |
| 249 | if (result.ok) { | |
| 250 | const gone = new Set(result.value.messages.filter((m) => m.deleted_at).map((m) => m.id)); | |
| 251 | setMessages((now) => mergeMessages(now.filter((m) => !gone.has(m.id)), result.value.messages.filter((m) => !m.deleted_at))); | |
| 252 | } | |
| 253 | } catch { | |
| 254 | // The next event, or the next return, catches up. | |
| 255 | } | |
| 256 | void loadThread(); | |
| 257 | refresh(); | |
| 258 | }, [slug, data.channel.id, loadThread, refresh]); | |
| 259 | const live = useChatLive(slug, data.channel.id, onEvent, onReconnect); | |
| 260 | ||
| 261 | // Typing shows until its time runs out. | |
| 262 | useEffect(() => { | |
| 263 | if (typing.size === 0) return; | |
| 264 | const timer = setInterval(() => { | |
| 265 | const now = Date.now(); | |
| 266 | setTyping((current) => { | |
| 267 | const next = new Map([...current].filter(([, value]) => value.until > now)); | |
| 268 | return next.size === current.size ? current : next; | |
| 269 | }); | |
| 270 | }, 1000); | |
| 271 | return () => clearInterval(timer); | |
| 272 | }, [typing.size]); | |
| 273 | ||
| 274 | // Says "is typing" to the others, at most every few seconds. | |
| 275 | const lastTyping = useRef(0); | |
| 276 | const onTyping = useCallback(() => { | |
| 277 | const now = Date.now(); | |
| 278 | if (now - lastTyping.current < 3000) return; | |
| 279 | lastTyping.current = now; | |
| 280 | live.send({ type: "typing", channel_id: data.channel.id }); | |
| 281 | }, [live, data.channel.id]); | |
| 282 | ||
| 283 | const post = useCallback( | |
| 284 | async (body: string, root: string | null) => { | |
| 285 | if (!me) return; | |
| 286 | const clientId = newClientId(); | |
| 287 | const author: MemberProfile = { kind: "user", id: me.id, name: me.username, display_name: me.username, avatar: me.avatar, role: null }; | |
| 288 | const pending: ShownMessage = { | |
| 289 | id: clientId, | |
| 290 | client_id: clientId, | |
| 291 | pending: true, | |
| 292 | channel_id: data.channel.id, | |
| 293 | author, | |
| 294 | kind: "text", | |
| 295 | body, | |
| 296 | card: null, | |
| 297 | thread_root: root, | |
| 298 | reply_count: 0, | |
| 299 | last_reply_at: null, | |
| 300 | created_at: new Date().toISOString(), | |
| 301 | edited_at: null, | |
| 302 | deleted_at: null, | |
| 303 | }; | |
| 304 | const put = (list: ShownMessage[]) => mergeMessages(list, [pending]); | |
| 305 | if (root) setThread((now) => put(now ?? [])); | |
| 306 | else { | |
| 307 | stick.current = true; | |
| 308 | setMessages(put); | |
| 309 | } | |
| 310 | const saved = await send<ChatMessage>({ intent: "post", channel_id: data.channel.id, body, thread_root: root }); | |
| 311 | const settle = (list: ShownMessage[]) => | |
| 312 | saved.ok | |
| 313 | ? mergeMessages(list, [{ ...saved.value, client_id: clientId }]) | |
| 314 | : list.map((m) => (m.client_id === clientId ? { ...m, failed: true, pending: true } : m)); | |
| 315 | if (root) { | |
| 316 | setThread((now) => (now ? settle(now) : now)); | |
| 317 | if (saved.ok) countReply(root, saved.value.id, saved.value.created_at); | |
| 318 | } else setMessages(settle); | |
| 319 | }, | |
| 320 | [me, send, data.channel.id, countReply], | |
| 321 | ); | |
| 322 | const retry = (message: ShownMessage) => { | |
| 323 | const drop = (list: ShownMessage[]) => list.filter((m) => m.client_id !== message.client_id); | |
| 324 | if (message.thread_root) setThread((now) => (now ? drop(now) : now)); | |
| 325 | else setMessages(drop); | |
| 326 | void post(message.body, message.thread_root); | |
| 327 | }; | |
| 328 | ||
| 329 | const loadOlder = async () => { | |
| 330 | if (!older || loadingOlder) return; | |
| 331 | setLoadingOlder(true); | |
| 332 | const element = scroller.current; | |
| 333 | const fromBottom = element ? element.scrollHeight - element.scrollTop : 0; | |
| 334 | try { | |
| 335 | const response = await fetch(`/${slug}/-/chat/api?channel=${encodeURIComponent(data.channel.id)}&before=${encodeURIComponent(older)}`); | |
| 336 | const result = (await response.json()) as Result<{ messages: ChatMessage[]; older: string | null }>; | |
| 337 | if (result.ok) { | |
| 338 | stick.current = false; | |
| 339 | setMessages((now) => mergeMessages(now, result.value.messages)); | |
| 340 | setOlder(result.value.older); | |
| 341 | requestAnimationFrame(() => { | |
| 342 | if (element) element.scrollTop = element.scrollHeight - fromBottom; | |
| 343 | }); | |
| 344 | } | |
| 345 | } finally { | |
| 346 | setLoadingOlder(false); | |
| 347 | } | |
| 348 | }; | |
| 349 | ||
| 350 | const openThread = (id: string | null) => { | |
| 351 | const next = new URLSearchParams(params); | |
| 352 | if (id) next.set("thread", id); | |
| 353 | else next.delete("thread"); | |
| 354 | setParams(next, { preventScrollReset: true, replace: !!threadId && !!id }); | |
| 355 | }; | |
| 356 | ||
| 357 | const toggleStar = async () => { | |
| 358 | await send({ intent: "preferences", channel_id: data.channel.id, starred: !starred }); | |
| 359 | refresh(); | |
| 360 | }; | |
| 361 | ||
| 362 | const join = async () => { | |
| 363 | const done = await send({ intent: "join", channel_id: data.channel.id }); | |
| 364 | if (done.ok) { | |
| 365 | setJoined(true); | |
| 366 | if (me) { | |
| 367 | setMembers((now) => [ | |
| 368 | ...now, | |
| 369 | { | |
| 370 | channel_id: data.channel.id, | |
| 371 | member: { kind: "user", id: me.id, name: me.username, display_name: me.username, avatar: me.avatar, role: null }, | |
| 372 | role: "member", | |
| 373 | starred: false, | |
| 374 | muted: false, | |
| 375 | last_read_id: null, | |
| 376 | joined_at: new Date().toISOString(), | |
| 377 | }, | |
| 378 | ]); | |
| 379 | } | |
| 380 | } | |
| 381 | }; | |
| 382 | ||
| 383 | const rows = useMemo(() => timeline(messages, new Date(), zone), [messages, zone]); | |
| 384 | const typers = [...typing.values()].map((t) => t.member); | |
| 385 | const name = data.channel.name ?? ""; | |
| 386 | const placeholder = isDm | |
| 387 | ? `Message ${others.map(shownName).join(", ") || "yourself"}` | |
| 388 | : `Message #${name}. @ a teammate or an agent`; | |
| 389 | const rootMessage = threadId ? messages.find((m) => m.id === threadId) ?? null : null; | |
| 390 | ||
| 391 | return ( | |
| 392 | <div className="flex h-[calc(100dvh-3.5rem)] min-h-0 lg:h-dvh"> | |
| 393 | <section aria-label={data.title} className="flex min-w-0 grow flex-col"> | |
| 394 | <ChannelHeader | |
| 395 | data={data} | |
| 396 | others={others} | |
| 397 | starred={starred} | |
| 398 | muted={muted} | |
| 399 | memberCount={members.length} | |
| 400 | live={live.state} | |
| 401 | info={info} | |
| 402 | onStar={() => void toggleStar()} | |
| 403 | onInfo={toggleInfo} | |
| 404 | /> | |
| 405 | <div | |
| 406 | ref={scroller} | |
| 407 | onScroll={(event) => { | |
| 408 | const element = event.currentTarget; | |
| 409 | stick.current = element.scrollHeight - element.scrollTop - element.clientHeight < 120; | |
| 410 | }} | |
| 411 | className="min-h-0 grow overflow-y-auto overscroll-contain [scrollbar-gutter:stable] [scrollbar-width:thin]" | |
| 412 | > | |
| 413 | <div className="mx-auto flex min-h-full max-w-[56rem] flex-col justify-end px-2 pt-6 pb-3 sm:px-4"> | |
| 414 | {older ? ( | |
| 415 | <div className="flex justify-center pb-4"> | |
| 416 | <button | |
| 417 | type="button" | |
| 418 | onClick={() => void loadOlder()} | |
| 419 | className="rounded-full border border-line px-3 py-1 text-xs text-muted transition-colors hover:border-line-strong hover:text-fg" | |
| 420 | > | |
| 421 | {loadingOlder ? "Loading…" : "Load earlier messages"} | |
| 422 | </button> | |
| 423 | </div> | |
| 424 | ) : ( | |
| 425 | <ConversationStart data={data} others={others} /> | |
| 426 | )} | |
| 427 | {rows.map((row) => | |
| 428 | row.kind === "day" ? ( | |
| 429 | <DayRule key={row.key} label={row.label} /> | |
| 430 | ) : ( | |
| 431 | <MessageRow | |
| 432 | key={row.key} | |
| 433 | message={row.message} | |
| 434 | head={row.head} | |
| 435 | zone={zone} | |
| 436 | context={context} | |
| 437 | code={code} | |
| 438 | slug={slug} | |
| 439 | onThread={() => openThread(row.message.id)} | |
| 440 | onRetry={() => retry(row.message)} | |
| 441 | threadOpen={row.message.id === threadId} | |
| 442 | /> | |
| 443 | ), | |
| 444 | )} | |
| 445 | </div> | |
| 446 | </div> | |
| 447 | <div className="shrink-0 px-2 pb-3 sm:px-4 sm:pb-4"> | |
| 448 | <div className="mx-auto max-w-[56rem]"> | |
| 449 | <TypingLine members={typers} /> | |
| 450 | {joined ? ( | |
| 451 | <Composer draftKey={data.channel.id} placeholder={placeholder} people={people} onSend={(body) => void post(body, null)} onTyping={onTyping} autoFocus /> | |
| 452 | ) : ( | |
| 453 | <div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-line bg-surface px-4 py-3"> | |
| 454 | <p className="text-sm text-muted"> | |
| 455 | You're reading <span className="font-medium text-fg">#{name}</span>. Join to post and get its notifications. | |
| 456 | </p> | |
| 457 | <button | |
| 458 | type="button" | |
| 459 | onClick={() => void join()} | |
| 460 | className="inline-flex h-9 items-center rounded-md bg-accent px-3.5 text-sm font-medium text-bg hover:bg-accent-hover" | |
| 461 | > | |
| 462 | Join channel | |
| 463 | </button> | |
| 464 | </div> | |
| 465 | )} | |
| 466 | </div> | |
| 467 | </div> | |
| 468 | </section> | |
| 469 | {threadId ? ( | |
| 470 | <SidePanel title="Thread" subtitle={isDm ? undefined : `#${name}`} onClose={() => openThread(null)}> | |
| 471 | <ThreadPanel | |
| 472 | root={rootMessage} | |
| 473 | replies={thread} | |
| 474 | zone={zone} | |
| 475 | context={context} | |
| 476 | code={code} | |
| 477 | slug={slug} | |
| 478 | people={people} | |
| 479 | joined={joined} | |
| 480 | onSend={(body) => void post(body, threadId)} | |
| 481 | onRetry={retry} | |
| 482 | draftKey={`${data.channel.id}:${threadId}`} | |
| 483 | /> | |
| 484 | </SidePanel> | |
| 485 | ) : ( | |
| 486 | info && ( | |
| 487 | <SidePanel title={isDm ? "Details" : "About this channel"} onClose={toggleInfo}> | |
| 488 | <InfoPanel data={data} members={members} slug={slug} people={people} onAdded={(m) => setMembers((now) => [...now, m])} joined={joined} /> | |
| 489 | </SidePanel> | |
| 490 | ) | |
| 491 | )} | |
| 492 | </div> | |
| 493 | ); | |
| 494 | } | |
| 495 | ||
| 496 | /** The top of a conversation: what it is, and its controls. */ | |
| 497 | function ChannelHeader({ | |
| 498 | data, | |
| 499 | others, | |
| 500 | starred, | |
| 501 | muted, | |
| 502 | memberCount, | |
| 503 | live, | |
| 504 | info, | |
| 505 | onStar, | |
| 506 | onInfo, | |
| 507 | }: { | |
| 508 | data: Loaded; | |
| 509 | others: MemberProfile[]; | |
| 510 | starred: boolean; | |
| 511 | muted: boolean; | |
| 512 | memberCount: number; | |
| 513 | live: LiveState; | |
| 514 | info: boolean; | |
| 515 | onStar: () => void; | |
| 516 | onInfo: () => void; | |
| 517 | }) { | |
| 518 | const channel = data.channel; | |
| 519 | const agentDm = channel.kind === "dm" && others.length === 1 && others[0]!.kind === "agent" ? others[0]! : null; | |
| 520 | return ( | |
| 521 | <header className="flex h-14 shrink-0 items-center gap-3 border-b border-line px-4 sm:px-5"> | |
| 522 | <div className="flex min-w-0 grow items-center gap-2.5"> | |
| 523 | {channel.kind === "channel" ? ( | |
| 524 | <> | |
| 525 | <h1 className="flex min-w-0 shrink-0 items-center gap-1 text-[0.9375rem] font-semibold"> | |
| 526 | {channel.private ? <Lock size={15} className="text-faint" /> : <Hash size={16} className="text-faint" />} | |
| 527 | <span className="truncate">{channel.name}</span> | |
| 528 | </h1> | |
| 529 | <StarButton starred={starred} onClick={onStar} /> | |
| 530 | {muted && ( | |
| 531 | <Hint label="Muted: no unread count or notifications"> | |
| 532 | <span className="text-faint"> | |
| 533 | <BellOff size={14} aria-label="Muted" /> | |
| 534 | </span> | |
| 535 | </Hint> | |
| 536 | )} | |
| 537 | {channel.topic && ( | |
| 538 | <> | |
| 539 | <span aria-hidden="true" className="hidden h-4 w-px shrink-0 bg-line sm:block" /> | |
| 540 | <p className="hidden min-w-0 truncate text-sm text-muted sm:block">{channel.topic}</p> | |
| 541 | </> | |
| 542 | )} | |
| 543 | </> | |
| 544 | ) : ( | |
| 545 | <> | |
| 546 | <span className="flex shrink-0 -space-x-1.5"> | |
| 547 | {others.slice(0, 3).map((member) => ( | |
| 548 | <span key={`${member.kind}:${member.id}`} className="rounded-full ring-2 ring-bg"> | |
| 549 | <MemberAvatar member={member} size={26} /> | |
| 550 | </span> | |
| 551 | ))} | |
| 552 | </span> | |
| 553 | <div className="min-w-0"> | |
| 554 | <h1 className="flex min-w-0 items-center gap-1.5 text-[0.9375rem] leading-tight font-semibold"> | |
| 555 | <span className="truncate">{others.map(shownName).join(", ") || "Just you"}</span> | |
| 556 | {agentDm && <AgentPill />} | |
| 557 | </h1> | |
| 558 | {agentDm?.role && <p className="truncate text-xs leading-tight text-muted">{agentDm.role}</p>} | |
| 559 | </div> | |
| 560 | <StarButton starred={starred} onClick={onStar} /> | |
| 561 | </> | |
| 562 | )} | |
| 563 | </div> | |
| 564 | {live !== "open" && ( | |
| 565 | <span role="status" className="hidden items-center gap-1.5 text-xs text-faint sm:flex"> | |
| 566 | <span className="size-1.5 animate-pulse rounded-full bg-warn" /> | |
| 567 | {live === "reconnecting" ? "Reconnecting…" : "Connecting…"} | |
| 568 | </span> | |
| 569 | )} | |
| 570 | {channel.kind === "channel" && ( | |
| 571 | <Hint label={info ? "Hide details" : "Members and details"}> | |
| 572 | <button | |
| 573 | type="button" | |
| 574 | onClick={onInfo} | |
| 575 | aria-pressed={info} | |
| 576 | aria-label="Members and details" | |
| 577 | className={`flex h-8 items-center gap-1.5 rounded-md border px-2 text-xs tabular-nums transition-colors ${ | |
| 578 | info ? "border-line-strong bg-raised text-fg" : "border-line text-muted hover:border-line-strong hover:text-fg" | |
| 579 | }`} | |
| 580 | > | |
| 581 | <UsersStack count={memberCount} /> | |
| 582 | </button> | |
| 583 | </Hint> | |
| 584 | )} | |
| 585 | {channel.kind === "dm" && ( | |
| 586 | <Hint label={info ? "Hide details" : "Details"}> | |
| 587 | <button | |
| 588 | type="button" | |
| 589 | onClick={onInfo} | |
| 590 | aria-pressed={info} | |
| 591 | aria-label="Details" | |
| 592 | 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"}`} | |
| 593 | > | |
| 594 | <PanelRight size={16} /> | |
| 595 | </button> | |
| 596 | </Hint> | |
| 597 | )} | |
| 598 | </header> | |
| 599 | ); | |
| 600 | } | |
| 601 | ||
| 602 | function UsersStack({ count }: { count: number }) { | |
| 603 | return ( | |
| 604 | <> | |
| 605 | <Users size={14} /> | |
| 606 | {count} | |
| 607 | </> | |
| 608 | ); | |
| 609 | } | |
| 610 | ||
| 611 | function StarButton({ starred, onClick }: { starred: boolean; onClick: () => void }) { | |
| 612 | return ( | |
| 613 | <Hint label={starred ? "Unstar" : "Star: keep it at the top of the sidebar"}> | |
| 614 | <button | |
| 615 | type="button" | |
| 616 | aria-label={starred ? "Unstar" : "Star"} | |
| 617 | aria-pressed={starred} | |
| 618 | onClick={onClick} | |
| 619 | 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"}`} | |
| 620 | > | |
| 621 | <Star size={15} fill={starred ? "currentColor" : "none"} /> | |
| 622 | </button> | |
| 623 | </Hint> | |
| 624 | ); | |
| 625 | } | |
| 626 | ||
| 627 | /** How a conversation begins, above its first message. */ | |
| 628 | function ConversationStart({ data, others }: { data: Loaded; others: MemberProfile[] }) { | |
| 629 | const channel = data.channel; | |
| 630 | if (channel.kind === "dm") { | |
| 631 | const agent = others.length === 1 && others[0]!.kind === "agent" ? others[0]! : null; | |
| 632 | return ( | |
| 633 | <div className="px-2 pb-6 sm:px-3"> | |
| 634 | <div className="flex -space-x-2"> | |
| 635 | {others.slice(0, 4).map((m) => ( | |
| 636 | <span key={m.id} className="rounded-full ring-4 ring-bg"> | |
| 637 | <MemberAvatar member={m} size={56} /> | |
| 638 | </span> | |
| 639 | ))} | |
| 640 | </div> | |
| 641 | <h2 className="mt-3 flex items-center gap-2 text-xl font-semibold tracking-tight"> | |
| 642 | {others.map(shownName).join(", ") || "Just you"} | |
| 643 | {agent && <AgentPill />} | |
| 644 | </h2> | |
| 645 | <p className="mt-1 max-w-xl text-sm text-muted"> | |
| 646 | {agent | |
| 647 | ? `${agent.role ? `${agent.role}. ` : ""}Ask a question or give it a job: it answers here, and opens a task when the work needs one.` | |
| 648 | : "This is the start of your conversation."} | |
| 649 | </p> | |
| 650 | </div> | |
| 651 | ); | |
| 652 | } | |
| 653 | return ( | |
| 654 | <div className="px-2 pb-6 sm:px-3"> | |
| 655 | <span className="flex size-12 items-center justify-center rounded-xl bg-raised text-muted"> | |
| 656 | {channel.private ? <Lock size={22} /> : <Hash size={24} />} | |
| 657 | </span> | |
| 658 | <h2 className="mt-3 text-xl font-semibold tracking-tight">Welcome to #{channel.name}</h2> | |
| 659 | <p className="mt-1 max-w-xl text-sm text-muted">{channel.topic ?? "This is the very beginning of the channel."}</p> | |
| 660 | </div> | |
| 661 | ); | |
| 662 | } | |
| 663 | ||
| 664 | function DayRule({ label }: { label: string }) { | |
| 665 | return ( | |
| 666 | <div role="separator" aria-label={label} className="relative my-3 flex items-center justify-center"> | |
| 667 | <span aria-hidden="true" className="absolute inset-x-2 top-1/2 h-px bg-line" /> | |
| 668 | <span className="relative rounded-full border border-line bg-bg px-3 py-0.5 text-[0.6875rem] font-medium text-muted" suppressHydrationWarning> | |
| 669 | {label} | |
| 670 | </span> | |
| 671 | </div> | |
| 672 | ); | |
| 673 | } | |
| 674 | ||
| 675 | const CARD_ICONS: Record<string, ReactNode> = { | |
| 676 | pull: <GitPullRequest size={16} />, | |
| 677 | issue: <CircleDot size={16} />, | |
| 678 | task: <ListChecks size={16} />, | |
| 679 | deploy: <Rocket size={16} />, | |
| 680 | approval: <ShieldCheck size={16} />, | |
| 681 | }; | |
| 682 | ||
| 683 | /** How loud a card's state reads, from what it says. */ | |
| 684 | function stateTone(state: string): BadgeTone { | |
| 685 | const s = state.toLowerCase(); | |
| 686 | if (/merged/.test(s)) return "merged"; | |
| 687 | if (/fail|error|blocked|over budget|rejected/.test(s)) return "danger"; | |
| 688 | if (/approval|waiting|review|queued|needs/.test(s)) return "warn"; | |
| 689 | if (/pass|done|deployed|ready|approved|live|success/.test(s)) return "success"; | |
| 690 | if (/working|running|in progress|open/.test(s)) return "info"; | |
| 691 | return "neutral"; | |
| 692 | } | |
| 693 | ||
| 694 | /** A card g1t or an agent posted: what it is about, a line of detail, and its state. */ | |
| 695 | export function CardBox({ card, href }: { card: MessageCard; href: string | null }) { | |
| 696 | const inner = ( | |
| 697 | <> | |
| 698 | <span className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg bg-raised text-muted group-hover:text-fg"> | |
| 699 | {CARD_ICONS[card.kind] ?? <Sparkles size={16} />} | |
| 700 | </span> | |
| 701 | <span className="min-w-0 grow"> | |
| 702 | <span className="line-clamp-2 text-sm font-medium text-fg">{card.title}</span> | |
| 703 | {card.detail && <span className="mt-0.5 block truncate font-mono text-xs text-muted">{card.detail}</span>} | |
| 704 | </span> | |
| 705 | {card.state && ( | |
| 706 | <Badge tone={stateTone(card.state)} className="mt-0.5"> | |
| 707 | {card.state} | |
| 708 | </Badge> | |
| 709 | )} | |
| 710 | </> | |
| 711 | ); | |
| 712 | 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"; | |
| 713 | return href ? ( | |
| 714 | <Link to={href} className={`${box} hover:border-line-strong hover:bg-raised/40`}> | |
| 715 | {inner} | |
| 716 | </Link> | |
| 717 | ) : ( | |
| 718 | <div className={box}>{inner}</div> | |
| 719 | ); | |
| 720 | } | |
| 721 | ||
| 722 | function cardHref(card: MessageCard, slug: string, code: boolean): string | null { | |
| 723 | if (!card.href) return null; | |
| 724 | if (code || !/^\/[^/]+\/(?!-\/)[^/]+/.test(card.href)) return card.href; | |
| 725 | return codeAccessPath(slug, card.href); | |
| 726 | } | |
| 727 | ||
| 728 | /** One message: with its author's name and avatar when it starts a group. */ | |
| 729 | function MessageRow({ | |
| 730 | message, | |
| 731 | head, | |
| 732 | zone, | |
| 733 | context, | |
| 734 | code, | |
| 735 | slug, | |
| 736 | onThread, | |
| 737 | onRetry, | |
| 738 | threadOpen, | |
| 739 | inThread, | |
| 740 | }: { | |
| 741 | message: ShownMessage; | |
| 742 | head: boolean; | |
| 743 | zone: string | undefined; | |
| 744 | context: TextContext; | |
| 745 | code: boolean; | |
| 746 | slug: string; | |
| 747 | onThread?: () => void; | |
| 748 | onRetry: () => void; | |
| 749 | threadOpen?: boolean; | |
| 750 | inThread?: boolean; | |
| 751 | }) { | |
| 752 | const author = message.author; | |
| 753 | const time = clock(message.created_at, zone); | |
| 754 | const profile = author.kind === "agent" ? `/${slug}/-/agents/${author.name}` : `/u/${author.name}`; | |
| 755 | return ( | |
| 756 | <article | |
| 757 | aria-label={`${shownName(author)}, ${time}`} | |
| 758 | className={`group/message relative flex gap-3 rounded-lg px-2 transition-colors hover:bg-surface/70 sm:px-3 ${head ? "mt-3 pt-1 pb-1" : "py-0.5"} ${ | |
| 759 | threadOpen ? "bg-accent/[0.06] hover:bg-accent/[0.08]" : "" | |
| 760 | }`} | |
| 761 | > | |
| 762 | <div className="w-9 shrink-0"> | |
| 763 | {head ? ( | |
| 764 | <Link to={profile} tabIndex={-1} aria-hidden="true" className="mt-0.5 block"> | |
| 765 | <MemberAvatar member={author} size={36} /> | |
| 766 | </Link> | |
| 767 | ) : ( | |
| 768 | <time | |
| 769 | dateTime={message.created_at} | |
| 770 | suppressHydrationWarning | |
| 771 | className="mt-1 block text-right text-[0.625rem] leading-5 text-faint tabular-nums opacity-0 group-hover/message:opacity-100" | |
| 772 | > | |
| 773 | {time.replace(/\s?[AP]M$/, "")} | |
| 774 | </time> | |
| 775 | )} | |
| 776 | </div> | |
| 777 | <div className="min-w-0 grow"> | |
| 778 | {head && ( | |
| 779 | <div className="flex items-baseline gap-2"> | |
| 780 | <Link to={profile} className="truncate text-[0.9375rem] font-semibold text-fg hover:underline"> | |
| 781 | {shownName(author)} | |
| 782 | </Link> | |
| 783 | {author.kind === "agent" && <AgentPill className="self-center" />} | |
| 784 | <time dateTime={message.created_at} suppressHydrationWarning className="shrink-0 text-xs text-faint tabular-nums"> | |
| 785 | {time} | |
| 786 | </time> | |
| 787 | </div> | |
| 788 | )} | |
| 789 | <div className={message.pending && !message.failed ? "opacity-60" : undefined}> | |
| 790 | {message.body && <MessageText body={message.body} context={context} />} | |
| 791 | {message.card && <CardBox card={message.card} href={cardHref(message.card, slug, code)} />} | |
| 792 | {message.edited_at && <span className="text-[0.6875rem] text-faint"> (edited)</span>} | |
| 793 | </div> | |
| 794 | {message.failed && ( | |
| 795 | <p className="mt-0.5 text-xs text-danger"> | |
| 796 | Not sent.{" "} | |
| 797 | <button type="button" onClick={onRetry} className="font-medium underline underline-offset-2 hover:text-fg"> | |
| 798 | Try again | |
| 799 | </button> | |
| 800 | </p> | |
| 801 | )} | |
| 802 | {!inThread && message.reply_count > 0 && onThread && ( | |
| 803 | <button | |
| 804 | type="button" | |
| 805 | onClick={onThread} | |
| 806 | 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" | |
| 807 | > | |
| 808 | <MessageSquareText size={14} /> | |
| 809 | {message.reply_count} {message.reply_count === 1 ? "reply" : "replies"} | |
| 810 | {message.last_reply_at && ( | |
| 811 | <span className="font-normal text-faint" suppressHydrationWarning> | |
| 812 | Last reply {clock(message.last_reply_at, zone)} | |
| 813 | </span> | |
| 814 | )} | |
| 815 | </button> | |
| 816 | )} | |
| 817 | </div> | |
| 818 | {!inThread && onThread && !message.pending && ( | |
| 819 | <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"> | |
| 820 | <Hint label="Reply in thread"> | |
| 821 | <button | |
| 822 | type="button" | |
| 823 | aria-label="Reply in thread" | |
| 824 | onClick={onThread} | |
| 825 | className="flex size-7 items-center justify-center rounded-md text-muted hover:bg-raised hover:text-fg" | |
| 826 | > | |
| 827 | <MessageSquareText size={15} /> | |
| 828 | </button> | |
| 829 | </Hint> | |
| 830 | </div> | |
| 831 | )} | |
| 832 | </article> | |
| 833 | ); | |
| 834 | } | |
| 835 | ||
| 836 | /** "reviewer is typing…", for whoever is. */ | |
| 837 | function TypingLine({ members }: { members: MemberProfile[] }) { | |
| 838 | const names = members.map(shownName); | |
| 839 | const who = names.length === 1 ? names[0] : names.length === 2 ? `${names[0]} and ${names[1]}` : names.length > 2 ? "Several people" : ""; | |
| 840 | return ( | |
| 841 | <div aria-live="polite" className="flex h-6 items-center gap-1.5 px-1 text-xs text-muted"> | |
| 842 | {who && ( | |
| 843 | <> | |
| 844 | <span aria-hidden="true" className="flex gap-0.5"> | |
| 845 | {[0, 1, 2].map((i) => ( | |
| 846 | <span key={i} className="size-1 animate-bounce rounded-full bg-accent" style={{ animationDelay: `${i * 140}ms` }} /> | |
| 847 | ))} | |
| 848 | </span> | |
| 849 | <span> | |
| 850 | <span className="font-medium text-fg-soft">{who}</span> {names.length === 1 ? "is" : "are"} typing… | |
| 851 | </span> | |
| 852 | </> | |
| 853 | )} | |
| 854 | </div> | |
| 855 | ); | |
| 856 | } | |
| 857 | ||
| 858 | /** A panel beside the conversation: a thread, or the details. Over everything on a phone. */ | |
| 859 | function SidePanel({ title, subtitle, onClose, children }: { title: string; subtitle?: string; onClose: () => void; children: ReactNode }) { | |
| 860 | return ( | |
| 861 | <aside | |
| 862 | aria-label={title} | |
| 863 | 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]" | |
| 864 | > | |
| 865 | <div className="flex h-14 shrink-0 items-center gap-2 border-b border-line px-4"> | |
| 866 | <button type="button" onClick={onClose} aria-label="Back" className="-ml-1 rounded-md p-1 text-muted hover:bg-raised hover:text-fg lg:hidden"> | |
| 867 | <ArrowLeft size={16} /> | |
| 868 | </button> | |
| 869 | <h2 className="text-[0.9375rem] font-semibold">{title}</h2> | |
| 870 | {subtitle && <span className="truncate text-sm text-faint">{subtitle}</span>} | |
| 871 | <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"> | |
| 872 | <X size={16} /> | |
| 873 | </button> | |
| 874 | </div> | |
| 875 | <div className="flex min-h-0 grow flex-col">{children}</div> | |
| 876 | </aside> | |
| 877 | ); | |
| 878 | } | |
| 879 | ||
| 880 | function ThreadPanel({ | |
| 881 | root, | |
| 882 | replies, | |
| 883 | zone, | |
| 884 | context, | |
| 885 | code, | |
| 886 | slug, | |
| 887 | people, | |
| 888 | joined, | |
| 889 | onSend, | |
| 890 | onRetry, | |
| 891 | draftKey, | |
| 892 | }: { | |
| 893 | root: ShownMessage | null; | |
| 894 | replies: ShownMessage[] | null; | |
| 895 | zone: string | undefined; | |
| 896 | context: TextContext; | |
| 897 | code: boolean; | |
| 898 | slug: string; | |
| 899 | people: Mentionable[]; | |
| 900 | joined: boolean; | |
| 901 | onSend: (body: string) => void; | |
| 902 | onRetry: (message: ShownMessage) => void; | |
| 903 | draftKey: string; | |
| 904 | }) { | |
| 905 | const rows = useMemo(() => timeline(replies ?? [], new Date(), zone).filter((row) => row.kind === "message"), [replies, zone]); | |
| 906 | return ( | |
| 907 | <> | |
| 908 | <div className="min-h-0 grow overflow-y-auto px-1 py-3 [scrollbar-width:thin]"> | |
| 909 | {root ? ( | |
| 910 | <MessageRow message={root} head zone={zone} context={context} code={code} slug={slug} onRetry={() => onRetry(root)} inThread /> | |
| 911 | ) : ( | |
| 912 | <p className="px-4 py-2 text-sm text-faint">The message this thread is under is further back.</p> | |
| 913 | )} | |
| 914 | <div className="my-2 flex items-center gap-3 px-4 text-xs text-faint"> | |
| 915 | {replies == null ? "Loading replies…" : `${replies.filter((r) => !r.deleted_at).length} ${replies.length === 1 ? "reply" : "replies"}`} | |
| 916 | <span className="h-px grow bg-line" /> | |
| 917 | </div> | |
| 918 | {rows.map((row) => | |
| 919 | row.kind === "message" ? ( | |
| 920 | <MessageRow | |
| 921 | key={row.key} | |
| 922 | message={row.message} | |
| 923 | head={row.head} | |
| 924 | zone={zone} | |
| 925 | context={context} | |
| 926 | code={code} | |
| 927 | slug={slug} | |
| 928 | onRetry={() => onRetry(row.message)} | |
| 929 | inThread | |
| 930 | /> | |
| 931 | ) : null, | |
| 932 | )} | |
| 933 | </div> | |
| 934 | {joined && ( | |
| 935 | <div className="shrink-0 p-3"> | |
| 936 | <Composer draftKey={draftKey} placeholder="Reply…" people={people} onSend={onSend} compact autoFocus /> | |
| 937 | </div> | |
| 938 | )} | |
| 939 | </> | |
| 940 | ); | |
| 941 | } | |
| 942 | ||
| 943 | function InfoPanel({ | |
| 944 | data, | |
| 945 | members, | |
| 946 | slug, | |
| 947 | people, | |
| 948 | onAdded, | |
| 949 | joined, | |
| 950 | }: { | |
| 951 | data: Loaded; | |
| 952 | members: ChannelMember[]; | |
| 953 | slug: string; | |
| 954 | people: Mentionable[]; | |
| 955 | onAdded: (member: ChannelMember) => void; | |
| 956 | joined: boolean; | |
| 957 | }) { | |
| 958 | const channel = data.channel; | |
| 959 | const agents = members.filter((m) => m.member.kind === "agent"); | |
| 960 | const humans = members.filter((m) => m.member.kind === "user"); | |
| 961 | return ( | |
| 962 | <div className="min-h-0 grow overflow-y-auto p-4 [scrollbar-width:thin]"> | |
| 963 | {channel.kind === "channel" && ( | |
| 964 | <section className="rounded-xl border border-line bg-surface p-3.5"> | |
| 965 | <h3 className="text-xs font-medium text-faint">Topic</h3> | |
| 966 | <p className="mt-1 text-sm text-fg-soft">{channel.topic || "No topic yet."}</p> | |
| 967 | <h3 className="mt-3 text-xs font-medium text-faint">Visibility</h3> | |
| 968 | <p className="mt-1 flex items-center gap-1.5 text-sm text-fg-soft"> | |
| 969 | {channel.private ? <Lock size={13} /> : <Hash size={13} />} | |
| 970 | {channel.private ? "Private: invited members only" : "Public to the workspace"} | |
| 971 | </p> | |
| 972 | </section> | |
| 973 | )} | |
| 974 | {agents.length > 0 && ( | |
| 975 | <section className="mt-5"> | |
| 976 | <h3 className="mb-2 px-1 text-xs font-medium text-faint">Agents · {agents.length}</h3> | |
| 977 | <div className="flex flex-wrap gap-1.5"> | |
| 978 | {agents.map(({ member }) => ( | |
| 979 | <Hint key={member.id} label={member.role ?? undefined}> | |
| 980 | <Link | |
| 981 | to={`/${slug}/-/agents/${member.name}`} | |
| 982 | 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" | |
| 983 | > | |
| 984 | <MemberAvatar member={member} size={20} /> | |
| 985 | {shownName(member)} | |
| 986 | </Link> | |
| 987 | </Hint> | |
| 988 | ))} | |
| 989 | </div> | |
| 990 | </section> | |
| 991 | )} | |
| 992 | <section className="mt-5"> | |
| 993 | <div className="mb-1 flex items-center justify-between px-1"> | |
| 994 | <h3 className="text-xs font-medium text-faint">People · {humans.length}</h3> | |
| 995 | {joined && channel.kind === "channel" && <AddMember slug={slug} channelId={channel.id} people={people} members={members} onAdded={onAdded} />} | |
| 996 | </div> | |
| 997 | <ul className="space-y-px"> | |
| 998 | {humans.map(({ member, role }) => ( | |
| 999 | <li key={member.id}> | |
| 1000 | <Link to={`/u/${member.name}`} className="flex items-center gap-2.5 rounded-md px-1 py-1.5 transition-colors hover:bg-raised/60"> | |
| 1001 | <MemberAvatar member={member} size={26} /> | |
| 1002 | <span className="min-w-0 grow truncate text-sm"> | |
| 1003 | {shownName(member)} | |
| 1004 | {member.display_name !== member.name && <span className="ml-1.5 text-faint">@{member.name}</span>} | |
| 1005 | </span> | |
| 1006 | {role === "owner" && <span className="text-[0.6875rem] text-faint">Owner</span>} | |
| 1007 | </Link> | |
| 1008 | </li> | |
| 1009 | ))} | |
| 1010 | </ul> | |
| 1011 | </section> | |
| 1012 | </div> | |
| 1013 | ); | |
| 1014 | } | |
| 1015 | ||
| 1016 | /** Adding a person or an agent to the channel. An invite lets them read; it never grants write. */ | |
| 1017 | function AddMember({ | |
| 1018 | slug, | |
| 1019 | channelId, | |
| 1020 | people, | |
| 1021 | members, | |
| 1022 | onAdded, | |
| 1023 | }: { | |
| 1024 | slug: string; | |
| 1025 | channelId: string; | |
| 1026 | people: Mentionable[]; | |
| 1027 | members: ChannelMember[]; | |
| 1028 | onAdded: (member: ChannelMember) => void; | |
| 1029 | }) { | |
| 1030 | const [open, setOpen] = useState(false); | |
| 1031 | const [query, setQuery] = useState(""); | |
| 1032 | const [error, setError] = useState<string | null>(null); | |
| 1033 | const send = useChatSend(slug); | |
| 1034 | const chatData = useChatData(); | |
| 1035 | const inside = new Set(members.map((m) => `${m.member.kind}:${m.member.name.toLowerCase()}`)); | |
| 1036 | const q = query.trim().toLowerCase().replace(/^@/, ""); | |
| 1037 | const options = people | |
| 1038 | .filter((p) => !inside.has(`${p.kind}:${p.name.toLowerCase()}`)) | |
| 1039 | .filter((p) => !q || p.name.toLowerCase().includes(q) || p.display_name.toLowerCase().includes(q)) | |
| 1040 | .slice(0, 8); | |
| 1041 | const add = async (person: Mentionable) => { | |
| 1042 | const agent = person.kind === "agent" ? chatData?.agents.find((a) => a.handle === person.name) : null; | |
| 1043 | const key = agent ? `agent:${agent.id}` : `user:${person.name}`; | |
| 1044 | setError(null); | |
| 1045 | const done = await send({ intent: "invite", channel_id: channelId, member: key }); | |
| 1046 | if (!done.ok) return setError(done.error.message); | |
| 1047 | onAdded({ | |
| 1048 | channel_id: channelId, | |
| 1049 | member: { kind: person.kind, id: agent?.id ?? person.name, name: person.name, display_name: person.display_name, avatar: person.avatar, role: person.role ?? null }, | |
| 1050 | role: "member", | |
| 1051 | starred: false, | |
| 1052 | muted: false, | |
| 1053 | last_read_id: null, | |
| 1054 | joined_at: new Date().toISOString(), | |
| 1055 | }); | |
| 1056 | setOpen(false); | |
| 1057 | }; | |
| 1058 | return ( | |
| 1059 | <Popover open={open} onOpenChange={setOpen}> | |
| 1060 | <PopoverTrigger asChild> | |
| 1061 | <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"> | |
| 1062 | <UserPlus size={13} /> | |
| 1063 | Add | |
| 1064 | </button> | |
| 1065 | </PopoverTrigger> | |
| 1066 | <PopoverContent align="end" className="w-72 p-1.5"> | |
| 1067 | <input | |
| 1068 | autoFocus | |
| 1069 | value={query} | |
| 1070 | onChange={(event) => setQuery(event.target.value)} | |
| 1071 | placeholder="Add a person or an agent" | |
| 1072 | aria-label="Add a person or an agent" | |
| 1073 | autoComplete="off" | |
| 1074 | data-1p-ignore | |
| 1075 | 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" | |
| 1076 | /> | |
| 1077 | {options.length === 0 && <p className="px-2 py-3 text-xs text-faint">Everyone who matches is already here.</p>} | |
| 1078 | {options.map((person) => ( | |
| 1079 | <button | |
| 1080 | key={`${person.kind}:${person.name}`} | |
| 1081 | type="button" | |
| 1082 | onClick={() => void add(person)} | |
| 1083 | className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm hover:bg-line" | |
| 1084 | > | |
| 1085 | <MemberAvatar member={person} size={20} /> | |
| 1086 | <span className="min-w-0 grow truncate">{person.display_name}</span> | |
| 1087 | {person.kind === "agent" && <AgentPill />} | |
| 1088 | </button> | |
| 1089 | ))} | |
| 1090 | {error && <p className="px-2 pt-1 text-xs text-danger">{error}</p>} | |
| 1091 | <p className="border-t border-line px-2 pt-1.5 pb-0.5 text-[0.6875rem] leading-snug text-faint"> | |
| 1092 | Adding an agent lets it read this channel. It never grants it write access. | |
| 1093 | </p> | |
| 1094 | </PopoverContent> | |
| 1095 | </Popover> | |
| 1096 | ); | |
| 1097 | } |