Skip to content
1,822 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);
Merge main into Artifacts Phase 2171 // A message being edited: in its place on a computer (`inline`), in a sheet on a phone.
172 const [editing, setEditing] = useState<{ id: string; body: string; inline?: boolean } | null>(null);
Chat and workspace agents: channels, DMs and named agents you talk to173 const scroller = useRef<HTMLDivElement>(null);
174 const stick = useRef(true);
175
176 const entry = sidebar?.entries.find((e) => e.channel.id === data.channel.id);
177 const starred = entry?.starred ?? members.find((m) => m.member.kind === "user" && m.member.id === me?.id)?.starred ?? false;
178 const muted = entry?.muted ?? false;
179 const others = members.filter((m) => !(m.member.kind === "user" && m.member.id === me?.id)).map((m) => m.member);
180 const isDm = data.channel.kind === "dm";
181
182 // The details panel: open by default on a wide screen, as last left.
183 useEffect(() => {
184 try {
185 const saved = localStorage.getItem("chat-info");
186 setInfo(saved ? saved === "1" : window.matchMedia("(min-width: 1280px)").matches && !isDm);
187 } catch {
188 setInfo(false);
189 }
190 }, [isDm]);
191 const toggleInfo = () =>
192 setInfo((now) => {
193 try {
194 localStorage.setItem("chat-info", now ? "0" : "1");
195 } catch {
196 // Not kept: fine.
197 }
198 return !now;
199 });
200
201 const people = useMemo<Mentionable[]>(
202 () => [
203 ...(chatData?.agents ?? []).map((agent) => ({ kind: "agent" as const, name: agent.handle, display_name: agent.display_name, avatar: agent.avatar, role: agent.role })),
204 ...(chatData?.people ?? []).filter((p) => p.name !== me?.username),
205 ],
206 [chatData, me?.username],
207 );
208 const context = useMemo<TextContext>(
209 () => ({
210 slug,
211 me: me?.username ?? null,
212 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 calendar213 // `@ana` reads as the name people know: everyone in the workspace, the viewer too.
214 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 to215 channels: new Set((sidebar?.entries ?? []).filter((e) => e.channel.kind === "channel").map((e) => e.channel.name ?? "")),
216 project,
217 codeLink: code ? undefined : (href: string) => (/^\/[^/]+\/(?!-\/)[^/]+/.test(href) ? codeAccessPath(slug, href) : href),
218 }),
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar219 [slug, me, people, chatData?.agents, sidebar?.entries, project, code],
Chat and workspace agents: channels, DMs and named agents you talk to220 );
221
222 // Keeps the newest message in view while the reader is at the bottom.
223 const toBottom = useCallback((smooth = false) => {
224 const element = scroller.current;
225 if (element) element.scrollTo({ top: element.scrollHeight, behavior: smooth ? "smooth" : "auto" });
226 }, []);
227 useEffect(() => toBottom(), [toBottom]);
228 useEffect(() => {
229 if (stick.current) toBottom(true);
230 }, [messages.length, typing.size, toBottom]);
231
232 // Read up to the newest message whenever it is in view.
233 const lastRead = useRef<string | null>(null);
234 const markRead = useCallback(() => {
235 if (!joined || document.visibilityState !== "visible") return;
236 const newest = [...messages].reverse().find((m) => !m.pending);
237 if (!newest || newest.id === lastRead.current) return;
238 lastRead.current = newest.id;
239 void send({ intent: "read", channel_id: data.channel.id, id: newest.id });
240 window.dispatchEvent(new CustomEvent("g1t:chat-read", { detail: data.channel.id }));
241 }, [messages, joined, send, data.channel.id]);
242 useEffect(() => {
243 const timer = setTimeout(markRead, 400);
244 document.addEventListener("visibilitychange", markRead);
245 window.addEventListener("focus", markRead);
246 return () => {
247 clearTimeout(timer);
248 document.removeEventListener("visibilitychange", markRead);
249 window.removeEventListener("focus", markRead);
250 };
251 }, [markRead]);
252
253 // The thread open beside the conversation, from `?thread=`.
254 const loadThread = useCallback(async () => {
255 if (!threadId) return setThread(null);
256 try {
257 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 thread258 const result = (await response.json()) as Result<{ messages: ChatMessage[]; root?: ChatMessage | null }>;
Chat and workspace agents: channels, DMs and named agents you talk to259 // The page reaches back to the message the thread is under: shown above it, not among the replies.
260 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 thread261 const root = result.ok ? (result.value.root ?? result.value.messages.find((m) => m.id === threadId) ?? null) : null;
262 setThreadRoot(root);
Chat and workspace agents: channels, DMs and named agents you talk to263 } catch {
264 setThread([]);
265 }
266 }, [threadId, slug, data.channel.id]);
267 useEffect(() => {
268 setThread(null);
Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread269 setThreadRoot(null);
Chat and workspace agents: channels, DMs and named agents you talk to270 void loadThread();
271 }, [loadThread]);
272
273 // Reply counts: counted here only until the service's own count for that
274 // message arrives (a `message.updated` of it), which can come before or
275 // after the reply itself; never twice for one reply.
276 const serverCounted = useRef(new Set<string>());
277 const countedReplies = useRef(new Set<string>());
278 const countReply = useCallback((root: string, reply: string, at: string) => {
279 if (serverCounted.current.has(root) || countedReplies.current.has(reply)) return;
280 countedReplies.current.add(reply);
281 setMessages((now) => now.map((m) => (m.id === root ? { ...m, reply_count: m.reply_count + 1, last_reply_at: at } : m)));
282 }, []);
283 const latest = useRef(messages);
284 latest.current = messages;
285
286 // Everything that happens while the page is open.
287 const onEvent = useCallback(
288 (event: ChatLiveEvent) => {
289 if (event.type === "message.created" || event.type === "message.updated") {
290 const message = event.message;
291 if (message.channel_id !== data.channel.id) return;
292 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)293 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 to294 if (event.type === "message.created") countReply(message.thread_root, message.id, message.created_at);
295 } else {
296 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)297 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 thread298 // The open thread's root, a session's card say, changes in place at its top too.
299 if (message.id === threadId) setThreadRoot((now) => (now ? keepMine([now], message) : now));
Chat and workspace agents: channels, DMs and named agents you talk to300 }
301 // Whoever just spoke is no longer typing.
302 setTyping((now) => {
303 const key = `${message.author.kind}:${message.author.id}`;
304 if (!now.has(key)) return now;
305 const next = new Map(now);
306 next.delete(key);
307 return next;
308 });
309 } else if (event.type === "message.deleted") {
310 if (event.channel_id !== data.channel.id) return;
311 const gone = (m: ShownMessage) => (m.id === event.id ? { ...m, deleted_at: new Date().toISOString() } : m);
312 setMessages((now) => now.map(gone));
313 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 thread314 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)315 } else if (event.type === "reaction.added" || event.type === "reaction.removed") {
316 if (event.channel_id !== data.channel.id) return;
317 setMessages((now) => applyReactionEvent(now, event, me?.id ?? null));
318 setThread((now) => (now ? applyReactionEvent(now, event, me?.id ?? null) : now));
Chat and workspace agents: channels, DMs and named agents you talk to319 } else if (event.type === "typing") {
320 if (event.channel_id !== data.channel.id) return;
321 if (event.member.kind === "user" && event.member.id === me?.id) return;
322 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 project323 } else if (event.type === "channel.updated") {
324 // Renamed, a new topic, archived or back: everyone looking sees it at once.
325 if (event.channel.id !== data.channel.id) return;
326 setChannel(event.channel);
327 refresh();
Chat and workspace agents: channels, DMs and named agents you talk to328 }
329 },
Chat controls, public profiles, shadcn selects, and no Docs tab in a project330 [data.channel.id, threadId, me?.id, countReply, refresh],
Chat and workspace agents: channels, DMs and named agents you talk to331 );
332 // Back after a drop: what was missed.
333 const onReconnect = useCallback(async () => {
334 const base = `/${slug}/-/chat/api?channel=${encodeURIComponent(data.channel.id)}`;
335 try {
336 // Everything after the newest message held, page by page, oldest first;
337 // what was deleted meanwhile comes back empty and is dropped.
338 let after = [...latest.current].reverse().find((m) => !m.pending)?.id ?? null;
339 for (let pages = 0; after && pages < 20; pages++) {
340 const response = await fetch(`${base}&after=${encodeURIComponent(after)}`);
341 const result = (await response.json()) as Result<{ messages: ChatMessage[]; newer?: string | null }>;
342 if (!result.ok) break;
343 const gone = new Set(result.value.messages.filter((m) => m.deleted_at).map((m) => m.id));
344 const kept = result.value.messages.filter((m) => !m.deleted_at && !m.thread_root);
345 setMessages((now) => mergeMessages(now.filter((m) => !gone.has(m.id)), kept));
346 after = result.value.newer ?? null;
347 }
348 // Edits and deletions to what was already on screen: the latest page again.
349 const response = await fetch(base);
350 const result = (await response.json()) as Result<{ messages: ChatMessage[] }>;
351 if (result.ok) {
352 const gone = new Set(result.value.messages.filter((m) => m.deleted_at).map((m) => m.id));
353 setMessages((now) => mergeMessages(now.filter((m) => !gone.has(m.id)), result.value.messages.filter((m) => !m.deleted_at)));
354 }
355 } catch {
356 // The next event, or the next return, catches up.
357 }
358 void loadThread();
359 refresh();
360 }, [slug, data.channel.id, loadThread, refresh]);
361 const live = useChatLive(slug, data.channel.id, onEvent, onReconnect);
362
363 // Typing shows until its time runs out.
364 useEffect(() => {
365 if (typing.size === 0) return;
366 const timer = setInterval(() => {
367 const now = Date.now();
368 setTyping((current) => {
369 const next = new Map([...current].filter(([, value]) => value.until > now));
370 return next.size === current.size ? current : next;
371 });
372 }, 1000);
373 return () => clearInterval(timer);
374 }, [typing.size]);
375
376 // Says "is typing" to the others, at most every few seconds.
377 const lastTyping = useRef(0);
378 const onTyping = useCallback(() => {
379 const now = Date.now();
380 if (now - lastTyping.current < 3000) return;
381 lastTyping.current = now;
382 live.send({ type: "typing", channel_id: data.channel.id });
383 }, [live, data.channel.id]);
384
385 const post = useCallback(
386 async (body: string, root: string | null) => {
387 if (!me) return;
388 const clientId = newClientId();
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar389 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 to390 const pending: ShownMessage = {
391 id: clientId,
392 client_id: clientId,
393 pending: true,
394 channel_id: data.channel.id,
395 author,
396 kind: "text",
397 body,
398 card: null,
399 thread_root: root,
400 reply_count: 0,
401 last_reply_at: null,
402 created_at: new Date().toISOString(),
403 edited_at: null,
404 deleted_at: null,
405 };
406 const put = (list: ShownMessage[]) => mergeMessages(list, [pending]);
407 if (root) setThread((now) => put(now ?? []));
408 else {
409 stick.current = true;
410 setMessages(put);
411 }
412 const saved = await send<ChatMessage>({ intent: "post", channel_id: data.channel.id, body, thread_root: root });
413 const settle = (list: ShownMessage[]) =>
414 saved.ok
415 ? mergeMessages(list, [{ ...saved.value, client_id: clientId }])
416 : list.map((m) => (m.client_id === clientId ? { ...m, failed: true, pending: true } : m));
417 if (root) {
418 setThread((now) => (now ? settle(now) : now));
419 if (saved.ok) countReply(root, saved.value.id, saved.value.created_at);
420 } else setMessages(settle);
421 },
422 [me, send, data.channel.id, countReply],
423 );
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)424 // ── Reactions (components/emoji): shown at once, then as the service counts them.
425 const customs = useCustomEmoji(slug);
426 const { usercontent } = useAddresses();
427 const meProfile = useMemo<MemberProfile | null>(
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar428 () => (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)429 [me],
430 );
431 const react = useCallback(
432 async (messageId: string, emoji: string, on: boolean) => {
433 if (!meProfile) return;
434 const change = (to: boolean) => (list: ShownMessage[]) =>
435 list.map((m) => (m.id === messageId ? { ...m, reactions: toggleReaction(m.reactions ?? [], emoji, to, meProfile) } : m));
436 setMessages(change(on));
437 setThread((now) => (now ? change(on)(now) : now));
438 try {
439 const response = await fetch(`/${slug}/-/chat/api`, {
440 method: "POST",
441 headers: { "content-type": "application/json" },
442 body: JSON.stringify({ intent: on ? "react" : "unreact", channel_id: data.channel.id, message_id: messageId, emoji }),
443 });
444 const result = (await response.json()) as Result<ChatMessage["reactions"]>;
445 if (!result.ok) throw new Error(result.error.message);
446 const settle = (list: ShownMessage[]) => list.map((m) => (m.id === messageId ? { ...m, reactions: result.value } : m));
447 setMessages(settle);
448 setThread((now) => (now ? settle(now) : now));
449 } catch {
450 // Not saved: back as it was.
451 setMessages(change(!on));
452 setThread((now) => (now ? change(!on)(now) : now));
453 }
454 },
455 [meProfile, slug, data.channel.id],
456 );
457 const emojiContext = useMemo(
458 () => ({ customs, usercontent, me: meProfile, react: (id: string, emoji: string, on: boolean) => void react(id, emoji, on), manageHref: `/${slug}/-/emoji` }),
459 [customs, usercontent, meProfile, react, slug],
460 );
461
Chat and workspace agents: channels, DMs and named agents you talk to462 const retry = (message: ShownMessage) => {
463 const drop = (list: ShownMessage[]) => list.filter((m) => m.client_id !== message.client_id);
464 if (message.thread_root) setThread((now) => (now ? drop(now) : now));
465 else setMessages(drop);
466 void post(message.body, message.thread_root);
467 };
468
469 const loadOlder = async () => {
470 if (!older || loadingOlder) return;
471 setLoadingOlder(true);
472 const element = scroller.current;
473 const fromBottom = element ? element.scrollHeight - element.scrollTop : 0;
474 try {
475 const response = await fetch(`/${slug}/-/chat/api?channel=${encodeURIComponent(data.channel.id)}&before=${encodeURIComponent(older)}`);
476 const result = (await response.json()) as Result<{ messages: ChatMessage[]; older: string | null }>;
477 if (result.ok) {
478 stick.current = false;
479 setMessages((now) => mergeMessages(now, result.value.messages));
480 setOlder(result.value.older);
481 requestAnimationFrame(() => {
482 if (element) element.scrollTop = element.scrollHeight - fromBottom;
483 });
484 }
485 } finally {
486 setLoadingOlder(false);
487 }
488 };
489
490 const openThread = (id: string | null) => {
491 const next = new URLSearchParams(params);
492 if (id) next.set("thread", id);
493 else next.delete("thread");
494 setParams(next, { preventScrollReset: true, replace: !!threadId && !!id });
495 };
496
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.497 // A thread's link, to copy and to cite; and "Write this up as an artifact",
498 // 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 thread499 const [writeUp, setWriteUp] = useState<string | null>(null);
500 const linkToThread = (root: string) => threadLink(window.location.origin, channelPath(slug, channel), root);
501 const copyThreadLink = (root: string) => {
502 const done = navigator.clipboard?.writeText(linkToThread(root));
503 if (!done) return showToast(false, "Couldn't copy the link here.");
504 done.then(
505 () => showToast(true, "Link to the thread copied."),
506 () => showToast(false, "Couldn't copy the link here."),
507 );
508 };
509 const writers = useMemo(() => writeUpAgents(members.map((m) => m.member)), [members]);
510 const askForWriteUp = async (body: string): Promise<string | null> => {
511 const root = writeUp;
512 if (!root) return null;
513 // The thread open already: posted as any reply is, shown at once.
514 if (root === threadId) {
515 void post(body, root);
516 return null;
517 }
518 const saved = await send<ChatMessage>({ intent: "post", channel_id: data.channel.id, body, thread_root: root });
519 if (!saved.ok) return saved.error.message;
520 openThread(root);
521 return null;
522 };
523
Chat controls, public profiles, shadcn selects, and no Docs tab in a project524 const updateChannel = useCallback(
525 async (change: ChannelChange): Promise<Result<unknown>> => {
526 const done = await send<{ channel: Channel }>({ intent: "update_channel", channel_id: data.channel.id, ...change });
527 if (done.ok) setChannel(done.value.channel);
528 return done;
529 },
530 [send, data.channel.id],
531 );
532
Chat and workspace agents: channels, DMs and named agents you talk to533 const toggleStar = async () => {
534 await send({ intent: "preferences", channel_id: data.channel.id, starred: !starred });
535 refresh();
536 };
537
538 const join = async () => {
539 const done = await send({ intent: "join", channel_id: data.channel.id });
540 if (done.ok) {
541 setJoined(true);
542 if (me) {
543 setMembers((now) => [
544 ...now,
545 {
546 channel_id: data.channel.id,
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar547 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 to548 role: "member",
549 starred: false,
550 muted: false,
551 last_read_id: null,
552 joined_at: new Date().toISOString(),
553 },
554 ]);
555 }
556 }
557 };
558
559 const rows = useMemo(() => timeline(messages, new Date(), zone), [messages, zone]);
Merge main into Artifacts Phase 2560 const mine = (message: ShownMessage) => message.author.kind === "user" && message.author.id === me?.id && !message.pending && !message.deleted_at;
561 // Editing uses the composer, started from the message's Markdown.
562 const editor = (target: { id: string; body: string }) => (
563 <Composer
564 initial={target.body}
565 placeholder="Edit message"
566 people={people}
567 autoFocus
568 compact
569 onCancel={() => setEditing(null)}
570 onSend={async (body) => {
571 setEditing(null);
572 if (body === target.body) return;
573 // Shown edited at once; put back if the service refuses.
574 const before = messages.find((m) => m.id === target.id);
575 setMessages((now) => now.map((m) => (m.id === target.id ? { ...m, body, edited_at: new Date().toISOString() } : m)));
576 const saved = await send<ChatMessage>({ intent: "edit", channel_id: data.channel.id, id: target.id, body });
577 if (saved.ok) setMessages((now) => mergeMessages(now, [saved.value]));
578 else if (before) setMessages((now) => now.map((m) => (m.id === before.id ? before : m)));
579 }}
580 />
581 );
Chat and workspace agents: channels, DMs and named agents you talk to582 const typers = [...typing.values()].map((t) => t.member);
Chat controls, public profiles, shadcn selects, and no Docs tab in a project583 const name = channel.name ?? "";
584 const archived = channel.kind === "channel" && !!channel.archived_at;
Chat and workspace agents: channels, DMs and named agents you talk to585 const placeholder = isDm
586 ? `Message ${others.map(shownName).join(", ") || "yourself"}`
587 : `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 thread588 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)589 const cardContext = useMemo(
590 () => ({
591 slug,
592 agents: chatData?.agents ?? [],
593 onViewProfile: (username: string) => {
594 if (threadId) openThread(null);
595 setProfile(username);
596 },
597 }),
598 // eslint-disable-next-line react-hooks/exhaustive-deps
599 [slug, chatData?.agents, threadId],
600 );
Chat and workspace agents: channels, DMs and named agents you talk to601
602 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)603 // On a phone the conversation is the whole screen, as tall as what the
604 // keyboard leaves (components/mobile.tsx), so the composer sits on it.
605 <EmojiProvider value={emojiContext}>
606 <CardContext.Provider value={cardContext}>
607 <div
608 {...swipe}
g1t is described as the product it is in alpha, and its shell takes the new shape: the README, the docs home and docs/PLAN.md label every feature Live, Preview or Coming; a floating dock with the g1t mark, Today, Chat, Notifications, Agents, Code, Artifacts, your pinned apps and the Apps launcher, with People, Workspace and the account menu (now holding help) at its foot; the workspace's logo and switcher heading a flat sidebar that folds away with Ctrl B, or leading the header's breadcrumbs where there is none; the page in a rounded panel with a full-width header; Today as the front page, with first-time acceptance of agents' pull requests, what needs you and what's at stake, spend today and one item to start with, every number from a service; Notifications in place of the Inbox at /notifications; a bottom bar and More sheet on phones; and sign-in, sign-up, two-factor, reset, invites and choosing a workspace on standalone pages with no app around them.609 className="flex h-(--page-h) min-h-0 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"
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)610 >
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar611 {/* What pressing a card's action did (components/chat/card.tsx). */}
612 <CardToasts />
Chat and workspace agents: channels, DMs and named agents you talk to613 <section aria-label={data.title} className="flex min-w-0 grow flex-col">
614 <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)615 onBack={back}
Chat controls, public profiles, shadcn selects, and no Docs tab in a project616 data={view}
Chat and workspace agents: channels, DMs and named agents you talk to617 others={others}
618 starred={starred}
619 muted={muted}
620 memberCount={members.length}
621 live={live.state}
622 info={info}
623 onStar={() => void toggleStar()}
624 onInfo={toggleInfo}
625 />
626 <div
627 ref={scroller}
628 onScroll={(event) => {
629 const element = event.currentTarget;
630 stick.current = element.scrollHeight - element.scrollTop - element.clientHeight < 120;
631 }}
632 className="min-h-0 grow overflow-y-auto overscroll-contain [scrollbar-gutter:stable] [scrollbar-width:thin]"
633 >
634 <div className="mx-auto flex min-h-full max-w-[56rem] flex-col justify-end px-2 pt-6 pb-3 sm:px-4">
635 {older ? (
636 <div className="flex justify-center pb-4">
637 <button
638 type="button"
639 onClick={() => void loadOlder()}
640 className="rounded-full border border-line px-3 py-1 text-xs text-muted transition-colors hover:border-line-strong hover:text-fg"
641 >
642 {loadingOlder ? "Loading…" : "Load earlier messages"}
643 </button>
644 </div>
645 ) : (
Chat controls, public profiles, shadcn selects, and no Docs tab in a project646 <ConversationStart data={view} others={others} />
Chat and workspace agents: channels, DMs and named agents you talk to647 )}
648 {rows.map((row) =>
649 row.kind === "day" ? (
650 <DayRule key={row.key} label={row.label} />
651 ) : (
652 <MessageRow
653 key={row.key}
654 message={row.message}
655 head={row.head}
656 zone={zone}
657 context={context}
658 code={code}
659 slug={slug}
660 onThread={() => openThread(row.message.id)}
661 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)662 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 thread663 onCopyLink={() => copyThreadLink(row.message.id)}
Merge main into Artifacts Phase 2664 onEdit={mine(row.message) ? () => setEditing({ id: row.message.id, body: row.message.body, inline: true }) : undefined}
665 editor={editing?.inline && editing.id === row.message.id ? editor(editing) : undefined}
Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread666 onWriteUp={joined && !archived ? () => setWriteUp(row.message.id) : undefined}
Chat and workspace agents: channels, DMs and named agents you talk to667 threadOpen={row.message.id === threadId}
668 />
669 ),
670 )}
671 </div>
672 </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)673 <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 to674 <div className="mx-auto max-w-[56rem]">
675 <TypingLine members={typers} />
Chat controls, public profiles, shadcn selects, and no Docs tab in a project676 {archived ? (
677 <div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-line bg-surface px-4 py-3">
678 <p className="flex items-center gap-2 text-sm text-muted">
679 <Archive size={15} className="shrink-0 text-faint" />
680 <span>
681 <span className="font-medium text-fg">#{name}</span> is archived. Its history stays here to read; nobody can post in it.
682 </span>
683 </p>
684 {canManage && (
685 <button
686 type="button"
687 onClick={() => void updateChannel({ archived: false })}
688 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"
689 >
690 <ArchiveRestore size={15} />
691 Unarchive
692 </button>
693 )}
694 </div>
695 ) : joined ? (
Chat and workspace agents: channels, DMs and named agents you talk to696 <Composer draftKey={data.channel.id} placeholder={placeholder} people={people} onSend={(body) => void post(body, null)} onTyping={onTyping} autoFocus />
697 ) : (
698 <div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-line bg-surface px-4 py-3">
699 <p className="text-sm text-muted">
700 You're reading <span className="font-medium text-fg">#{name}</span>. Join to post and get its notifications.
701 </p>
702 <button
703 type="button"
704 onClick={() => void join()}
705 className="inline-flex h-9 items-center rounded-md bg-accent px-3.5 text-sm font-medium text-bg hover:bg-accent-hover"
706 >
707 Join channel
708 </button>
709 </div>
710 )}
711 </div>
712 </div>
713 </section>
714 {threadId ? (
Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread715 <SidePanel
716 title="Thread"
717 subtitle={isDm ? undefined : `#${name}`}
718 onClose={() => openThread(null)}
719 menu={<ThreadMenu onCopyLink={() => copyThreadLink(threadId)} onWriteUp={joined && !archived ? () => setWriteUp(threadId) : undefined} />}
720 >
Chat and workspace agents: channels, DMs and named agents you talk to721 <ThreadPanel
722 root={rootMessage}
723 replies={thread}
724 zone={zone}
725 context={context}
726 code={code}
727 slug={slug}
728 people={people}
Chat controls, public profiles, shadcn selects, and no Docs tab in a project729 joined={joined && !archived}
Chat and workspace agents: channels, DMs and named agents you talk to730 onSend={(body) => void post(body, threadId)}
731 onRetry={retry}
Chat controls, public profiles, shadcn selects, and no Docs tab in a project732 onTyping={onTyping}
Chat and workspace agents: channels, DMs and named agents you talk to733 draftKey={`${data.channel.id}:${threadId}`}
734 />
735 </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)736 ) : profile ? (
737 <SidePanel title="Profile" onClose={() => setProfile(null)}>
738 <ProfilePanel slug={slug} username={profile} messages={messages} zone={zone} context={context} />
739 </SidePanel>
Chat and workspace agents: channels, DMs and named agents you talk to740 ) : (
741 info && (
742 <SidePanel title={isDm ? "Details" : "About this channel"} onClose={toggleInfo}>
Chat controls, public profiles, shadcn selects, and no Docs tab in a project743 <InfoPanel
744 data={view}
745 members={members}
746 slug={slug}
747 people={people}
748 onAdded={(m) => setMembers((now) => [...now, m])}
749 joined={joined}
750 canManage={canManage}
751 onUpdate={updateChannel}
752 />
Chat and workspace agents: channels, DMs and named agents you talk to753 </SidePanel>
754 )
755 )}
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)756 <MessageActions
757 message={actions}
758 mine={actions != null && actions.author.kind === "user" && actions.author.id === me?.id}
759 onClose={() => setActions(null)}
760 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 thread761 onCopyLink={(message) => copyThreadLink(message.thread_root ?? message.id)}
762 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)763 onEdit={(message) => setEditing({ id: message.id, body: message.body })}
764 onDelete={async (message) => {
765 // Gone at once; back if the service refuses.
766 setMessages((now) => now.map((m) => (m.id === message.id ? { ...m, deleted_at: new Date().toISOString() } : m)));
767 const done = await send({ intent: "remove", channel_id: data.channel.id, id: message.id });
768 if (!done.ok) setMessages((now) => now.map((m) => (m.id === message.id ? { ...m, deleted_at: null } : m)));
769 }}
770 />
Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread771 <WriteUpDialog
772 open={writeUp != null}
773 onOpenChange={(open) => !open && setWriteUp(null)}
774 slug={slug}
775 agents={writers}
776 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.777 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 thread778 onSend={askForWriteUp}
779 />
Merge main into Artifacts Phase 2780 <BottomSheet open={editing != null && !editing.inline} onOpenChange={(open) => !open && setEditing(null)} title="Edit message">
781 {editing && !editing.inline && (
782 <div className="space-y-3 px-1 pt-1">
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)783 <h2 className="px-2 text-base font-semibold">Edit message</h2>
Merge main into Artifacts Phase 2784 {editor(editing)}
785 </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)786 )}
787 </BottomSheet>
Chat and workspace agents: channels, DMs and named agents you talk to788 </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)789 </CardContext.Provider>
790 </EmojiProvider>
Chat and workspace agents: channels, DMs and named agents you talk to791 );
792}
793
794/** The top of a conversation: what it is, and its controls. */
795function 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)796 onBack,
Chat and workspace agents: channels, DMs and named agents you talk to797 data,
798 others,
799 starred,
800 muted,
801 memberCount,
802 live,
803 info,
804 onStar,
805 onInfo,
806}: {
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)807 /** A phone: back to the list of conversations. */
808 onBack: () => void;
Chat and workspace agents: channels, DMs and named agents you talk to809 data: Loaded;
810 others: MemberProfile[];
811 starred: boolean;
812 muted: boolean;
813 memberCount: number;
814 live: LiveState;
815 info: boolean;
816 onStar: () => void;
817 onInfo: () => void;
818}) {
819 const channel = data.channel;
820 const agentDm = channel.kind === "dm" && others.length === 1 && others[0]!.kind === "agent" ? others[0]! : null;
821 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)822 <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">
823 <button
824 type="button"
825 onClick={onBack}
826 aria-label="Back to conversations"
827 className="flex size-10 shrink-0 items-center justify-center rounded-full text-muted active:bg-raised md:hidden"
828 >
829 <ChevronLeft size={22} />
830 </button>
Chat and workspace agents: channels, DMs and named agents you talk to831 <div className="flex min-w-0 grow items-center gap-2.5">
832 {channel.kind === "channel" ? (
833 <>
834 <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 project835 {channel.private ? (
836 <Hint label="Private channel: only its members can find and read it">
837 <span className="flex">
838 <Lock size={15} className="text-faint" aria-label="Private channel" />
839 </span>
840 </Hint>
841 ) : (
842 <Hash size={16} className="text-faint" aria-label="Public channel" />
843 )}
Chat and workspace agents: channels, DMs and named agents you talk to844 <span className="truncate">{channel.name}</span>
845 </h1>
846 <StarButton starred={starred} onClick={onStar} />
847 {muted && (
848 <Hint label="Muted: no unread count or notifications">
849 <span className="text-faint">
850 <BellOff size={14} aria-label="Muted" />
851 </span>
852 </Hint>
853 )}
Chat controls, public profiles, shadcn selects, and no Docs tab in a project854 {channel.archived_at && (
855 <Badge tone="neutral" className="shrink-0">
856 <Archive size={11} />
857 Archived
858 </Badge>
859 )}
Chat and workspace agents: channels, DMs and named agents you talk to860 {channel.topic && (
861 <>
862 <span aria-hidden="true" className="hidden h-4 w-px shrink-0 bg-line sm:block" />
863 <p className="hidden min-w-0 truncate text-sm text-muted sm:block">{channel.topic}</p>
864 </>
865 )}
866 </>
867 ) : (
868 <>
869 <span className="flex shrink-0 -space-x-1.5">
870 {others.slice(0, 3).map((member) => (
871 <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 managers872 <MemberAvatar member={member} size={26} presence={others.length === 1} />
Chat and workspace agents: channels, DMs and named agents you talk to873 </span>
874 ))}
875 </span>
876 <div className="min-w-0">
877 <h1 className="flex min-w-0 items-center gap-1.5 text-[0.9375rem] leading-tight font-semibold">
878 <span className="truncate">{others.map(shownName).join(", ") || "Just you"}</span>
879 {agentDm && <AgentPill />}
880 </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)881 {agentDm && (titleOf(agentDm) ?? agentDm.role) && (
882 <p className="truncate text-xs leading-tight text-muted">{titleOf(agentDm) ?? agentDm.role}</p>
883 )}
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers884 {/* One person: their status under their name, as their card has it. */}
885 {!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 to886 </div>
887 <StarButton starred={starred} onClick={onStar} />
888 </>
889 )}
890 </div>
891 {live !== "open" && (
892 <span role="status" className="hidden items-center gap-1.5 text-xs text-faint sm:flex">
893 <span className="size-1.5 animate-pulse rounded-full bg-warn" />
894 {live === "reconnecting" ? "Reconnecting…" : "Connecting…"}
895 </span>
896 )}
897 {channel.kind === "channel" && (
898 <Hint label={info ? "Hide details" : "Members and details"}>
899 <button
900 type="button"
901 onClick={onInfo}
902 aria-pressed={info}
903 aria-label="Members and details"
904 className={`flex h-8 items-center gap-1.5 rounded-md border px-2 text-xs tabular-nums transition-colors ${
905 info ? "border-line-strong bg-raised text-fg" : "border-line text-muted hover:border-line-strong hover:text-fg"
906 }`}
907 >
908 <UsersStack count={memberCount} />
909 </button>
910 </Hint>
911 )}
912 {channel.kind === "dm" && (
913 <Hint label={info ? "Hide details" : "Details"}>
914 <button
915 type="button"
916 onClick={onInfo}
917 aria-pressed={info}
918 aria-label="Details"
919 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"}`}
920 >
921 <PanelRight size={16} />
922 </button>
923 </Hint>
924 )}
925 </header>
926 );
927}
928
929function UsersStack({ count }: { count: number }) {
930 return (
931 <>
932 <Users size={14} />
933 {count}
934 </>
935 );
936}
937
938function StarButton({ starred, onClick }: { starred: boolean; onClick: () => void }) {
939 return (
940 <Hint label={starred ? "Unstar" : "Star: keep it at the top of the sidebar"}>
941 <button
942 type="button"
943 aria-label={starred ? "Unstar" : "Star"}
944 aria-pressed={starred}
945 onClick={onClick}
946 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"}`}
947 >
948 <Star size={15} fill={starred ? "currentColor" : "none"} />
949 </button>
950 </Hint>
951 );
952}
953
954/** How a conversation begins, above its first message. */
955function ConversationStart({ data, others }: { data: Loaded; others: MemberProfile[] }) {
956 const channel = data.channel;
957 if (channel.kind === "dm") {
958 const agent = others.length === 1 && others[0]!.kind === "agent" ? others[0]! : null;
959 return (
960 <div className="px-2 pb-6 sm:px-3">
961 <div className="flex -space-x-2">
962 {others.slice(0, 4).map((m) => (
963 <span key={m.id} className="rounded-full ring-4 ring-bg">
964 <MemberAvatar member={m} size={56} />
965 </span>
966 ))}
967 </div>
968 <h2 className="mt-3 flex items-center gap-2 text-xl font-semibold tracking-tight">
969 {others.map(shownName).join(", ") || "Just you"}
970 {agent && <AgentPill />}
971 </h2>
972 <p className="mt-1 max-w-xl text-sm text-muted">
973 {agent
974 ? `${agent.role ? `${agent.role}. ` : ""}Ask a question or give it a job: it answers here, and opens a task when the work needs one.`
975 : "This is the start of your conversation."}
976 </p>
977 </div>
978 );
979 }
980 return (
981 <div className="px-2 pb-6 sm:px-3">
982 <span className="flex size-12 items-center justify-center rounded-xl bg-raised text-muted">
983 {channel.private ? <Lock size={22} /> : <Hash size={24} />}
984 </span>
985 <h2 className="mt-3 text-xl font-semibold tracking-tight">Welcome to #{channel.name}</h2>
986 <p className="mt-1 max-w-xl text-sm text-muted">{channel.topic ?? "This is the very beginning of the channel."}</p>
987 </div>
988 );
989}
990
991function DayRule({ label }: { label: string }) {
992 return (
993 <div role="separator" aria-label={label} className="relative my-3 flex items-center justify-center">
994 <span aria-hidden="true" className="absolute inset-x-2 top-1/2 h-px bg-line" />
995 <span className="relative rounded-full border border-line bg-bg px-3 py-0.5 text-[0.6875rem] font-medium text-muted" suppressHydrationWarning>
996 {label}
997 </span>
998 </div>
999 );
1000}
1001
1002/** 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)1003/**
1004 * Someone's profile beside the conversation, opened from their card: who
1005 * they are, their teams and local time, and what they said here lately.
1006 * The conversation stays where it was.
1007 */
1008function ProfilePanel({
1009 slug,
1010 username,
1011 messages,
1012 zone,
1013 context,
1014}: {
1015 slug: string;
1016 username: string;
1017 messages: ShownMessage[];
1018 zone: string | undefined;
1019 context: TextContext;
1020}) {
1021 const [card, setCard] = useState<PersonCard | null | undefined>(undefined);
1022 useEffect(() => {
1023 let live = true;
1024 setCard(undefined);
1025 void personCard(slug, username).then((value) => {
1026 if (live) setCard(value);
1027 });
1028 return () => {
1029 live = false;
1030 };
1031 }, [slug, username]);
1032 const theirs = messages
1033 .filter((m) => m.author.kind === "user" && m.author.name === username && !m.deleted_at && m.body)
1034 .slice(-5)
1035 .reverse();
1036 const time = card ? localTime(card.timezone, Date.now()) : null;
1037 return (
1038 <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 managers1039 <WithPresence person={{ username }} size={88}>
1040 <Avatar name={username} image={card?.avatar ?? null} size={88} />
1041 </WithPresence>
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar1042 <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)1043 <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 calendar1044 @{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)1045 {card?.pronouns ? <span className="font-sans"> · {card.pronouns}</span> : null}
1046 </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 managers1047 <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)1048 {card?.bio && <p className="mt-3 text-sm leading-relaxed text-fg-soft">{card.bio}</p>}
1049 <dl className="mt-4 space-y-2 text-sm">
1050 {card && card.teams.length > 0 && (
1051 <div className="flex gap-3">
1052 <dt className="w-20 shrink-0 text-faint">Teams</dt>
1053 <dd className="text-fg-soft">{card.teams.map((team) => team.name).join(", ")}</dd>
1054 </div>
1055 )}
1056 {time && (
1057 <div className="flex gap-3">
1058 <dt className="w-20 shrink-0 text-faint">Local time</dt>
1059 <dd className="text-fg-soft" suppressHydrationWarning>
1060 {time}
1061 </dd>
1062 </div>
1063 )}
1064 {card?.location && (
1065 <div className="flex gap-3">
1066 <dt className="w-20 shrink-0 text-faint">Location</dt>
1067 <dd className="text-fg-soft">{card.location}</dd>
1068 </div>
1069 )}
1070 {card && card.workspaces.length > 0 && (
1071 <div className="flex gap-3">
1072 <dt className="w-20 shrink-0 text-faint">Workspaces</dt>
1073 <dd className="text-fg-soft">{card.workspaces.map((w) => w.name).join(", ")}</dd>
1074 </div>
1075 )}
1076 </dl>
1077 <h4 className="mt-6 mb-2 text-xs font-medium text-faint">Lately in this conversation</h4>
1078 {theirs.length === 0 ? (
1079 <p className="text-sm text-muted">Nothing here yet.</p>
1080 ) : (
1081 <ul className="space-y-3">
1082 {theirs.map((m) => (
1083 <li key={m.id} className="rounded-lg border border-line bg-surface px-3 py-2.5">
1084 <p className="mb-1 text-[0.6875rem] text-faint" suppressHydrationWarning>
1085 {clock(m.created_at, zone)}
1086 </p>
1087 <div className="line-clamp-4 text-sm">
1088 <MessageText body={m.body} context={context} />
1089 </div>
1090 </li>
1091 ))}
1092 </ul>
1093 )}
1094 <Link to={`/u/${username}`} className="mt-6 inline-block text-sm text-accent hover:underline">
1095 Open their full profile
1096 </Link>
1097 </div>
1098 );
1099}
1100
1101/** An agent's title ("QA Engineer"), when the chat service sends it with the member. */
1102function titleOf(member: MemberProfile): string | null {
1103 return (member as MemberProfile & { title?: string | null }).title ?? null;
1104}
1105
1106/** A long press (half a second, without moving) on a touch screen. */
1107function useLongPress(onLongPress?: () => void) {
1108 const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
1109 const cancel = () => {
1110 if (timer.current) clearTimeout(timer.current);
1111 timer.current = null;
1112 };
1113 if (!onLongPress) return {};
1114 return {
1115 onTouchStart: () => {
1116 cancel();
1117 timer.current = setTimeout(() => {
1118 timer.current = null;
1119 if (typeof navigator !== "undefined" && "vibrate" in navigator) navigator.vibrate?.(8);
1120 onLongPress();
1121 }, 480);
1122 },
1123 onTouchMove: cancel,
1124 onTouchEnd: cancel,
1125 onTouchCancel: cancel,
1126 onContextMenu: (event: React.MouseEvent) => {
1127 // A phone's own long-press menu (copy, look up) would cover ours.
1128 if (window.matchMedia("(pointer: coarse)").matches) event.preventDefault();
1129 },
1130 };
1131}
1132
1133/** A long-pressed message's actions, in a sheet from the bottom: reply, copy, and for your own, edit and delete. */
1134function MessageActions({
1135 message,
1136 mine,
1137 onClose,
1138 onThread,
Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread1139 onCopyLink,
1140 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)1141 onEdit,
1142 onDelete,
1143}: {
1144 message: ShownMessage | null;
1145 mine: boolean;
1146 onClose: () => void;
1147 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 thread1148 onCopyLink: (message: ShownMessage) => void;
1149 /** Absent where the person cannot post (not joined, archived). */
1150 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)1151 onEdit: (message: ShownMessage) => void;
1152 onDelete: (message: ShownMessage) => void;
1153}) {
1154 const [confirm, setConfirm] = useState(false);
1155 useEffect(() => setConfirm(false), [message]);
1156 const act = (run: () => void) => () => {
1157 run();
1158 onClose();
1159 };
1160 return (
1161 <BottomSheet open={message != null} onOpenChange={(open) => !open && onClose()} title="Message actions">
1162 {message && (
1163 <>
1164 <p className="mt-1 mb-2 line-clamp-2 px-3 text-sm text-muted">
1165 <span className="font-medium text-fg-soft">{shownName(message.author)}:</span> {message.body || message.card?.title}
1166 </p>
1167 {!message.pending && <QuickReactions messageId={message.id} onDone={onClose} />}
1168 {!message.thread_root && (
1169 <SheetRow icon={<MessageSquareText />} onClick={act(() => onThread(message.id))}>
1170 Reply in thread
1171 </SheetRow>
1172 )}
1173 <SheetRow icon={<Copy />} onClick={act(() => void navigator.clipboard?.writeText(message.body || message.card?.title || ""))}>
1174 Copy text
1175 </SheetRow>
Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread1176 {!message.pending && (
1177 <SheetRow icon={<Link2 />} onClick={act(() => onCopyLink(message))}>
1178 Copy link to thread
1179 </SheetRow>
1180 )}
1181 {!message.pending && onWriteUp && (
1182 <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.1183 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 thread1184 </SheetRow>
1185 )}
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)1186 {mine && message.kind === "text" && (
1187 <SheetRow icon={<Pencil />} onClick={act(() => onEdit(message))}>
1188 Edit
1189 </SheetRow>
1190 )}
1191 {mine &&
1192 (confirm ? (
1193 <SheetRow icon={<Trash2 className="text-danger" />} onClick={act(() => onDelete(message))}>
1194 <span className="text-danger">Delete for everyone</span>
1195 </SheetRow>
1196 ) : (
1197 <SheetRow icon={<Trash2 />} onClick={() => setConfirm(true)}>
1198 Delete
1199 </SheetRow>
1200 ))}
1201 </>
1202 )}
1203 </BottomSheet>
1204 );
1205}
1206
Chat and workspace agents: channels, DMs and named agents you talk to1207function MessageRow({
1208 message,
1209 head,
1210 zone,
1211 context,
1212 code,
1213 slug,
1214 onThread,
1215 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)1216 onLongPress,
Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread1217 onCopyLink,
1218 onWriteUp,
Chat and workspace agents: channels, DMs and named agents you talk to1219 threadOpen,
1220 inThread,
Merge main into Artifacts Phase 21221 onEdit,
1222 editor,
Chat and workspace agents: channels, DMs and named agents you talk to1223}: {
Merge main into Artifacts Phase 21224 /** Your own message: "Edit message" in the "⋯", and the composer in its place while editing. */
1225 onEdit?: () => void;
1226 editor?: 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)1227 /** A phone: a long press opens the message's actions. */
1228 onLongPress?: () => void;
Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread1229 /** The hover toolbar's "⋯": copy the link to its thread, write it up in Docs. */
1230 onCopyLink?: () => void;
1231 onWriteUp?: () => void;
Chat and workspace agents: channels, DMs and named agents you talk to1232 message: ShownMessage;
1233 head: boolean;
1234 zone: string | undefined;
1235 context: TextContext;
1236 code: boolean;
1237 slug: string;
1238 onThread?: () => void;
1239 onRetry: () => void;
1240 threadOpen?: boolean;
1241 inThread?: boolean;
1242}) {
1243 const author = message.author;
1244 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)1245 const press = useLongPress(onLongPress);
Chat and workspace agents: channels, DMs and named agents you talk to1246 return (
1247 <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)1248 {...press}
Chat and workspace agents: channels, DMs and named agents you talk to1249 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)1250 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 to1251 threadOpen ? "bg-accent/[0.06] hover:bg-accent/[0.08]" : ""
1252 }`}
1253 >
1254 <div className="w-9 shrink-0">
1255 {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)1256 <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 to1257 <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)1258 </MemberCard>
Chat and workspace agents: channels, DMs and named agents you talk to1259 ) : (
1260 <time
1261 dateTime={message.created_at}
1262 suppressHydrationWarning
1263 className="mt-1 block text-right text-[0.625rem] leading-5 text-faint tabular-nums opacity-0 group-hover/message:opacity-100"
1264 >
1265 {time.replace(/\s?[AP]M$/, "")}
1266 </time>
1267 )}
1268 </div>
1269 <div className="min-w-0 grow">
1270 {head && (
1271 <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)1272 <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 to1273 {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)1274 </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 managers1275 {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 to1276 {author.kind === "agent" && <AgentPill className="self-center" />}
1277 <time dateTime={message.created_at} suppressHydrationWarning className="shrink-0 text-xs text-faint tabular-nums">
1278 {time}
1279 </time>
1280 </div>
1281 )}
1282 <div className={message.pending && !message.failed ? "opacity-60" : undefined}>
Merge main into Artifacts Phase 21283 {editor ? <div className="py-1">{editor}</div> : message.body && <MessageText body={message.body} context={context} />}
1284 {!editor && 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 calendar1285 {message.card && (
1286 <CardBox
1287 card={message.card}
1288 slug={slug}
1289 code={code}
1290 channelId={message.channel_id}
1291 messageId={message.id}
1292 inert={!!message.pending || !!message.deleted_at}
1293 />
1294 )}
Chat and workspace agents: channels, DMs and named agents you talk to1295 {message.edited_at && <span className="text-[0.6875rem] text-faint"> (edited)</span>}
1296 </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)1297 {!message.pending && !message.deleted_at && <ReactionBar messageId={message.id} reactions={message.reactions} />}
Chat and workspace agents: channels, DMs and named agents you talk to1298 {message.failed && (
1299 <p className="mt-0.5 text-xs text-danger">
1300 Not sent.{" "}
1301 <button type="button" onClick={onRetry} className="font-medium underline underline-offset-2 hover:text-fg">
1302 Try again
1303 </button>
1304 </p>
1305 )}
1306 {!inThread && message.reply_count > 0 && onThread && (
1307 <button
1308 type="button"
1309 onClick={onThread}
1310 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"
1311 >
1312 <MessageSquareText size={14} />
1313 {message.reply_count} {message.reply_count === 1 ? "reply" : "replies"}
1314 {message.last_reply_at && (
1315 <span className="font-normal text-faint" suppressHydrationWarning>
1316 Last reply {clock(message.last_reply_at, zone)}
1317 </span>
1318 )}
1319 </button>
1320 )}
1321 </div>
1322 {!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)1323 <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">
1324 <AddReaction messageId={message.id} />
Chat and workspace agents: channels, DMs and named agents you talk to1325 <Hint label="Reply in thread">
1326 <button
1327 type="button"
1328 aria-label="Reply in thread"
1329 onClick={onThread}
1330 className="flex size-7 items-center justify-center rounded-md text-muted hover:bg-raised hover:text-fg"
1331 >
1332 <MessageSquareText size={15} />
1333 </button>
1334 </Hint>
Merge main into Artifacts Phase 21335 {onCopyLink && <MoreActions onCopyLink={onCopyLink} onWriteUp={onWriteUp} onEdit={onEdit} />}
Chat and workspace agents: channels, DMs and named agents you talk to1336 </div>
1337 )}
1338 </article>
1339 );
1340}
1341
Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread1342/** A message's "⋯" in its hover toolbar. */
Merge main into Artifacts Phase 21343function MoreActions({ onCopyLink, onWriteUp, onEdit }: { onCopyLink: () => void; onWriteUp?: () => void; onEdit?: () => void }) {
Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread1344 return (
1345 <DropdownMenu>
1346 <Hint label="More actions">
1347 <DropdownMenuTrigger
1348 aria-label="More actions"
1349 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"
1350 >
1351 <Ellipsis size={15} />
1352 </DropdownMenuTrigger>
1353 </Hint>
1354 <DropdownMenuContent align="end">
Merge main into Artifacts Phase 21355 {onEdit && (
1356 <DropdownMenuItem onSelect={onEdit}>
1357 <Pencil />
1358 Edit message
1359 </DropdownMenuItem>
1360 )}
Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread1361 <ThreadMenuItems onCopyLink={onCopyLink} onWriteUp={onWriteUp} />
1362 </DropdownMenuContent>
1363 </DropdownMenu>
1364 );
1365}
1366
1367function ThreadMenuItems({ onCopyLink, onWriteUp }: { onCopyLink: () => void; onWriteUp?: () => void }) {
1368 return (
1369 <>
1370 <DropdownMenuItem onSelect={onCopyLink}>
1371 <Link2 />
1372 Copy link to thread
1373 </DropdownMenuItem>
1374 {onWriteUp && (
1375 <DropdownMenuItem onSelect={onWriteUp}>
1376 <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.1377 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 thread1378 </DropdownMenuItem>
1379 )}
1380 </>
1381 );
1382}
1383
1384/** The thread panel's "⋯", in its header. */
1385function ThreadMenu({ onCopyLink, onWriteUp }: { onCopyLink: () => void; onWriteUp?: () => void }) {
1386 return (
1387 <DropdownMenu>
1388 <Hint label="Thread actions">
1389 <DropdownMenuTrigger
1390 aria-label="Thread actions"
1391 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"
1392 >
1393 <Ellipsis size={16} />
1394 </DropdownMenuTrigger>
1395 </Hint>
1396 <DropdownMenuContent align="end">
1397 <ThreadMenuItems onCopyLink={onCopyLink} onWriteUp={onWriteUp} />
1398 </DropdownMenuContent>
1399 </DropdownMenu>
1400 );
1401}
1402
Chat and workspace agents: channels, DMs and named agents you talk to1403/** "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)1404/** What an agent working on a reply says it is doing: one phrase per agent, so each sounds like itself. */
1405const 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"];
1406
1407function agentDoing(id: string): string {
1408 let hash = 0;
1409 for (const char of id) hash = (hash * 31 + char.charCodeAt(0)) | 0;
1410 return AGENT_DOING[Math.abs(hash) % AGENT_DOING.length]!;
1411}
1412
Chat and workspace agents: channels, DMs and named agents you talk to1413function TypingLine({ members }: { members: MemberProfile[] }) {
1414 const names = members.map(shownName);
1415 const who = names.length === 1 ? names[0] : names.length === 2 ? `${names[0]} and ${names[1]}` : names.length > 2 ? "Several people" : "";
1416 return (
1417 <div aria-live="polite" className="flex h-6 items-center gap-1.5 px-1 text-xs text-muted">
1418 {who && (
1419 <>
1420 <span aria-hidden="true" className="flex gap-0.5">
1421 {[0, 1, 2].map((i) => (
1422 <span key={i} className="size-1 animate-bounce rounded-full bg-accent" style={{ animationDelay: `${i * 140}ms` }} />
1423 ))}
1424 </span>
1425 <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)1426 <span className="font-medium text-fg-soft">{who}</span>{" "}
1427 {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 to1428 </span>
1429 </>
1430 )}
1431 </div>
1432 );
1433}
1434
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)1435/**
1436 * A panel beside the conversation: a thread, or the details. On a phone it
1437 * is a screen of its own, pushed over the conversation, as tall as what
1438 * the keyboard leaves.
1439 */
Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread1440function 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)1441 const swipe = useSwipeBack(onClose);
Chat and workspace agents: channels, DMs and named agents you talk to1442 return (
1443 <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)1444 {...swipe}
Chat and workspace agents: channels, DMs and named agents you talk to1445 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)1446 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 to1447 >
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)1448 <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">
1449 <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">
1450 <ChevronLeft size={22} />
Chat and workspace agents: channels, DMs and named agents you talk to1451 </button>
1452 <h2 className="text-[0.9375rem] font-semibold">{title}</h2>
1453 {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 thread1454 <div className="ml-auto flex shrink-0 items-center gap-1">
1455 {menu}
1456 <button type="button" onClick={onClose} aria-label="Close" className="hidden rounded-md p-1 text-faint hover:bg-raised hover:text-fg lg:block">
1457 <X size={16} />
1458 </button>
1459 </div>
Chat and workspace agents: channels, DMs and named agents you talk to1460 </div>
1461 <div className="flex min-h-0 grow flex-col">{children}</div>
1462 </aside>
1463 );
1464}
1465
1466function ThreadPanel({
1467 root,
1468 replies,
1469 zone,
1470 context,
1471 code,
1472 slug,
1473 people,
1474 joined,
1475 onSend,
1476 onRetry,
Chat controls, public profiles, shadcn selects, and no Docs tab in a project1477 onTyping,
Chat and workspace agents: channels, DMs and named agents you talk to1478 draftKey,
1479}: {
1480 root: ShownMessage | null;
1481 replies: ShownMessage[] | null;
1482 zone: string | undefined;
1483 context: TextContext;
1484 code: boolean;
1485 slug: string;
1486 people: Mentionable[];
1487 joined: boolean;
1488 onSend: (body: string) => void;
1489 onRetry: (message: ShownMessage) => void;
Chat controls, public profiles, shadcn selects, and no Docs tab in a project1490 /** Says "is typing" to the others in the channel, as the main composer does. */
1491 onTyping: () => void;
Chat and workspace agents: channels, DMs and named agents you talk to1492 draftKey: string;
1493}) {
1494 const rows = useMemo(() => timeline(replies ?? [], new Date(), zone).filter((row) => row.kind === "message"), [replies, zone]);
1495 return (
1496 <>
1497 <div className="min-h-0 grow overflow-y-auto px-1 py-3 [scrollbar-width:thin]">
1498 {root ? (
1499 <MessageRow message={root} head zone={zone} context={context} code={code} slug={slug} onRetry={() => onRetry(root)} inThread />
1500 ) : (
Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread1501 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 to1502 )}
1503 <div className="my-2 flex items-center gap-3 px-4 text-xs text-faint">
1504 {replies == null ? "Loading replies…" : `${replies.filter((r) => !r.deleted_at).length} ${replies.length === 1 ? "reply" : "replies"}`}
1505 <span className="h-px grow bg-line" />
1506 </div>
1507 {rows.map((row) =>
1508 row.kind === "message" ? (
1509 <MessageRow
1510 key={row.key}
1511 message={row.message}
1512 head={row.head}
1513 zone={zone}
1514 context={context}
1515 code={code}
1516 slug={slug}
1517 onRetry={() => onRetry(row.message)}
1518 inThread
1519 />
1520 ) : null,
1521 )}
1522 </div>
1523 {joined && (
1524 <div className="shrink-0 p-3">
Chat controls, public profiles, shadcn selects, and no Docs tab in a project1525 <Composer draftKey={draftKey} placeholder="Reply…" people={people} onSend={onSend} onTyping={onTyping} compact autoFocus />
1526 </div>
1527 )}
1528 </>
1529 );
1530}
1531
1532/**
1533 * A channel's name, topic and visibility, and changing them: any member
1534 * edits the topic; renaming and archiving are for whoever the workspace's
1535 * chat settings allow (`canManage`). #general keeps its name and stays.
1536 */
1537function ChannelAbout({
1538 channel,
1539 joined,
1540 canManage,
1541 onUpdate,
1542}: {
1543 channel: Channel;
1544 joined: boolean;
1545 canManage: boolean;
1546 onUpdate: (change: ChannelChange) => Promise<Result<unknown>>;
1547}) {
1548 const [editing, setEditing] = useState<"name" | "topic" | null>(null);
1549 const [value, setValue] = useState("");
1550 const [error, setError] = useState<string | null>(null);
1551 const [busy, setBusy] = useState(false);
1552 const [confirm, setConfirm] = useState(false);
1553 const general = channel.name === "general";
1554 const archived = !!channel.archived_at;
1555 const mayRename = canManage && !general && !archived;
1556 const mayTopic = joined && !archived;
1557 const save = async (change: ChannelChange) => {
1558 setBusy(true);
1559 setError(null);
1560 const done = await onUpdate(change);
1561 setBusy(false);
1562 if (!done.ok) return setError(done.error.message);
1563 setEditing(null);
1564 setConfirm(false);
1565 };
1566 const start = (field: "name" | "topic") => {
1567 setEditing(field);
1568 setValue(field === "name" ? (channel.name ?? "") : (channel.topic ?? ""));
1569 setError(null);
1570 };
1571 const edit = (field: "name" | "topic") => (
1572 <form
1573 className="mt-1.5 space-y-2"
1574 onSubmit={(event) => {
1575 event.preventDefault();
1576 void save(field === "name" ? { name: value } : { topic: value });
1577 }}
1578 >
1579 <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">
1580 {field === "name" && (channel.private ? <Lock size={13} className="text-faint" /> : <Hash size={13} className="text-faint" />)}
1581 <input
1582 autoFocus
1583 value={value}
1584 onChange={(event) => setValue(event.target.value)}
1585 onKeyDown={(event) => event.key === "Escape" && setEditing(null)}
1586 maxLength={field === "name" ? 80 : 250}
1587 placeholder={field === "name" ? "channel-name" : "What this channel is for"}
1588 aria-label={field === "name" ? "Channel name" : "Topic"}
1589 autoComplete="off"
1590 data-1p-ignore
1591 className="min-w-0 grow bg-transparent text-sm outline-none placeholder:text-faint"
1592 />
1593 </span>
1594 <div className="flex justify-end gap-1.5">
1595 <button type="button" onClick={() => setEditing(null)} className="h-7 rounded-md px-2.5 text-xs text-muted hover:bg-raised hover:text-fg">
1596 Cancel
1597 </button>
1598 <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">
1599 {busy ? "Saving…" : "Save"}
1600 </button>
1601 </div>
1602 </form>
1603 );
1604 const editButton = (field: "name" | "topic", label: string) => (
1605 <button
1606 type="button"
1607 onClick={() => start(field)}
1608 aria-label={label}
1609 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"
1610 >
1611 Edit
1612 </button>
1613 );
1614 return (
1615 <>
1616 <section className="group/about rounded-xl border border-line bg-surface p-3.5">
1617 <div className="flex items-center justify-between">
1618 <h3 className="text-xs font-medium text-faint">Name</h3>
1619 {mayRename && editing !== "name" && editButton("name", "Rename channel")}
Chat and workspace agents: channels, DMs and named agents you talk to1620 </div>
Chat controls, public profiles, shadcn selects, and no Docs tab in a project1621 {editing === "name" ? edit("name") : <p className="mt-1 text-sm text-fg-soft">#{channel.name}</p>}
1622 <div className="mt-3 flex items-center justify-between">
1623 <h3 className="text-xs font-medium text-faint">Topic</h3>
1624 {mayTopic && editing !== "topic" && editButton("topic", "Edit topic")}
1625 </div>
1626 {editing === "topic" ? edit("topic") : <p className="mt-1 text-sm text-fg-soft">{channel.topic || "No topic yet."}</p>}
1627 <h3 className="mt-3 text-xs font-medium text-faint">Visibility</h3>
1628 <p className="mt-1 flex items-center gap-1.5 text-sm text-fg-soft">
1629 {channel.private ? <Lock size={13} /> : <Hash size={13} />}
1630 {channel.private ? "Private: only its members can find and read it" : "Public: anyone in the workspace can read and join"}
1631 </p>
1632 {error && <p className="mt-2 text-xs text-danger">{error}</p>}
1633 </section>
1634 {canManage && !general && (
1635 <button
1636 type="button"
1637 onClick={() => (archived ? void save({ archived: false }) : setConfirm(true))}
1638 disabled={busy}
1639 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"
1640 >
1641 {archived ? <ArchiveRestore size={14} /> : <Archive size={14} />}
1642 {archived ? "Unarchive channel" : "Archive channel"}
1643 </button>
Chat and workspace agents: channels, DMs and named agents you talk to1644 )}
Chat controls, public profiles, shadcn selects, and no Docs tab in a project1645 <AlertDialog open={confirm} onOpenChange={setConfirm}>
1646 <AlertDialogContent>
1647 <AlertDialogHeader>
1648 <AlertDialogTitle>Archive #{channel.name}?</AlertDialogTitle>
1649 <AlertDialogDescription>
1650 It leaves everyone&apos;s sidebar and nobody can post in it. Its history stays readable, and it can be unarchived from Browse
1651 channels, Archived.
1652 </AlertDialogDescription>
1653 </AlertDialogHeader>
1654 <AlertDialogFooter>
1655 <AlertDialogCancel>Cancel</AlertDialogCancel>
1656 <AlertDialogAction
1657 onClick={(event) => {
1658 event.preventDefault();
1659 void save({ archived: true });
1660 }}
1661 disabled={busy}
1662 >
1663 {busy ? "Archiving…" : "Archive"}
1664 </AlertDialogAction>
1665 </AlertDialogFooter>
1666 </AlertDialogContent>
1667 </AlertDialog>
Chat and workspace agents: channels, DMs and named agents you talk to1668 </>
1669 );
1670}
1671
1672function InfoPanel({
1673 data,
1674 members,
1675 slug,
1676 people,
1677 onAdded,
1678 joined,
Chat controls, public profiles, shadcn selects, and no Docs tab in a project1679 canManage,
1680 onUpdate,
Chat and workspace agents: channels, DMs and named agents you talk to1681}: {
1682 data: Loaded;
1683 members: ChannelMember[];
1684 slug: string;
1685 people: Mentionable[];
1686 onAdded: (member: ChannelMember) => void;
1687 joined: boolean;
Chat controls, public profiles, shadcn selects, and no Docs tab in a project1688 /** Whether the viewer may rename, archive and unarchive it. */
1689 canManage: boolean;
1690 onUpdate: (change: ChannelChange) => Promise<Result<unknown>>;
Chat and workspace agents: channels, DMs and named agents you talk to1691}) {
1692 const channel = data.channel;
1693 const agents = members.filter((m) => m.member.kind === "agent");
1694 const humans = members.filter((m) => m.member.kind === "user");
1695 return (
1696 <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 project1697 {channel.kind === "channel" && <ChannelAbout channel={channel} joined={joined} canManage={canManage} onUpdate={onUpdate} />}
Chat and workspace agents: channels, DMs and named agents you talk to1698 {agents.length > 0 && (
1699 <section className="mt-5">
1700 <h3 className="mb-2 px-1 text-xs font-medium text-faint">Agents · {agents.length}</h3>
1701 <div className="flex flex-wrap gap-1.5">
1702 {agents.map(({ member }) => (
1703 <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)1704 <MemberCard
1705 member={member}
Chat and workspace agents: channels, DMs and named agents you talk to1706 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"
1707 >
1708 <MemberAvatar member={member} size={20} />
1709 {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)1710 </MemberCard>
Chat and workspace agents: channels, DMs and named agents you talk to1711 </Hint>
1712 ))}
1713 </div>
1714 </section>
1715 )}
1716 <section className="mt-5">
1717 <div className="mb-1 flex items-center justify-between px-1">
1718 <h3 className="text-xs font-medium text-faint">People · {humans.length}</h3>
1719 {joined && channel.kind === "channel" && <AddMember slug={slug} channelId={channel.id} people={people} members={members} onAdded={onAdded} />}
1720 </div>
1721 <ul className="space-y-px">
1722 {humans.map(({ member, role }) => (
1723 <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)1724 <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 managers1725 <MemberAvatar member={member} size={26} presence />
Chat and workspace agents: channels, DMs and named agents you talk to1726 <span className="min-w-0 grow truncate text-sm">
1727 {shownName(member)}
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar1728 {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 managers1729 <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 to1730 </span>
1731 {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)1732 </MemberCard>
Chat and workspace agents: channels, DMs and named agents you talk to1733 </li>
1734 ))}
1735 </ul>
1736 </section>
1737 </div>
1738 );
1739}
1740
1741/** Adding a person or an agent to the channel. An invite lets them read; it never grants write. */
1742function AddMember({
1743 slug,
1744 channelId,
1745 people,
1746 members,
1747 onAdded,
1748}: {
1749 slug: string;
1750 channelId: string;
1751 people: Mentionable[];
1752 members: ChannelMember[];
1753 onAdded: (member: ChannelMember) => void;
1754}) {
1755 const [open, setOpen] = useState(false);
1756 const [query, setQuery] = useState("");
1757 const [error, setError] = useState<string | null>(null);
1758 const send = useChatSend(slug);
1759 const chatData = useChatData();
1760 const inside = new Set(members.map((m) => `${m.member.kind}:${m.member.name.toLowerCase()}`));
1761 const q = query.trim().toLowerCase().replace(/^@/, "");
1762 const options = people
1763 .filter((p) => !inside.has(`${p.kind}:${p.name.toLowerCase()}`))
1764 .filter((p) => !q || p.name.toLowerCase().includes(q) || p.display_name.toLowerCase().includes(q))
1765 .slice(0, 8);
1766 const add = async (person: Mentionable) => {
1767 const agent = person.kind === "agent" ? chatData?.agents.find((a) => a.handle === person.name) : null;
1768 const key = agent ? `agent:${agent.id}` : `user:${person.name}`;
1769 setError(null);
1770 const done = await send({ intent: "invite", channel_id: channelId, member: key });
1771 if (!done.ok) return setError(done.error.message);
1772 onAdded({
1773 channel_id: channelId,
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar1774 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 to1775 role: "member",
1776 starred: false,
1777 muted: false,
1778 last_read_id: null,
1779 joined_at: new Date().toISOString(),
1780 });
1781 setOpen(false);
1782 };
1783 return (
1784 <Popover open={open} onOpenChange={setOpen}>
1785 <PopoverTrigger asChild>
1786 <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">
1787 <UserPlus size={13} />
1788 Add
1789 </button>
1790 </PopoverTrigger>
1791 <PopoverContent align="end" className="w-72 p-1.5">
1792 <input
1793 autoFocus
1794 value={query}
1795 onChange={(event) => setQuery(event.target.value)}
1796 placeholder="Add a person or an agent"
1797 aria-label="Add a person or an agent"
1798 autoComplete="off"
1799 data-1p-ignore
1800 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"
1801 />
1802 {options.length === 0 && <p className="px-2 py-3 text-xs text-faint">Everyone who matches is already here.</p>}
1803 {options.map((person) => (
1804 <button
1805 key={`${person.kind}:${person.name}`}
1806 type="button"
1807 onClick={() => void add(person)}
1808 className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm hover:bg-line"
1809 >
1810 <MemberAvatar member={person} size={20} />
1811 <span className="min-w-0 grow truncate">{person.display_name}</span>
1812 {person.kind === "agent" && <AgentPill />}
1813 </button>
1814 ))}
1815 {error && <p className="px-2 pt-1 text-xs text-danger">{error}</p>}
1816 <p className="border-t border-line px-2 pt-1.5 pb-0.5 text-[0.6875rem] leading-snug text-faint">
1817 Adding an agent lets it read this channel. It never grants it write access.
1818 </p>
1819 </PopoverContent>
1820 </Popover>
1821 );
1822}

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