Skip to content
1,814 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";
Artifacts is a mode in the rail, the shell and the phone's tab bar in place of Docs: its home lists everything you can open by day or as cards under All, Yours and Shared with you, with search, filters and tiles to start a doc (slides, designs and dashboards say they are coming soon), and its sidebar holds favorites, spaces, Private, Shared, projects' docs and the trash.48import { FolioUnfurls } from "../folios/unfurl";
Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread49import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../ui/dropdown-menu";
50import { threadLink, writeUpAgents } from "../../lib/write-up";
Chat controls, public profiles, shadcn selects, and no Docs tab in a project51import {
52 AlertDialog,
53 AlertDialogAction,
54 AlertDialogCancel,
55 AlertDialogContent,
56 AlertDialogDescription,
57 AlertDialogFooter,
58 AlertDialogHeader,
59 AlertDialogTitle,
60} from "../ui/alert-dialog";
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar61import { Badge } from "../ui/badge";
Chat and workspace agents: channels, DMs and named agents you talk to62import { Hint } from "../ui/hint";
63import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar64import { type Mentionable, type ShownMessage, channelPath, mentionNames, mergeMessages, shownHandle, shownName, timeline } from "../../lib/chat";
Chat and workspace agents: channels, DMs and named agents you talk to65import { 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)66import { conversationCache } from "./conversation-cache";
67// Reactions and the workspace's own emoji (components/emoji).
68import { EmojiProvider, useCustomEmoji } from "../emoji/context";
69import { AddReaction, QuickReactions, ReactionBar } from "../emoji/reaction-bar";
70import { useAddresses } from "../../lib/addresses";
71import { applyReactionEvent, keepMine, toggleReaction } from "../../lib/emoji";
Chat and workspace agents: channels, DMs and named agents you talk to72import type { ChannelData } from "../../routes/workspace/chat/channel";
73
74type Loaded = Extract<ChannelData, { unavailable: false }>;
75
76/** True once the page has hydrated: times and days are the reader's own from then on. */
77function useHydrated(): boolean {
78 const [hydrated, setHydrated] = useState(false);
79 useEffect(() => setHydrated(true), []);
80 return hydrated;
81}
82
83function clock(at: string, zone: string | undefined): string {
84 return new Date(at).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", timeZone: zone });
85}
86
87/** Whether the viewer uses Code in this workspace (docs/WORKSPACE.md, "Members without Code"). */
88export function useCodeAccess(slug: string): boolean {
89 const root = useRouteLoaderData("root") as { user?: { workspaces?: { slug: string; code_access?: boolean }[] } | null } | undefined;
90 return hasCodeAccess(root?.user?.workspaces?.find((m) => m.slug === slug) ?? null);
91}
92
93/** The project a channel of the same name is about, for a bare `#123`. */
94function useProjectNamed(name: string | null): string | null {
95 const root = useRouteLoaderData("root") as { shell?: { repos?: { name: string }[] } } | undefined;
96 if (!name) return null;
97 return root?.shell?.repos?.some((repo) => repo.name === name) ? name : null;
98}
99
100function newClientId(): string {
101 return `tmp-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
102}
103
104/** A conversation: its header, its messages, the box to write in, and its thread and details beside it. */
105export function ChannelView({ data }: { data: Loaded }) {
106 const chatData = useChatData();
107 const slug = chatData?.slug ?? "";
108 const me = chatData?.me ?? null;
109 const send = useChatSend(slug);
110 const { sidebar, refresh } = useChatSidebar();
111 const hydrated = useHydrated();
112 const zone = hydrated ? undefined : "UTC";
113 const code = useCodeAccess(slug);
114 const project = useProjectNamed(data.channel.kind === "channel" ? data.channel.name : null);
115 const [params, setParams] = useSearchParams();
116 const threadId = params.get("thread");
117
118 const [messages, setMessages] = useState<ShownMessage[]>(() => mergeMessages([], data.messages));
119 const [older, setOlder] = useState<string | null>(data.older);
120 const [loadingOlder, setLoadingOlder] = useState(false);
121 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 thread122 // The message the open thread is under, as its page says, for when it is
123 // further back than the conversation has loaded (a long-running session's card).
124 const [threadRoot, setThreadRoot] = useState<ShownMessage | null>(null);
Chat and workspace agents: channels, DMs and named agents you talk to125 const [typing, setTyping] = useState<Map<string, { member: MemberProfile; until: number }>>(new Map());
126 const [joined, setJoined] = useState(data.joined);
127 const [members, setMembers] = useState<ChannelMember[]>(data.members);
Chat controls, public profiles, shadcn selects, and no Docs tab in a project128 // The channel as it is now: renamed, its topic changed, archived, live.
129 const [channel, setChannel] = useState<Channel>(data.channel);
130 const [canManage, setCanManage] = useState(data.can_manage === true);
131 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)132 // The server's answer behind a cached draw: merged in by id.
133 useEffect(() => {
134 const onFresh = (event: Event) => {
135 const { key, value } = (event as CustomEvent<{ key: string; value: ChannelData }>).detail;
136 if (key !== window.location.pathname || value.unavailable || value.channel.id !== data.channel.id) return;
137 setMessages((now) => mergeMessages(now, value.messages));
138 setMembers(value.members);
139 setJoined(value.joined);
Chat controls, public profiles, shadcn selects, and no Docs tab in a project140 setChannel(value.channel);
141 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)142 };
143 window.addEventListener("g1t:chat-fresh", onFresh);
144 return () => window.removeEventListener("g1t:chat-fresh", onFresh);
145 }, [data.channel.id]);
146 // The cache follows what is on screen, so coming back shows the latest.
147 useEffect(() => {
148 const path = window.location.pathname;
149 const timer = setTimeout(
Chat controls, public profiles, shadcn selects, and no Docs tab in a project150 () => 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)151 400,
152 );
153 return () => clearTimeout(timer);
Chat controls, public profiles, shadcn selects, and no Docs tab in a project154 }, [view, messages, members, joined, canManage]);
155 // Renamed while open, here or by someone else: the address follows the name.
156 const navigate = useNavigate();
157 const nameShown = useRef(data.channel.name);
158 useEffect(() => {
159 if (channel.kind !== "channel" || channel.name === nameShown.current || !slug) return;
160 nameShown.current = channel.name;
161 navigate(`${channelPath(slug, channel)}${window.location.search}`, { replace: true, preventScrollReset: true });
162 }, [channel, slug, navigate]);
Chat and workspace agents: channels, DMs and named agents you talk to163 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)164 // A phone: back to the list, by the chevron or a swipe from the edge;
165 // and a long-pressed message's actions.
166 const back = useBack(`/${chatData?.slug ?? ""}/-/chat`);
167 const swipe = useSwipeBack(back);
168 const [actions, setActions] = useState<ShownMessage | null>(null);
169 // Someone's profile, opened from their card, beside the conversation.
170 const [profile, setProfile] = useState<string | null>(null);
171 const [editing, setEditing] = useState<{ id: string; body: string } | null>(null);
Chat and workspace agents: channels, DMs and named agents you talk to172 const scroller = useRef<HTMLDivElement>(null);
173 const stick = useRef(true);
174
175 const entry = sidebar?.entries.find((e) => e.channel.id === data.channel.id);
176 const starred = entry?.starred ?? members.find((m) => m.member.kind === "user" && m.member.id === me?.id)?.starred ?? false;
177 const muted = entry?.muted ?? false;
178 const others = members.filter((m) => !(m.member.kind === "user" && m.member.id === me?.id)).map((m) => m.member);
179 const isDm = data.channel.kind === "dm";
180
181 // The details panel: open by default on a wide screen, as last left.
182 useEffect(() => {
183 try {
184 const saved = localStorage.getItem("chat-info");
185 setInfo(saved ? saved === "1" : window.matchMedia("(min-width: 1280px)").matches && !isDm);
186 } catch {
187 setInfo(false);
188 }
189 }, [isDm]);
190 const toggleInfo = () =>
191 setInfo((now) => {
192 try {
193 localStorage.setItem("chat-info", now ? "0" : "1");
194 } catch {
195 // Not kept: fine.
196 }
197 return !now;
198 });
199
200 const people = useMemo<Mentionable[]>(
201 () => [
202 ...(chatData?.agents ?? []).map((agent) => ({ kind: "agent" as const, name: agent.handle, display_name: agent.display_name, avatar: agent.avatar, role: agent.role })),
203 ...(chatData?.people ?? []).filter((p) => p.name !== me?.username),
204 ],
205 [chatData, me?.username],
206 );
207 const context = useMemo<TextContext>(
208 () => ({
209 slug,
210 me: me?.username ?? null,
211 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 calendar212 // `@ana` reads as the name people know: everyone in the workspace, the viewer too.
213 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 to214 channels: new Set((sidebar?.entries ?? []).filter((e) => e.channel.kind === "channel").map((e) => e.channel.name ?? "")),
215 project,
216 codeLink: code ? undefined : (href: string) => (/^\/[^/]+\/(?!-\/)[^/]+/.test(href) ? codeAccessPath(slug, href) : href),
217 }),
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar218 [slug, me, people, chatData?.agents, sidebar?.entries, project, code],
Chat and workspace agents: channels, DMs and named agents you talk to219 );
220
221 // Keeps the newest message in view while the reader is at the bottom.
222 const toBottom = useCallback((smooth = false) => {
223 const element = scroller.current;
224 if (element) element.scrollTo({ top: element.scrollHeight, behavior: smooth ? "smooth" : "auto" });
225 }, []);
226 useEffect(() => toBottom(), [toBottom]);
227 useEffect(() => {
228 if (stick.current) toBottom(true);
229 }, [messages.length, typing.size, toBottom]);
230
231 // Read up to the newest message whenever it is in view.
232 const lastRead = useRef<string | null>(null);
233 const markRead = useCallback(() => {
234 if (!joined || document.visibilityState !== "visible") return;
235 const newest = [...messages].reverse().find((m) => !m.pending);
236 if (!newest || newest.id === lastRead.current) return;
237 lastRead.current = newest.id;
238 void send({ intent: "read", channel_id: data.channel.id, id: newest.id });
239 window.dispatchEvent(new CustomEvent("g1t:chat-read", { detail: data.channel.id }));
240 }, [messages, joined, send, data.channel.id]);
241 useEffect(() => {
242 const timer = setTimeout(markRead, 400);
243 document.addEventListener("visibilitychange", markRead);
244 window.addEventListener("focus", markRead);
245 return () => {
246 clearTimeout(timer);
247 document.removeEventListener("visibilitychange", markRead);
248 window.removeEventListener("focus", markRead);
249 };
250 }, [markRead]);
251
252 // The thread open beside the conversation, from `?thread=`.
253 const loadThread = useCallback(async () => {
254 if (!threadId) return setThread(null);
255 try {
256 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 thread257 const result = (await response.json()) as Result<{ messages: ChatMessage[]; root?: ChatMessage | null }>;
Chat and workspace agents: channels, DMs and named agents you talk to258 // The page reaches back to the message the thread is under: shown above it, not among the replies.
259 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 thread260 const root = result.ok ? (result.value.root ?? result.value.messages.find((m) => m.id === threadId) ?? null) : null;
261 setThreadRoot(root);
Chat and workspace agents: channels, DMs and named agents you talk to262 } catch {
263 setThread([]);
264 }
265 }, [threadId, slug, data.channel.id]);
266 useEffect(() => {
267 setThread(null);
Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread268 setThreadRoot(null);
Chat and workspace agents: channels, DMs and named agents you talk to269 void loadThread();
270 }, [loadThread]);
271
272 // Reply counts: counted here only until the service's own count for that
273 // message arrives (a `message.updated` of it), which can come before or
274 // after the reply itself; never twice for one reply.
275 const serverCounted = useRef(new Set<string>());
276 const countedReplies = useRef(new Set<string>());
277 const countReply = useCallback((root: string, reply: string, at: string) => {
278 if (serverCounted.current.has(root) || countedReplies.current.has(reply)) return;
279 countedReplies.current.add(reply);
280 setMessages((now) => now.map((m) => (m.id === root ? { ...m, reply_count: m.reply_count + 1, last_reply_at: at } : m)));
281 }, []);
282 const latest = useRef(messages);
283 latest.current = messages;
284
285 // Everything that happens while the page is open.
286 const onEvent = useCallback(
287 (event: ChatLiveEvent) => {
288 if (event.type === "message.created" || event.type === "message.updated") {
289 const message = event.message;
290 if (message.channel_id !== data.channel.id) return;
291 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)292 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 to293 if (event.type === "message.created") countReply(message.thread_root, message.id, message.created_at);
294 } else {
295 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)296 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 thread297 // The open thread's root, a session's card say, changes in place at its top too.
298 if (message.id === threadId) setThreadRoot((now) => (now ? keepMine([now], message) : now));
Chat and workspace agents: channels, DMs and named agents you talk to299 }
300 // Whoever just spoke is no longer typing.
301 setTyping((now) => {
302 const key = `${message.author.kind}:${message.author.id}`;
303 if (!now.has(key)) return now;
304 const next = new Map(now);
305 next.delete(key);
306 return next;
307 });
308 } else if (event.type === "message.deleted") {
309 if (event.channel_id !== data.channel.id) return;
310 const gone = (m: ShownMessage) => (m.id === event.id ? { ...m, deleted_at: new Date().toISOString() } : m);
311 setMessages((now) => now.map(gone));
312 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 thread313 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)314 } else if (event.type === "reaction.added" || event.type === "reaction.removed") {
315 if (event.channel_id !== data.channel.id) return;
316 setMessages((now) => applyReactionEvent(now, event, me?.id ?? null));
317 setThread((now) => (now ? applyReactionEvent(now, event, me?.id ?? null) : now));
Chat and workspace agents: channels, DMs and named agents you talk to318 } else if (event.type === "typing") {
319 if (event.channel_id !== data.channel.id) return;
320 if (event.member.kind === "user" && event.member.id === me?.id) return;
321 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 project322 } else if (event.type === "channel.updated") {
323 // Renamed, a new topic, archived or back: everyone looking sees it at once.
324 if (event.channel.id !== data.channel.id) return;
325 setChannel(event.channel);
326 refresh();
Chat and workspace agents: channels, DMs and named agents you talk to327 }
328 },
Chat controls, public profiles, shadcn selects, and no Docs tab in a project329 [data.channel.id, threadId, me?.id, countReply, refresh],
Chat and workspace agents: channels, DMs and named agents you talk to330 );
331 // Back after a drop: what was missed.
332 const onReconnect = useCallback(async () => {
333 const base = `/${slug}/-/chat/api?channel=${encodeURIComponent(data.channel.id)}`;
334 try {
335 // Everything after the newest message held, page by page, oldest first;
336 // what was deleted meanwhile comes back empty and is dropped.
337 let after = [...latest.current].reverse().find((m) => !m.pending)?.id ?? null;
338 for (let pages = 0; after && pages < 20; pages++) {
339 const response = await fetch(`${base}&after=${encodeURIComponent(after)}`);
340 const result = (await response.json()) as Result<{ messages: ChatMessage[]; newer?: string | null }>;
341 if (!result.ok) break;
342 const gone = new Set(result.value.messages.filter((m) => m.deleted_at).map((m) => m.id));
343 const kept = result.value.messages.filter((m) => !m.deleted_at && !m.thread_root);
344 setMessages((now) => mergeMessages(now.filter((m) => !gone.has(m.id)), kept));
345 after = result.value.newer ?? null;
346 }
347 // Edits and deletions to what was already on screen: the latest page again.
348 const response = await fetch(base);
349 const result = (await response.json()) as Result<{ messages: ChatMessage[] }>;
350 if (result.ok) {
351 const gone = new Set(result.value.messages.filter((m) => m.deleted_at).map((m) => m.id));
352 setMessages((now) => mergeMessages(now.filter((m) => !gone.has(m.id)), result.value.messages.filter((m) => !m.deleted_at)));
353 }
354 } catch {
355 // The next event, or the next return, catches up.
356 }
357 void loadThread();
358 refresh();
359 }, [slug, data.channel.id, loadThread, refresh]);
360 const live = useChatLive(slug, data.channel.id, onEvent, onReconnect);
361
362 // Typing shows until its time runs out.
363 useEffect(() => {
364 if (typing.size === 0) return;
365 const timer = setInterval(() => {
366 const now = Date.now();
367 setTyping((current) => {
368 const next = new Map([...current].filter(([, value]) => value.until > now));
369 return next.size === current.size ? current : next;
370 });
371 }, 1000);
372 return () => clearInterval(timer);
373 }, [typing.size]);
374
375 // Says "is typing" to the others, at most every few seconds.
376 const lastTyping = useRef(0);
377 const onTyping = useCallback(() => {
378 const now = Date.now();
379 if (now - lastTyping.current < 3000) return;
380 lastTyping.current = now;
381 live.send({ type: "typing", channel_id: data.channel.id });
382 }, [live, data.channel.id]);
383
384 const post = useCallback(
385 async (body: string, root: string | null) => {
386 if (!me) return;
387 const clientId = newClientId();
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar388 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 to389 const pending: ShownMessage = {
390 id: clientId,
391 client_id: clientId,
392 pending: true,
393 channel_id: data.channel.id,
394 author,
395 kind: "text",
396 body,
397 card: null,
398 thread_root: root,
399 reply_count: 0,
400 last_reply_at: null,
401 created_at: new Date().toISOString(),
402 edited_at: null,
403 deleted_at: null,
404 };
405 const put = (list: ShownMessage[]) => mergeMessages(list, [pending]);
406 if (root) setThread((now) => put(now ?? []));
407 else {
408 stick.current = true;
409 setMessages(put);
410 }
411 const saved = await send<ChatMessage>({ intent: "post", channel_id: data.channel.id, body, thread_root: root });
412 const settle = (list: ShownMessage[]) =>
413 saved.ok
414 ? mergeMessages(list, [{ ...saved.value, client_id: clientId }])
415 : list.map((m) => (m.client_id === clientId ? { ...m, failed: true, pending: true } : m));
416 if (root) {
417 setThread((now) => (now ? settle(now) : now));
418 if (saved.ok) countReply(root, saved.value.id, saved.value.created_at);
419 } else setMessages(settle);
420 },
421 [me, send, data.channel.id, countReply],
422 );
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)423 // ── Reactions (components/emoji): shown at once, then as the service counts them.
424 const customs = useCustomEmoji(slug);
425 const { usercontent } = useAddresses();
426 const meProfile = useMemo<MemberProfile | null>(
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar427 () => (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)428 [me],
429 );
430 const react = useCallback(
431 async (messageId: string, emoji: string, on: boolean) => {
432 if (!meProfile) return;
433 const change = (to: boolean) => (list: ShownMessage[]) =>
434 list.map((m) => (m.id === messageId ? { ...m, reactions: toggleReaction(m.reactions ?? [], emoji, to, meProfile) } : m));
435 setMessages(change(on));
436 setThread((now) => (now ? change(on)(now) : now));
437 try {
438 const response = await fetch(`/${slug}/-/chat/api`, {
439 method: "POST",
440 headers: { "content-type": "application/json" },
441 body: JSON.stringify({ intent: on ? "react" : "unreact", channel_id: data.channel.id, message_id: messageId, emoji }),
442 });
443 const result = (await response.json()) as Result<ChatMessage["reactions"]>;
444 if (!result.ok) throw new Error(result.error.message);
445 const settle = (list: ShownMessage[]) => list.map((m) => (m.id === messageId ? { ...m, reactions: result.value } : m));
446 setMessages(settle);
447 setThread((now) => (now ? settle(now) : now));
448 } catch {
449 // Not saved: back as it was.
450 setMessages(change(!on));
451 setThread((now) => (now ? change(!on)(now) : now));
452 }
453 },
454 [meProfile, slug, data.channel.id],
455 );
456 const emojiContext = useMemo(
457 () => ({ customs, usercontent, me: meProfile, react: (id: string, emoji: string, on: boolean) => void react(id, emoji, on), manageHref: `/${slug}/-/emoji` }),
458 [customs, usercontent, meProfile, react, slug],
459 );
460
Chat and workspace agents: channels, DMs and named agents you talk to461 const retry = (message: ShownMessage) => {
462 const drop = (list: ShownMessage[]) => list.filter((m) => m.client_id !== message.client_id);
463 if (message.thread_root) setThread((now) => (now ? drop(now) : now));
464 else setMessages(drop);
465 void post(message.body, message.thread_root);
466 };
467
468 const loadOlder = async () => {
469 if (!older || loadingOlder) return;
470 setLoadingOlder(true);
471 const element = scroller.current;
472 const fromBottom = element ? element.scrollHeight - element.scrollTop : 0;
473 try {
474 const response = await fetch(`/${slug}/-/chat/api?channel=${encodeURIComponent(data.channel.id)}&before=${encodeURIComponent(older)}`);
475 const result = (await response.json()) as Result<{ messages: ChatMessage[]; older: string | null }>;
476 if (result.ok) {
477 stick.current = false;
478 setMessages((now) => mergeMessages(now, result.value.messages));
479 setOlder(result.value.older);
480 requestAnimationFrame(() => {
481 if (element) element.scrollTop = element.scrollHeight - fromBottom;
482 });
483 }
484 } finally {
485 setLoadingOlder(false);
486 }
487 };
488
489 const openThread = (id: string | null) => {
490 const next = new URLSearchParams(params);
491 if (id) next.set("thread", id);
492 else next.delete("thread");
493 setParams(next, { preventScrollReset: true, replace: !!threadId && !!id });
494 };
495
Artifacts is a mode in the rail, the shell and the phone's tab bar in place of Docs: its home lists everything you can open by day or as cards under All, Yours and Shared with you, with search, filters and tiles to start a doc (slides, designs and dashboards say they are coming soon), and its sidebar holds favorites, spaces, Private, Shared, projects' docs and the trash.496 // A thread's link, to copy and to cite; and "Write this up as an artifact",
497 // which asks an agent, in the thread and as the person, for a doc about it.
Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread498 const [writeUp, setWriteUp] = useState<string | null>(null);
499 const linkToThread = (root: string) => threadLink(window.location.origin, channelPath(slug, channel), root);
500 const copyThreadLink = (root: string) => {
501 const done = navigator.clipboard?.writeText(linkToThread(root));
502 if (!done) return showToast(false, "Couldn't copy the link here.");
503 done.then(
504 () => showToast(true, "Link to the thread copied."),
505 () => showToast(false, "Couldn't copy the link here."),
506 );
507 };
508 const writers = useMemo(() => writeUpAgents(members.map((m) => m.member)), [members]);
509 const askForWriteUp = async (body: string): Promise<string | null> => {
510 const root = writeUp;
511 if (!root) return null;
512 // The thread open already: posted as any reply is, shown at once.
513 if (root === threadId) {
514 void post(body, root);
515 return null;
516 }
517 const saved = await send<ChatMessage>({ intent: "post", channel_id: data.channel.id, body, thread_root: root });
518 if (!saved.ok) return saved.error.message;
519 openThread(root);
520 return null;
521 };
522
Chat controls, public profiles, shadcn selects, and no Docs tab in a project523 const updateChannel = useCallback(
524 async (change: ChannelChange): Promise<Result<unknown>> => {
525 const done = await send<{ channel: Channel }>({ intent: "update_channel", channel_id: data.channel.id, ...change });
526 if (done.ok) setChannel(done.value.channel);
527 return done;
528 },
529 [send, data.channel.id],
530 );
531
Chat and workspace agents: channels, DMs and named agents you talk to532 const toggleStar = async () => {
533 await send({ intent: "preferences", channel_id: data.channel.id, starred: !starred });
534 refresh();
535 };
536
537 const join = async () => {
538 const done = await send({ intent: "join", channel_id: data.channel.id });
539 if (done.ok) {
540 setJoined(true);
541 if (me) {
542 setMembers((now) => [
543 ...now,
544 {
545 channel_id: data.channel.id,
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar546 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 to547 role: "member",
548 starred: false,
549 muted: false,
550 last_read_id: null,
551 joined_at: new Date().toISOString(),
552 },
553 ]);
554 }
555 }
556 };
557
558 const rows = useMemo(() => timeline(messages, new Date(), zone), [messages, zone]);
559 const typers = [...typing.values()].map((t) => t.member);
Chat controls, public profiles, shadcn selects, and no Docs tab in a project560 const name = channel.name ?? "";
561 const archived = channel.kind === "channel" && !!channel.archived_at;
Chat and workspace agents: channels, DMs and named agents you talk to562 const placeholder = isDm
563 ? `Message ${others.map(shownName).join(", ") || "yourself"}`
564 : `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 thread565 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)566 const cardContext = useMemo(
567 () => ({
568 slug,
569 agents: chatData?.agents ?? [],
570 onViewProfile: (username: string) => {
571 if (threadId) openThread(null);
572 setProfile(username);
573 },
574 }),
575 // eslint-disable-next-line react-hooks/exhaustive-deps
576 [slug, chatData?.agents, threadId],
577 );
Chat and workspace agents: channels, DMs and named agents you talk to578
579 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)580 // On a phone the conversation is the whole screen, as tall as what the
581 // keyboard leaves (components/mobile.tsx), so the composer sits on it.
582 <EmojiProvider value={emojiContext}>
583 <CardContext.Provider value={cardContext}>
584 <div
585 {...swipe}
586 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"
587 >
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar588 {/* What pressing a card's action did (components/chat/card.tsx). */}
589 <CardToasts />
Chat and workspace agents: channels, DMs and named agents you talk to590 <section aria-label={data.title} className="flex min-w-0 grow flex-col">
591 <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)592 onBack={back}
Chat controls, public profiles, shadcn selects, and no Docs tab in a project593 data={view}
Chat and workspace agents: channels, DMs and named agents you talk to594 others={others}
595 starred={starred}
596 muted={muted}
597 memberCount={members.length}
598 live={live.state}
599 info={info}
600 onStar={() => void toggleStar()}
601 onInfo={toggleInfo}
602 />
603 <div
604 ref={scroller}
605 onScroll={(event) => {
606 const element = event.currentTarget;
607 stick.current = element.scrollHeight - element.scrollTop - element.clientHeight < 120;
608 }}
609 className="min-h-0 grow overflow-y-auto overscroll-contain [scrollbar-gutter:stable] [scrollbar-width:thin]"
610 >
611 <div className="mx-auto flex min-h-full max-w-[56rem] flex-col justify-end px-2 pt-6 pb-3 sm:px-4">
612 {older ? (
613 <div className="flex justify-center pb-4">
614 <button
615 type="button"
616 onClick={() => void loadOlder()}
617 className="rounded-full border border-line px-3 py-1 text-xs text-muted transition-colors hover:border-line-strong hover:text-fg"
618 >
619 {loadingOlder ? "Loading…" : "Load earlier messages"}
620 </button>
621 </div>
622 ) : (
Chat controls, public profiles, shadcn selects, and no Docs tab in a project623 <ConversationStart data={view} others={others} />
Chat and workspace agents: channels, DMs and named agents you talk to624 )}
625 {rows.map((row) =>
626 row.kind === "day" ? (
627 <DayRule key={row.key} label={row.label} />
628 ) : (
629 <MessageRow
630 key={row.key}
631 message={row.message}
632 head={row.head}
633 zone={zone}
634 context={context}
635 code={code}
636 slug={slug}
637 onThread={() => openThread(row.message.id)}
638 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)639 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 thread640 onCopyLink={() => copyThreadLink(row.message.id)}
641 onWriteUp={joined && !archived ? () => setWriteUp(row.message.id) : undefined}
Chat and workspace agents: channels, DMs and named agents you talk to642 threadOpen={row.message.id === threadId}
643 />
644 ),
645 )}
646 </div>
647 </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)648 <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 to649 <div className="mx-auto max-w-[56rem]">
650 <TypingLine members={typers} />
Chat controls, public profiles, shadcn selects, and no Docs tab in a project651 {archived ? (
652 <div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-line bg-surface px-4 py-3">
653 <p className="flex items-center gap-2 text-sm text-muted">
654 <Archive size={15} className="shrink-0 text-faint" />
655 <span>
656 <span className="font-medium text-fg">#{name}</span> is archived. Its history stays here to read; nobody can post in it.
657 </span>
658 </p>
659 {canManage && (
660 <button
661 type="button"
662 onClick={() => void updateChannel({ archived: false })}
663 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"
664 >
665 <ArchiveRestore size={15} />
666 Unarchive
667 </button>
668 )}
669 </div>
670 ) : joined ? (
Chat and workspace agents: channels, DMs and named agents you talk to671 <Composer draftKey={data.channel.id} placeholder={placeholder} people={people} onSend={(body) => void post(body, null)} onTyping={onTyping} autoFocus />
672 ) : (
673 <div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-line bg-surface px-4 py-3">
674 <p className="text-sm text-muted">
675 You're reading <span className="font-medium text-fg">#{name}</span>. Join to post and get its notifications.
676 </p>
677 <button
678 type="button"
679 onClick={() => void join()}
680 className="inline-flex h-9 items-center rounded-md bg-accent px-3.5 text-sm font-medium text-bg hover:bg-accent-hover"
681 >
682 Join channel
683 </button>
684 </div>
685 )}
686 </div>
687 </div>
688 </section>
689 {threadId ? (
Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread690 <SidePanel
691 title="Thread"
692 subtitle={isDm ? undefined : `#${name}`}
693 onClose={() => openThread(null)}
694 menu={<ThreadMenu onCopyLink={() => copyThreadLink(threadId)} onWriteUp={joined && !archived ? () => setWriteUp(threadId) : undefined} />}
695 >
Chat and workspace agents: channels, DMs and named agents you talk to696 <ThreadPanel
697 root={rootMessage}
698 replies={thread}
699 zone={zone}
700 context={context}
701 code={code}
702 slug={slug}
703 people={people}
Chat controls, public profiles, shadcn selects, and no Docs tab in a project704 joined={joined && !archived}
Chat and workspace agents: channels, DMs and named agents you talk to705 onSend={(body) => void post(body, threadId)}
706 onRetry={retry}
Chat controls, public profiles, shadcn selects, and no Docs tab in a project707 onTyping={onTyping}
Chat and workspace agents: channels, DMs and named agents you talk to708 draftKey={`${data.channel.id}:${threadId}`}
709 />
710 </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)711 ) : profile ? (
712 <SidePanel title="Profile" onClose={() => setProfile(null)}>
713 <ProfilePanel slug={slug} username={profile} messages={messages} zone={zone} context={context} />
714 </SidePanel>
Chat and workspace agents: channels, DMs and named agents you talk to715 ) : (
716 info && (
717 <SidePanel title={isDm ? "Details" : "About this channel"} onClose={toggleInfo}>
Chat controls, public profiles, shadcn selects, and no Docs tab in a project718 <InfoPanel
719 data={view}
720 members={members}
721 slug={slug}
722 people={people}
723 onAdded={(m) => setMembers((now) => [...now, m])}
724 joined={joined}
725 canManage={canManage}
726 onUpdate={updateChannel}
727 />
Chat and workspace agents: channels, DMs and named agents you talk to728 </SidePanel>
729 )
730 )}
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)731 <MessageActions
732 message={actions}
733 mine={actions != null && actions.author.kind === "user" && actions.author.id === me?.id}
734 onClose={() => setActions(null)}
735 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 thread736 onCopyLink={(message) => copyThreadLink(message.thread_root ?? message.id)}
737 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)738 onEdit={(message) => setEditing({ id: message.id, body: message.body })}
739 onDelete={async (message) => {
740 // Gone at once; back if the service refuses.
741 setMessages((now) => now.map((m) => (m.id === message.id ? { ...m, deleted_at: new Date().toISOString() } : m)));
742 const done = await send({ intent: "remove", channel_id: data.channel.id, id: message.id });
743 if (!done.ok) setMessages((now) => now.map((m) => (m.id === message.id ? { ...m, deleted_at: null } : m)));
744 }}
745 />
Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread746 <WriteUpDialog
747 open={writeUp != null}
748 onOpenChange={(open) => !open && setWriteUp(null)}
749 slug={slug}
750 agents={writers}
751 link={writeUp && typeof window !== "undefined" ? linkToThread(writeUp) : ""}
Artifacts is a mode in the rail, the shell and the phone's tab bar in place of Docs: its home lists everything you can open by day or as cards under All, Yours and Shared with you, with search, filters and tiles to start a doc (slides, designs and dashboards say they are coming soon), and its sidebar holds favorites, spaces, Private, Shared, projects' docs and the trash.752 shared={channel.kind === "dm" || channel.private}
Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread753 onSend={askForWriteUp}
754 />
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)755 <BottomSheet open={editing != null} onOpenChange={(open) => !open && setEditing(null)} title="Edit message">
756 {editing && (
757 <form
758 className="space-y-3 px-1 pt-1"
759 onSubmit={async (event) => {
760 event.preventDefault();
761 const body = editing.body.trim();
762 if (!body) return;
763 // Shown edited at once; put back if the service refuses.
764 const before = messages.find((m) => m.id === editing.id);
765 setMessages((now) => now.map((m) => (m.id === editing.id ? { ...m, body, edited_at: new Date().toISOString() } : m)));
766 setEditing(null);
767 const saved = await send<ChatMessage>({ intent: "edit", channel_id: data.channel.id, id: editing.id, body });
768 if (saved.ok) setMessages((now) => mergeMessages(now, [saved.value]));
769 else if (before) setMessages((now) => now.map((m) => (m.id === before.id ? before : m)));
770 }}
771 >
772 <h2 className="px-2 text-base font-semibold">Edit message</h2>
773 <textarea
774 autoFocus
775 value={editing.body}
776 onChange={(event) => setEditing({ ...editing, body: event.target.value })}
777 rows={4}
778 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"
779 />
780 <div className="flex gap-2">
781 <button type="button" onClick={() => setEditing(null)} className="h-11 grow rounded-xl border border-line text-[0.9375rem] font-medium active:bg-raised">
782 Cancel
783 </button>
784 <button type="submit" className="h-11 grow rounded-xl bg-accent text-[0.9375rem] font-medium text-bg active:bg-accent-hover">
785 Save
786 </button>
787 </div>
788 </form>
789 )}
790 </BottomSheet>
Chat and workspace agents: channels, DMs and named agents you talk to791 </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)792 </CardContext.Provider>
793 </EmojiProvider>
Chat and workspace agents: channels, DMs and named agents you talk to794 );
795}
796
797/** The top of a conversation: what it is, and its controls. */
798function 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)799 onBack,
Chat and workspace agents: channels, DMs and named agents you talk to800 data,
801 others,
802 starred,
803 muted,
804 memberCount,
805 live,
806 info,
807 onStar,
808 onInfo,
809}: {
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)810 /** A phone: back to the list of conversations. */
811 onBack: () => void;
Chat and workspace agents: channels, DMs and named agents you talk to812 data: Loaded;
813 others: MemberProfile[];
814 starred: boolean;
815 muted: boolean;
816 memberCount: number;
817 live: LiveState;
818 info: boolean;
819 onStar: () => void;
820 onInfo: () => void;
821}) {
822 const channel = data.channel;
823 const agentDm = channel.kind === "dm" && others.length === 1 && others[0]!.kind === "agent" ? others[0]! : null;
824 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)825 <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">
826 <button
827 type="button"
828 onClick={onBack}
829 aria-label="Back to conversations"
830 className="flex size-10 shrink-0 items-center justify-center rounded-full text-muted active:bg-raised md:hidden"
831 >
832 <ChevronLeft size={22} />
833 </button>
Chat and workspace agents: channels, DMs and named agents you talk to834 <div className="flex min-w-0 grow items-center gap-2.5">
835 {channel.kind === "channel" ? (
836 <>
837 <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 project838 {channel.private ? (
839 <Hint label="Private channel: only its members can find and read it">
840 <span className="flex">
841 <Lock size={15} className="text-faint" aria-label="Private channel" />
842 </span>
843 </Hint>
844 ) : (
845 <Hash size={16} className="text-faint" aria-label="Public channel" />
846 )}
Chat and workspace agents: channels, DMs and named agents you talk to847 <span className="truncate">{channel.name}</span>
848 </h1>
849 <StarButton starred={starred} onClick={onStar} />
850 {muted && (
851 <Hint label="Muted: no unread count or notifications">
852 <span className="text-faint">
853 <BellOff size={14} aria-label="Muted" />
854 </span>
855 </Hint>
856 )}
Chat controls, public profiles, shadcn selects, and no Docs tab in a project857 {channel.archived_at && (
858 <Badge tone="neutral" className="shrink-0">
859 <Archive size={11} />
860 Archived
861 </Badge>
862 )}
Chat and workspace agents: channels, DMs and named agents you talk to863 {channel.topic && (
864 <>
865 <span aria-hidden="true" className="hidden h-4 w-px shrink-0 bg-line sm:block" />
866 <p className="hidden min-w-0 truncate text-sm text-muted sm:block">{channel.topic}</p>
867 </>
868 )}
869 </>
870 ) : (
871 <>
872 <span className="flex shrink-0 -space-x-1.5">
873 {others.slice(0, 3).map((member) => (
874 <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 managers875 <MemberAvatar member={member} size={26} presence={others.length === 1} />
Chat and workspace agents: channels, DMs and named agents you talk to876 </span>
877 ))}
878 </span>
879 <div className="min-w-0">
880 <h1 className="flex min-w-0 items-center gap-1.5 text-[0.9375rem] leading-tight font-semibold">
881 <span className="truncate">{others.map(shownName).join(", ") || "Just you"}</span>
882 {agentDm && <AgentPill />}
883 </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)884 {agentDm && (titleOf(agentDm) ?? agentDm.role) && (
885 <p className="truncate text-xs leading-tight text-muted">{titleOf(agentDm) ?? agentDm.role}</p>
886 )}
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers887 {/* One person: their status under their name, as their card has it. */}
888 {!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 to889 </div>
890 <StarButton starred={starred} onClick={onStar} />
891 </>
892 )}
893 </div>
894 {live !== "open" && (
895 <span role="status" className="hidden items-center gap-1.5 text-xs text-faint sm:flex">
896 <span className="size-1.5 animate-pulse rounded-full bg-warn" />
897 {live === "reconnecting" ? "Reconnecting…" : "Connecting…"}
898 </span>
899 )}
900 {channel.kind === "channel" && (
901 <Hint label={info ? "Hide details" : "Members and details"}>
902 <button
903 type="button"
904 onClick={onInfo}
905 aria-pressed={info}
906 aria-label="Members and details"
907 className={`flex h-8 items-center gap-1.5 rounded-md border px-2 text-xs tabular-nums transition-colors ${
908 info ? "border-line-strong bg-raised text-fg" : "border-line text-muted hover:border-line-strong hover:text-fg"
909 }`}
910 >
911 <UsersStack count={memberCount} />
912 </button>
913 </Hint>
914 )}
915 {channel.kind === "dm" && (
916 <Hint label={info ? "Hide details" : "Details"}>
917 <button
918 type="button"
919 onClick={onInfo}
920 aria-pressed={info}
921 aria-label="Details"
922 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"}`}
923 >
924 <PanelRight size={16} />
925 </button>
926 </Hint>
927 )}
928 </header>
929 );
930}
931
932function UsersStack({ count }: { count: number }) {
933 return (
934 <>
935 <Users size={14} />
936 {count}
937 </>
938 );
939}
940
941function StarButton({ starred, onClick }: { starred: boolean; onClick: () => void }) {
942 return (
943 <Hint label={starred ? "Unstar" : "Star: keep it at the top of the sidebar"}>
944 <button
945 type="button"
946 aria-label={starred ? "Unstar" : "Star"}
947 aria-pressed={starred}
948 onClick={onClick}
949 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"}`}
950 >
951 <Star size={15} fill={starred ? "currentColor" : "none"} />
952 </button>
953 </Hint>
954 );
955}
956
957/** How a conversation begins, above its first message. */
958function ConversationStart({ data, others }: { data: Loaded; others: MemberProfile[] }) {
959 const channel = data.channel;
960 if (channel.kind === "dm") {
961 const agent = others.length === 1 && others[0]!.kind === "agent" ? others[0]! : null;
962 return (
963 <div className="px-2 pb-6 sm:px-3">
964 <div className="flex -space-x-2">
965 {others.slice(0, 4).map((m) => (
966 <span key={m.id} className="rounded-full ring-4 ring-bg">
967 <MemberAvatar member={m} size={56} />
968 </span>
969 ))}
970 </div>
971 <h2 className="mt-3 flex items-center gap-2 text-xl font-semibold tracking-tight">
972 {others.map(shownName).join(", ") || "Just you"}
973 {agent && <AgentPill />}
974 </h2>
975 <p className="mt-1 max-w-xl text-sm text-muted">
976 {agent
977 ? `${agent.role ? `${agent.role}. ` : ""}Ask a question or give it a job: it answers here, and opens a task when the work needs one.`
978 : "This is the start of your conversation."}
979 </p>
980 </div>
981 );
982 }
983 return (
984 <div className="px-2 pb-6 sm:px-3">
985 <span className="flex size-12 items-center justify-center rounded-xl bg-raised text-muted">
986 {channel.private ? <Lock size={22} /> : <Hash size={24} />}
987 </span>
988 <h2 className="mt-3 text-xl font-semibold tracking-tight">Welcome to #{channel.name}</h2>
989 <p className="mt-1 max-w-xl text-sm text-muted">{channel.topic ?? "This is the very beginning of the channel."}</p>
990 </div>
991 );
992}
993
994function DayRule({ label }: { label: string }) {
995 return (
996 <div role="separator" aria-label={label} className="relative my-3 flex items-center justify-center">
997 <span aria-hidden="true" className="absolute inset-x-2 top-1/2 h-px bg-line" />
998 <span className="relative rounded-full border border-line bg-bg px-3 py-0.5 text-[0.6875rem] font-medium text-muted" suppressHydrationWarning>
999 {label}
1000 </span>
1001 </div>
1002 );
1003}
1004
1005/** 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)1006/**
1007 * Someone's profile beside the conversation, opened from their card: who
1008 * they are, their teams and local time, and what they said here lately.
1009 * The conversation stays where it was.
1010 */
1011function ProfilePanel({
1012 slug,
1013 username,
1014 messages,
1015 zone,
1016 context,
1017}: {
1018 slug: string;
1019 username: string;
1020 messages: ShownMessage[];
1021 zone: string | undefined;
1022 context: TextContext;
1023}) {
1024 const [card, setCard] = useState<PersonCard | null | undefined>(undefined);
1025 useEffect(() => {
1026 let live = true;
1027 setCard(undefined);
1028 void personCard(slug, username).then((value) => {
1029 if (live) setCard(value);
1030 });
1031 return () => {
1032 live = false;
1033 };
1034 }, [slug, username]);
1035 const theirs = messages
1036 .filter((m) => m.author.kind === "user" && m.author.name === username && !m.deleted_at && m.body)
1037 .slice(-5)
1038 .reverse();
1039 const time = card ? localTime(card.timezone, Date.now()) : null;
1040 return (
1041 <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 managers1042 <WithPresence person={{ username }} size={88}>
1043 <Avatar name={username} image={card?.avatar ?? null} size={88} />
1044 </WithPresence>
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar1045 <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)1046 <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 calendar1047 @{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)1048 {card?.pronouns ? <span className="font-sans"> · {card.pronouns}</span> : null}
1049 </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 managers1050 <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)1051 {card?.bio && <p className="mt-3 text-sm leading-relaxed text-fg-soft">{card.bio}</p>}
1052 <dl className="mt-4 space-y-2 text-sm">
1053 {card && card.teams.length > 0 && (
1054 <div className="flex gap-3">
1055 <dt className="w-20 shrink-0 text-faint">Teams</dt>
1056 <dd className="text-fg-soft">{card.teams.map((team) => team.name).join(", ")}</dd>
1057 </div>
1058 )}
1059 {time && (
1060 <div className="flex gap-3">
1061 <dt className="w-20 shrink-0 text-faint">Local time</dt>
1062 <dd className="text-fg-soft" suppressHydrationWarning>
1063 {time}
1064 </dd>
1065 </div>
1066 )}
1067 {card?.location && (
1068 <div className="flex gap-3">
1069 <dt className="w-20 shrink-0 text-faint">Location</dt>
1070 <dd className="text-fg-soft">{card.location}</dd>
1071 </div>
1072 )}
1073 {card && card.workspaces.length > 0 && (
1074 <div className="flex gap-3">
1075 <dt className="w-20 shrink-0 text-faint">Workspaces</dt>
1076 <dd className="text-fg-soft">{card.workspaces.map((w) => w.name).join(", ")}</dd>
1077 </div>
1078 )}
1079 </dl>
1080 <h4 className="mt-6 mb-2 text-xs font-medium text-faint">Lately in this conversation</h4>
1081 {theirs.length === 0 ? (
1082 <p className="text-sm text-muted">Nothing here yet.</p>
1083 ) : (
1084 <ul className="space-y-3">
1085 {theirs.map((m) => (
1086 <li key={m.id} className="rounded-lg border border-line bg-surface px-3 py-2.5">
1087 <p className="mb-1 text-[0.6875rem] text-faint" suppressHydrationWarning>
1088 {clock(m.created_at, zone)}
1089 </p>
1090 <div className="line-clamp-4 text-sm">
1091 <MessageText body={m.body} context={context} />
1092 </div>
1093 </li>
1094 ))}
1095 </ul>
1096 )}
1097 <Link to={`/u/${username}`} className="mt-6 inline-block text-sm text-accent hover:underline">
1098 Open their full profile
1099 </Link>
1100 </div>
1101 );
1102}
1103
1104/** An agent's title ("QA Engineer"), when the chat service sends it with the member. */
1105function titleOf(member: MemberProfile): string | null {
1106 return (member as MemberProfile & { title?: string | null }).title ?? null;
1107}
1108
1109/** A long press (half a second, without moving) on a touch screen. */
1110function useLongPress(onLongPress?: () => void) {
1111 const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
1112 const cancel = () => {
1113 if (timer.current) clearTimeout(timer.current);
1114 timer.current = null;
1115 };
1116 if (!onLongPress) return {};
1117 return {
1118 onTouchStart: () => {
1119 cancel();
1120 timer.current = setTimeout(() => {
1121 timer.current = null;
1122 if (typeof navigator !== "undefined" && "vibrate" in navigator) navigator.vibrate?.(8);
1123 onLongPress();
1124 }, 480);
1125 },
1126 onTouchMove: cancel,
1127 onTouchEnd: cancel,
1128 onTouchCancel: cancel,
1129 onContextMenu: (event: React.MouseEvent) => {
1130 // A phone's own long-press menu (copy, look up) would cover ours.
1131 if (window.matchMedia("(pointer: coarse)").matches) event.preventDefault();
1132 },
1133 };
1134}
1135
1136/** A long-pressed message's actions, in a sheet from the bottom: reply, copy, and for your own, edit and delete. */
1137function MessageActions({
1138 message,
1139 mine,
1140 onClose,
1141 onThread,
Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread1142 onCopyLink,
1143 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)1144 onEdit,
1145 onDelete,
1146}: {
1147 message: ShownMessage | null;
1148 mine: boolean;
1149 onClose: () => void;
1150 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 thread1151 onCopyLink: (message: ShownMessage) => void;
1152 /** Absent where the person cannot post (not joined, archived). */
1153 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)1154 onEdit: (message: ShownMessage) => void;
1155 onDelete: (message: ShownMessage) => void;
1156}) {
1157 const [confirm, setConfirm] = useState(false);
1158 useEffect(() => setConfirm(false), [message]);
1159 const act = (run: () => void) => () => {
1160 run();
1161 onClose();
1162 };
1163 return (
1164 <BottomSheet open={message != null} onOpenChange={(open) => !open && onClose()} title="Message actions">
1165 {message && (
1166 <>
1167 <p className="mt-1 mb-2 line-clamp-2 px-3 text-sm text-muted">
1168 <span className="font-medium text-fg-soft">{shownName(message.author)}:</span> {message.body || message.card?.title}
1169 </p>
1170 {!message.pending && <QuickReactions messageId={message.id} onDone={onClose} />}
1171 {!message.thread_root && (
1172 <SheetRow icon={<MessageSquareText />} onClick={act(() => onThread(message.id))}>
1173 Reply in thread
1174 </SheetRow>
1175 )}
1176 <SheetRow icon={<Copy />} onClick={act(() => void navigator.clipboard?.writeText(message.body || message.card?.title || ""))}>
1177 Copy text
1178 </SheetRow>
Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread1179 {!message.pending && (
1180 <SheetRow icon={<Link2 />} onClick={act(() => onCopyLink(message))}>
1181 Copy link to thread
1182 </SheetRow>
1183 )}
1184 {!message.pending && onWriteUp && (
1185 <SheetRow icon={<FileText />} onClick={act(() => onWriteUp(message))}>
Artifacts is a mode in the rail, the shell and the phone's tab bar in place of Docs: its home lists everything you can open by day or as cards under All, Yours and Shared with you, with search, filters and tiles to start a doc (slides, designs and dashboards say they are coming soon), and its sidebar holds favorites, spaces, Private, Shared, projects' docs and the trash.1186 Write this up as an artifact
Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread1187 </SheetRow>
1188 )}
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)1189 {mine && message.kind === "text" && (
1190 <SheetRow icon={<Pencil />} onClick={act(() => onEdit(message))}>
1191 Edit
1192 </SheetRow>
1193 )}
1194 {mine &&
1195 (confirm ? (
1196 <SheetRow icon={<Trash2 className="text-danger" />} onClick={act(() => onDelete(message))}>
1197 <span className="text-danger">Delete for everyone</span>
1198 </SheetRow>
1199 ) : (
1200 <SheetRow icon={<Trash2 />} onClick={() => setConfirm(true)}>
1201 Delete
1202 </SheetRow>
1203 ))}
1204 </>
1205 )}
1206 </BottomSheet>
1207 );
1208}
1209
Chat and workspace agents: channels, DMs and named agents you talk to1210function MessageRow({
1211 message,
1212 head,
1213 zone,
1214 context,
1215 code,
1216 slug,
1217 onThread,
1218 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)1219 onLongPress,
Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread1220 onCopyLink,
1221 onWriteUp,
Chat and workspace agents: channels, DMs and named agents you talk to1222 threadOpen,
1223 inThread,
1224}: {
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)1225 /** A phone: a long press opens the message's actions. */
1226 onLongPress?: () => void;
Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread1227 /** The hover toolbar's "⋯": copy the link to its thread, write it up in Docs. */
1228 onCopyLink?: () => void;
1229 onWriteUp?: () => void;
Chat and workspace agents: channels, DMs and named agents you talk to1230 message: ShownMessage;
1231 head: boolean;
1232 zone: string | undefined;
1233 context: TextContext;
1234 code: boolean;
1235 slug: string;
1236 onThread?: () => void;
1237 onRetry: () => void;
1238 threadOpen?: boolean;
1239 inThread?: boolean;
1240}) {
1241 const author = message.author;
1242 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)1243 const press = useLongPress(onLongPress);
Chat and workspace agents: channels, DMs and named agents you talk to1244 return (
1245 <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)1246 {...press}
Chat and workspace agents: channels, DMs and named agents you talk to1247 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)1248 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 to1249 threadOpen ? "bg-accent/[0.06] hover:bg-accent/[0.08]" : ""
1250 }`}
1251 >
1252 <div className="w-9 shrink-0">
1253 {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)1254 <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 to1255 <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)1256 </MemberCard>
Chat and workspace agents: channels, DMs and named agents you talk to1257 ) : (
1258 <time
1259 dateTime={message.created_at}
1260 suppressHydrationWarning
1261 className="mt-1 block text-right text-[0.625rem] leading-5 text-faint tabular-nums opacity-0 group-hover/message:opacity-100"
1262 >
1263 {time.replace(/\s?[AP]M$/, "")}
1264 </time>
1265 )}
1266 </div>
1267 <div className="min-w-0 grow">
1268 {head && (
1269 <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)1270 <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 to1271 {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)1272 </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 managers1273 {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 to1274 {author.kind === "agent" && <AgentPill className="self-center" />}
1275 <time dateTime={message.created_at} suppressHydrationWarning className="shrink-0 text-xs text-faint tabular-nums">
1276 {time}
1277 </time>
1278 </div>
1279 )}
1280 <div className={message.pending && !message.failed ? "opacity-60" : undefined}>
1281 {message.body && <MessageText body={message.body} context={context} />}
Artifacts is a mode in the rail, the shell and the phone's tab bar in place of Docs: its home lists everything you can open by day or as cards under All, Yours and Shared with you, with search, filters and tiles to start a doc (slides, designs and dashboards say they are coming soon), and its sidebar holds favorites, spaces, Private, Shared, projects' docs and the trash.1282 {message.body && !message.pending && <FolioUnfurls slug={slug} body={message.body} />}
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar1283 {message.card && (
1284 <CardBox
1285 card={message.card}
1286 slug={slug}
1287 code={code}
1288 channelId={message.channel_id}
1289 messageId={message.id}
1290 inert={!!message.pending || !!message.deleted_at}
1291 />
1292 )}
Chat and workspace agents: channels, DMs and named agents you talk to1293 {message.edited_at && <span className="text-[0.6875rem] text-faint"> (edited)</span>}
1294 </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)1295 {!message.pending && !message.deleted_at && <ReactionBar messageId={message.id} reactions={message.reactions} />}
Chat and workspace agents: channels, DMs and named agents you talk to1296 {message.failed && (
1297 <p className="mt-0.5 text-xs text-danger">
1298 Not sent.{" "}
1299 <button type="button" onClick={onRetry} className="font-medium underline underline-offset-2 hover:text-fg">
1300 Try again
1301 </button>
1302 </p>
1303 )}
1304 {!inThread && message.reply_count > 0 && onThread && (
1305 <button
1306 type="button"
1307 onClick={onThread}
1308 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"
1309 >
1310 <MessageSquareText size={14} />
1311 {message.reply_count} {message.reply_count === 1 ? "reply" : "replies"}
1312 {message.last_reply_at && (
1313 <span className="font-normal text-faint" suppressHydrationWarning>
1314 Last reply {clock(message.last_reply_at, zone)}
1315 </span>
1316 )}
1317 </button>
1318 )}
1319 </div>
1320 {!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)1321 <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">
1322 <AddReaction messageId={message.id} />
Chat and workspace agents: channels, DMs and named agents you talk to1323 <Hint label="Reply in thread">
1324 <button
1325 type="button"
1326 aria-label="Reply in thread"
1327 onClick={onThread}
1328 className="flex size-7 items-center justify-center rounded-md text-muted hover:bg-raised hover:text-fg"
1329 >
1330 <MessageSquareText size={15} />
1331 </button>
1332 </Hint>
Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread1333 {onCopyLink && <MoreActions onCopyLink={onCopyLink} onWriteUp={onWriteUp} />}
Chat and workspace agents: channels, DMs and named agents you talk to1334 </div>
1335 )}
1336 </article>
1337 );
1338}
1339
Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread1340/** A message's "⋯" in its hover toolbar. */
1341function MoreActions({ onCopyLink, onWriteUp }: { onCopyLink: () => void; onWriteUp?: () => void }) {
1342 return (
1343 <DropdownMenu>
1344 <Hint label="More actions">
1345 <DropdownMenuTrigger
1346 aria-label="More actions"
1347 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"
1348 >
1349 <Ellipsis size={15} />
1350 </DropdownMenuTrigger>
1351 </Hint>
1352 <DropdownMenuContent align="end">
1353 <ThreadMenuItems onCopyLink={onCopyLink} onWriteUp={onWriteUp} />
1354 </DropdownMenuContent>
1355 </DropdownMenu>
1356 );
1357}
1358
1359function ThreadMenuItems({ onCopyLink, onWriteUp }: { onCopyLink: () => void; onWriteUp?: () => void }) {
1360 return (
1361 <>
1362 <DropdownMenuItem onSelect={onCopyLink}>
1363 <Link2 />
1364 Copy link to thread
1365 </DropdownMenuItem>
1366 {onWriteUp && (
1367 <DropdownMenuItem onSelect={onWriteUp}>
1368 <FileText />
Artifacts is a mode in the rail, the shell and the phone's tab bar in place of Docs: its home lists everything you can open by day or as cards under All, Yours and Shared with you, with search, filters and tiles to start a doc (slides, designs and dashboards say they are coming soon), and its sidebar holds favorites, spaces, Private, Shared, projects' docs and the trash.1369 Write this up as an artifact
Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread1370 </DropdownMenuItem>
1371 )}
1372 </>
1373 );
1374}
1375
1376/** The thread panel's "⋯", in its header. */
1377function ThreadMenu({ onCopyLink, onWriteUp }: { onCopyLink: () => void; onWriteUp?: () => void }) {
1378 return (
1379 <DropdownMenu>
1380 <Hint label="Thread actions">
1381 <DropdownMenuTrigger
1382 aria-label="Thread actions"
1383 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"
1384 >
1385 <Ellipsis size={16} />
1386 </DropdownMenuTrigger>
1387 </Hint>
1388 <DropdownMenuContent align="end">
1389 <ThreadMenuItems onCopyLink={onCopyLink} onWriteUp={onWriteUp} />
1390 </DropdownMenuContent>
1391 </DropdownMenu>
1392 );
1393}
1394
Chat and workspace agents: channels, DMs and named agents you talk to1395/** "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)1396/** What an agent working on a reply says it is doing: one phrase per agent, so each sounds like itself. */
1397const 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"];
1398
1399function agentDoing(id: string): string {
1400 let hash = 0;
1401 for (const char of id) hash = (hash * 31 + char.charCodeAt(0)) | 0;
1402 return AGENT_DOING[Math.abs(hash) % AGENT_DOING.length]!;
1403}
1404
Chat and workspace agents: channels, DMs and named agents you talk to1405function TypingLine({ members }: { members: MemberProfile[] }) {
1406 const names = members.map(shownName);
1407 const who = names.length === 1 ? names[0] : names.length === 2 ? `${names[0]} and ${names[1]}` : names.length > 2 ? "Several people" : "";
1408 return (
1409 <div aria-live="polite" className="flex h-6 items-center gap-1.5 px-1 text-xs text-muted">
1410 {who && (
1411 <>
1412 <span aria-hidden="true" className="flex gap-0.5">
1413 {[0, 1, 2].map((i) => (
1414 <span key={i} className="size-1 animate-bounce rounded-full bg-accent" style={{ animationDelay: `${i * 140}ms` }} />
1415 ))}
1416 </span>
1417 <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)1418 <span className="font-medium text-fg-soft">{who}</span>{" "}
1419 {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 to1420 </span>
1421 </>
1422 )}
1423 </div>
1424 );
1425}
1426
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)1427/**
1428 * A panel beside the conversation: a thread, or the details. On a phone it
1429 * is a screen of its own, pushed over the conversation, as tall as what
1430 * the keyboard leaves.
1431 */
Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread1432function 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)1433 const swipe = useSwipeBack(onClose);
Chat and workspace agents: channels, DMs and named agents you talk to1434 return (
1435 <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)1436 {...swipe}
Chat and workspace agents: channels, DMs and named agents you talk to1437 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)1438 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 to1439 >
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)1440 <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">
1441 <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">
1442 <ChevronLeft size={22} />
Chat and workspace agents: channels, DMs and named agents you talk to1443 </button>
1444 <h2 className="text-[0.9375rem] font-semibold">{title}</h2>
1445 {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 thread1446 <div className="ml-auto flex shrink-0 items-center gap-1">
1447 {menu}
1448 <button type="button" onClick={onClose} aria-label="Close" className="hidden rounded-md p-1 text-faint hover:bg-raised hover:text-fg lg:block">
1449 <X size={16} />
1450 </button>
1451 </div>
Chat and workspace agents: channels, DMs and named agents you talk to1452 </div>
1453 <div className="flex min-h-0 grow flex-col">{children}</div>
1454 </aside>
1455 );
1456}
1457
1458function ThreadPanel({
1459 root,
1460 replies,
1461 zone,
1462 context,
1463 code,
1464 slug,
1465 people,
1466 joined,
1467 onSend,
1468 onRetry,
Chat controls, public profiles, shadcn selects, and no Docs tab in a project1469 onTyping,
Chat and workspace agents: channels, DMs and named agents you talk to1470 draftKey,
1471}: {
1472 root: ShownMessage | null;
1473 replies: ShownMessage[] | null;
1474 zone: string | undefined;
1475 context: TextContext;
1476 code: boolean;
1477 slug: string;
1478 people: Mentionable[];
1479 joined: boolean;
1480 onSend: (body: string) => void;
1481 onRetry: (message: ShownMessage) => void;
Chat controls, public profiles, shadcn selects, and no Docs tab in a project1482 /** Says "is typing" to the others in the channel, as the main composer does. */
1483 onTyping: () => void;
Chat and workspace agents: channels, DMs and named agents you talk to1484 draftKey: string;
1485}) {
1486 const rows = useMemo(() => timeline(replies ?? [], new Date(), zone).filter((row) => row.kind === "message"), [replies, zone]);
1487 return (
1488 <>
1489 <div className="min-h-0 grow overflow-y-auto px-1 py-3 [scrollbar-width:thin]">
1490 {root ? (
1491 <MessageRow message={root} head zone={zone} context={context} code={code} slug={slug} onRetry={() => onRetry(root)} inThread />
1492 ) : (
Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread1493 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 to1494 )}
1495 <div className="my-2 flex items-center gap-3 px-4 text-xs text-faint">
1496 {replies == null ? "Loading replies…" : `${replies.filter((r) => !r.deleted_at).length} ${replies.length === 1 ? "reply" : "replies"}`}
1497 <span className="h-px grow bg-line" />
1498 </div>
1499 {rows.map((row) =>
1500 row.kind === "message" ? (
1501 <MessageRow
1502 key={row.key}
1503 message={row.message}
1504 head={row.head}
1505 zone={zone}
1506 context={context}
1507 code={code}
1508 slug={slug}
1509 onRetry={() => onRetry(row.message)}
1510 inThread
1511 />
1512 ) : null,
1513 )}
1514 </div>
1515 {joined && (
1516 <div className="shrink-0 p-3">
Chat controls, public profiles, shadcn selects, and no Docs tab in a project1517 <Composer draftKey={draftKey} placeholder="Reply…" people={people} onSend={onSend} onTyping={onTyping} compact autoFocus />
1518 </div>
1519 )}
1520 </>
1521 );
1522}
1523
1524/**
1525 * A channel's name, topic and visibility, and changing them: any member
1526 * edits the topic; renaming and archiving are for whoever the workspace's
1527 * chat settings allow (`canManage`). #general keeps its name and stays.
1528 */
1529function ChannelAbout({
1530 channel,
1531 joined,
1532 canManage,
1533 onUpdate,
1534}: {
1535 channel: Channel;
1536 joined: boolean;
1537 canManage: boolean;
1538 onUpdate: (change: ChannelChange) => Promise<Result<unknown>>;
1539}) {
1540 const [editing, setEditing] = useState<"name" | "topic" | null>(null);
1541 const [value, setValue] = useState("");
1542 const [error, setError] = useState<string | null>(null);
1543 const [busy, setBusy] = useState(false);
1544 const [confirm, setConfirm] = useState(false);
1545 const general = channel.name === "general";
1546 const archived = !!channel.archived_at;
1547 const mayRename = canManage && !general && !archived;
1548 const mayTopic = joined && !archived;
1549 const save = async (change: ChannelChange) => {
1550 setBusy(true);
1551 setError(null);
1552 const done = await onUpdate(change);
1553 setBusy(false);
1554 if (!done.ok) return setError(done.error.message);
1555 setEditing(null);
1556 setConfirm(false);
1557 };
1558 const start = (field: "name" | "topic") => {
1559 setEditing(field);
1560 setValue(field === "name" ? (channel.name ?? "") : (channel.topic ?? ""));
1561 setError(null);
1562 };
1563 const edit = (field: "name" | "topic") => (
1564 <form
1565 className="mt-1.5 space-y-2"
1566 onSubmit={(event) => {
1567 event.preventDefault();
1568 void save(field === "name" ? { name: value } : { topic: value });
1569 }}
1570 >
1571 <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">
1572 {field === "name" && (channel.private ? <Lock size={13} className="text-faint" /> : <Hash size={13} className="text-faint" />)}
1573 <input
1574 autoFocus
1575 value={value}
1576 onChange={(event) => setValue(event.target.value)}
1577 onKeyDown={(event) => event.key === "Escape" && setEditing(null)}
1578 maxLength={field === "name" ? 80 : 250}
1579 placeholder={field === "name" ? "channel-name" : "What this channel is for"}
1580 aria-label={field === "name" ? "Channel name" : "Topic"}
1581 autoComplete="off"
1582 data-1p-ignore
1583 className="min-w-0 grow bg-transparent text-sm outline-none placeholder:text-faint"
1584 />
1585 </span>
1586 <div className="flex justify-end gap-1.5">
1587 <button type="button" onClick={() => setEditing(null)} className="h-7 rounded-md px-2.5 text-xs text-muted hover:bg-raised hover:text-fg">
1588 Cancel
1589 </button>
1590 <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">
1591 {busy ? "Saving…" : "Save"}
1592 </button>
1593 </div>
1594 </form>
1595 );
1596 const editButton = (field: "name" | "topic", label: string) => (
1597 <button
1598 type="button"
1599 onClick={() => start(field)}
1600 aria-label={label}
1601 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"
1602 >
1603 Edit
1604 </button>
1605 );
1606 return (
1607 <>
1608 <section className="group/about rounded-xl border border-line bg-surface p-3.5">
1609 <div className="flex items-center justify-between">
1610 <h3 className="text-xs font-medium text-faint">Name</h3>
1611 {mayRename && editing !== "name" && editButton("name", "Rename channel")}
Chat and workspace agents: channels, DMs and named agents you talk to1612 </div>
Chat controls, public profiles, shadcn selects, and no Docs tab in a project1613 {editing === "name" ? edit("name") : <p className="mt-1 text-sm text-fg-soft">#{channel.name}</p>}
1614 <div className="mt-3 flex items-center justify-between">
1615 <h3 className="text-xs font-medium text-faint">Topic</h3>
1616 {mayTopic && editing !== "topic" && editButton("topic", "Edit topic")}
1617 </div>
1618 {editing === "topic" ? edit("topic") : <p className="mt-1 text-sm text-fg-soft">{channel.topic || "No topic yet."}</p>}
1619 <h3 className="mt-3 text-xs font-medium text-faint">Visibility</h3>
1620 <p className="mt-1 flex items-center gap-1.5 text-sm text-fg-soft">
1621 {channel.private ? <Lock size={13} /> : <Hash size={13} />}
1622 {channel.private ? "Private: only its members can find and read it" : "Public: anyone in the workspace can read and join"}
1623 </p>
1624 {error && <p className="mt-2 text-xs text-danger">{error}</p>}
1625 </section>
1626 {canManage && !general && (
1627 <button
1628 type="button"
1629 onClick={() => (archived ? void save({ archived: false }) : setConfirm(true))}
1630 disabled={busy}
1631 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"
1632 >
1633 {archived ? <ArchiveRestore size={14} /> : <Archive size={14} />}
1634 {archived ? "Unarchive channel" : "Archive channel"}
1635 </button>
Chat and workspace agents: channels, DMs and named agents you talk to1636 )}
Chat controls, public profiles, shadcn selects, and no Docs tab in a project1637 <AlertDialog open={confirm} onOpenChange={setConfirm}>
1638 <AlertDialogContent>
1639 <AlertDialogHeader>
1640 <AlertDialogTitle>Archive #{channel.name}?</AlertDialogTitle>
1641 <AlertDialogDescription>
1642 It leaves everyone&apos;s sidebar and nobody can post in it. Its history stays readable, and it can be unarchived from Browse
1643 channels, Archived.
1644 </AlertDialogDescription>
1645 </AlertDialogHeader>
1646 <AlertDialogFooter>
1647 <AlertDialogCancel>Cancel</AlertDialogCancel>
1648 <AlertDialogAction
1649 onClick={(event) => {
1650 event.preventDefault();
1651 void save({ archived: true });
1652 }}
1653 disabled={busy}
1654 >
1655 {busy ? "Archiving…" : "Archive"}
1656 </AlertDialogAction>
1657 </AlertDialogFooter>
1658 </AlertDialogContent>
1659 </AlertDialog>
Chat and workspace agents: channels, DMs and named agents you talk to1660 </>
1661 );
1662}
1663
1664function InfoPanel({
1665 data,
1666 members,
1667 slug,
1668 people,
1669 onAdded,
1670 joined,
Chat controls, public profiles, shadcn selects, and no Docs tab in a project1671 canManage,
1672 onUpdate,
Chat and workspace agents: channels, DMs and named agents you talk to1673}: {
1674 data: Loaded;
1675 members: ChannelMember[];
1676 slug: string;
1677 people: Mentionable[];
1678 onAdded: (member: ChannelMember) => void;
1679 joined: boolean;
Chat controls, public profiles, shadcn selects, and no Docs tab in a project1680 /** Whether the viewer may rename, archive and unarchive it. */
1681 canManage: boolean;
1682 onUpdate: (change: ChannelChange) => Promise<Result<unknown>>;
Chat and workspace agents: channels, DMs and named agents you talk to1683}) {
1684 const channel = data.channel;
1685 const agents = members.filter((m) => m.member.kind === "agent");
1686 const humans = members.filter((m) => m.member.kind === "user");
1687 return (
1688 <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 project1689 {channel.kind === "channel" && <ChannelAbout channel={channel} joined={joined} canManage={canManage} onUpdate={onUpdate} />}
Chat and workspace agents: channels, DMs and named agents you talk to1690 {agents.length > 0 && (
1691 <section className="mt-5">
1692 <h3 className="mb-2 px-1 text-xs font-medium text-faint">Agents · {agents.length}</h3>
1693 <div className="flex flex-wrap gap-1.5">
1694 {agents.map(({ member }) => (
1695 <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)1696 <MemberCard
1697 member={member}
Chat and workspace agents: channels, DMs and named agents you talk to1698 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"
1699 >
1700 <MemberAvatar member={member} size={20} />
1701 {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)1702 </MemberCard>
Chat and workspace agents: channels, DMs and named agents you talk to1703 </Hint>
1704 ))}
1705 </div>
1706 </section>
1707 )}
1708 <section className="mt-5">
1709 <div className="mb-1 flex items-center justify-between px-1">
1710 <h3 className="text-xs font-medium text-faint">People · {humans.length}</h3>
1711 {joined && channel.kind === "channel" && <AddMember slug={slug} channelId={channel.id} people={people} members={members} onAdded={onAdded} />}
1712 </div>
1713 <ul className="space-y-px">
1714 {humans.map(({ member, role }) => (
1715 <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)1716 <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 managers1717 <MemberAvatar member={member} size={26} presence />
Chat and workspace agents: channels, DMs and named agents you talk to1718 <span className="min-w-0 grow truncate text-sm">
1719 {shownName(member)}
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar1720 {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 managers1721 <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 to1722 </span>
1723 {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)1724 </MemberCard>
Chat and workspace agents: channels, DMs and named agents you talk to1725 </li>
1726 ))}
1727 </ul>
1728 </section>
1729 </div>
1730 );
1731}
1732
1733/** Adding a person or an agent to the channel. An invite lets them read; it never grants write. */
1734function AddMember({
1735 slug,
1736 channelId,
1737 people,
1738 members,
1739 onAdded,
1740}: {
1741 slug: string;
1742 channelId: string;
1743 people: Mentionable[];
1744 members: ChannelMember[];
1745 onAdded: (member: ChannelMember) => void;
1746}) {
1747 const [open, setOpen] = useState(false);
1748 const [query, setQuery] = useState("");
1749 const [error, setError] = useState<string | null>(null);
1750 const send = useChatSend(slug);
1751 const chatData = useChatData();
1752 const inside = new Set(members.map((m) => `${m.member.kind}:${m.member.name.toLowerCase()}`));
1753 const q = query.trim().toLowerCase().replace(/^@/, "");
1754 const options = people
1755 .filter((p) => !inside.has(`${p.kind}:${p.name.toLowerCase()}`))
1756 .filter((p) => !q || p.name.toLowerCase().includes(q) || p.display_name.toLowerCase().includes(q))
1757 .slice(0, 8);
1758 const add = async (person: Mentionable) => {
1759 const agent = person.kind === "agent" ? chatData?.agents.find((a) => a.handle === person.name) : null;
1760 const key = agent ? `agent:${agent.id}` : `user:${person.name}`;
1761 setError(null);
1762 const done = await send({ intent: "invite", channel_id: channelId, member: key });
1763 if (!done.ok) return setError(done.error.message);
1764 onAdded({
1765 channel_id: channelId,
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar1766 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 to1767 role: "member",
1768 starred: false,
1769 muted: false,
1770 last_read_id: null,
1771 joined_at: new Date().toISOString(),
1772 });
1773 setOpen(false);
1774 };
1775 return (
1776 <Popover open={open} onOpenChange={setOpen}>
1777 <PopoverTrigger asChild>
1778 <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">
1779 <UserPlus size={13} />
1780 Add
1781 </button>
1782 </PopoverTrigger>
1783 <PopoverContent align="end" className="w-72 p-1.5">
1784 <input
1785 autoFocus
1786 value={query}
1787 onChange={(event) => setQuery(event.target.value)}
1788 placeholder="Add a person or an agent"
1789 aria-label="Add a person or an agent"
1790 autoComplete="off"
1791 data-1p-ignore
1792 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"
1793 />
1794 {options.length === 0 && <p className="px-2 py-3 text-xs text-faint">Everyone who matches is already here.</p>}
1795 {options.map((person) => (
1796 <button
1797 key={`${person.kind}:${person.name}`}
1798 type="button"
1799 onClick={() => void add(person)}
1800 className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm hover:bg-line"
1801 >
1802 <MemberAvatar member={person} size={20} />
1803 <span className="min-w-0 grow truncate">{person.display_name}</span>
1804 {person.kind === "agent" && <AgentPill />}
1805 </button>
1806 ))}
1807 {error && <p className="px-2 pt-1 text-xs text-danger">{error}</p>}
1808 <p className="border-t border-line px-2 pt-1.5 pb-0.5 text-[0.6875rem] leading-snug text-faint">
1809 Adding an agent lets it read this channel. It never grants it write access.
1810 </p>
1811 </PopoverContent>
1812 </Popover>
1813 );
1814}

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