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

This file's history is long; its oldest lines are credited to the oldest commit read.