Skip to content
1,097 linesCodeBlameRaw

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 to1import {
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";
19import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
20import { Link, useRouteLoaderData, useSearchParams } from "react-router";
21
22import { type ChannelMember, type ChatLiveEvent, type ChatMessage, type MemberProfile, type MessageCard, type Result, hasCodeAccess } from "@g1t/contracts";
23
24import { useChatData, useChatSend, useChatSidebar } from "./actions";
25import { Composer } from "./composer";
26import { type LiveState, useChatLive } from "./live";
27import { AgentPill, MemberAvatar } from "./marks";
28import { MessageText, type TextContext } from "./text";
29import { Badge, type BadgeTone } from "../ui/badge";
30import { Hint } from "../ui/hint";
31import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
32import { type Mentionable, type ShownMessage, mergeMessages, shownName, timeline } from "../../lib/chat";
33import { codeAccessPath } from "../../lib/workspace-nav";
34import type { ChannelData } from "../../routes/workspace/chat/channel";
35
36type Loaded = Extract<ChannelData, { unavailable: false }>;
37
38/** True once the page has hydrated: times and days are the reader's own from then on. */
39function useHydrated(): boolean {
40 const [hydrated, setHydrated] = useState(false);
41 useEffect(() => setHydrated(true), []);
42 return hydrated;
43}
44
45function 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"). */
50export 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`. */
56function 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
62function 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. */
67export 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. */
497function 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
602function UsersStack({ count }: { count: number }) {
603 return (
604 <>
605 <Users size={14} />
606 {count}
607 </>
608 );
609}
610
611function 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. */
628function 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
664function 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
675const 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. */
684function 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. */
695export 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
722function 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. */
729function 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. */
837function 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. */
859function 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
880function 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
943function 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. */
1017function 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}