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

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