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

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