Skip to content
1,478 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 {
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)2 ChevronLeft,
3 Copy,
4 Pencil,
5 Trash2,
Chat and workspace agents: channels, DMs and named agents you talk to6 BellOff,
7 CircleDot,
8 GitPullRequest,
9 Hash,
10 ListChecks,
11 Lock,
12 MessageSquareText,
13 PanelRight,
14 Rocket,
15 ShieldCheck,
16 Sparkles,
17 Star,
18 UserPlus,
19 Users,
20 X,
21} from "lucide-react";
22import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
23import { Link, useRouteLoaderData, useSearchParams } from "react-router";
24
25import { type ChannelMember, type ChatLiveEvent, type ChatMessage, type MemberProfile, type MessageCard, type Result, hasCodeAccess } from "@g1t/contracts";
26
27import { useChatData, useChatSend, useChatSidebar } from "./actions";
28import { Composer } from "./composer";
29import { type LiveState, useChatLive } from "./live";
30import { AgentPill, MemberAvatar } from "./marks";
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)31import { CardContext, MemberCard, type PersonCard, personCard } from "./profile-card";
32import { Avatar } from "../ui";
33import { localTime } from "../../lib/time-zone";
34import { BottomSheet, SheetRow, useBack, useSwipeBack } from "../mobile";
Chat and workspace agents: channels, DMs and named agents you talk to35import { MessageText, type TextContext } from "./text";
36import { Badge, type BadgeTone } from "../ui/badge";
37import { Hint } from "../ui/hint";
38import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
39import { type Mentionable, type ShownMessage, mergeMessages, shownName, timeline } from "../../lib/chat";
40import { 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)41import { conversationCache } from "./conversation-cache";
42// Reactions and the workspace's own emoji (components/emoji).
43import { EmojiProvider, useCustomEmoji } from "../emoji/context";
44import { AddReaction, QuickReactions, ReactionBar } from "../emoji/reaction-bar";
45import { useAddresses } from "../../lib/addresses";
46import { applyReactionEvent, keepMine, toggleReaction } from "../../lib/emoji";
Chat and workspace agents: channels, DMs and named agents you talk to47import type { ChannelData } from "../../routes/workspace/chat/channel";
48
49type Loaded = Extract<ChannelData, { unavailable: false }>;
50
51/** True once the page has hydrated: times and days are the reader's own from then on. */
52function useHydrated(): boolean {
53 const [hydrated, setHydrated] = useState(false);
54 useEffect(() => setHydrated(true), []);
55 return hydrated;
56}
57
58function clock(at: string, zone: string | undefined): string {
59 return new Date(at).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", timeZone: zone });
60}
61
62/** Whether the viewer uses Code in this workspace (docs/WORKSPACE.md, "Members without Code"). */
63export function useCodeAccess(slug: string): boolean {
64 const root = useRouteLoaderData("root") as { user?: { workspaces?: { slug: string; code_access?: boolean }[] } | null } | undefined;
65 return hasCodeAccess(root?.user?.workspaces?.find((m) => m.slug === slug) ?? null);
66}
67
68/** The project a channel of the same name is about, for a bare `#123`. */
69function useProjectNamed(name: string | null): string | null {
70 const root = useRouteLoaderData("root") as { shell?: { repos?: { name: string }[] } } | undefined;
71 if (!name) return null;
72 return root?.shell?.repos?.some((repo) => repo.name === name) ? name : null;
73}
74
75function newClientId(): string {
76 return `tmp-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
77}
78
79/** A conversation: its header, its messages, the box to write in, and its thread and details beside it. */
80export function ChannelView({ data }: { data: Loaded }) {
81 const chatData = useChatData();
82 const slug = chatData?.slug ?? "";
83 const me = chatData?.me ?? null;
84 const send = useChatSend(slug);
85 const { sidebar, refresh } = useChatSidebar();
86 const hydrated = useHydrated();
87 const zone = hydrated ? undefined : "UTC";
88 const code = useCodeAccess(slug);
89 const project = useProjectNamed(data.channel.kind === "channel" ? data.channel.name : null);
90 const [params, setParams] = useSearchParams();
91 const threadId = params.get("thread");
92
93 const [messages, setMessages] = useState<ShownMessage[]>(() => mergeMessages([], data.messages));
94 const [older, setOlder] = useState<string | null>(data.older);
95 const [loadingOlder, setLoadingOlder] = useState(false);
96 const [thread, setThread] = useState<ShownMessage[] | null>(null);
97 const [typing, setTyping] = useState<Map<string, { member: MemberProfile; until: number }>>(new Map());
98 const [joined, setJoined] = useState(data.joined);
99 const [members, setMembers] = useState<ChannelMember[]>(data.members);
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)100 // The server's answer behind a cached draw: merged in by id.
101 useEffect(() => {
102 const onFresh = (event: Event) => {
103 const { key, value } = (event as CustomEvent<{ key: string; value: ChannelData }>).detail;
104 if (key !== window.location.pathname || value.unavailable || value.channel.id !== data.channel.id) return;
105 setMessages((now) => mergeMessages(now, value.messages));
106 setMembers(value.members);
107 setJoined(value.joined);
108 };
109 window.addEventListener("g1t:chat-fresh", onFresh);
110 return () => window.removeEventListener("g1t:chat-fresh", onFresh);
111 }, [data.channel.id]);
112 // The cache follows what is on screen, so coming back shows the latest.
113 useEffect(() => {
114 const path = window.location.pathname;
115 const timer = setTimeout(
116 () => conversationCache.set(path, { ...data, messages: messages.filter((m) => !m.pending).slice(-80), members, joined }),
117 400,
118 );
119 return () => clearTimeout(timer);
120 }, [data, messages, members, joined]);
Chat and workspace agents: channels, DMs and named agents you talk to121 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)122 // A phone: back to the list, by the chevron or a swipe from the edge;
123 // and a long-pressed message's actions.
124 const back = useBack(`/${chatData?.slug ?? ""}/-/chat`);
125 const swipe = useSwipeBack(back);
126 const [actions, setActions] = useState<ShownMessage | null>(null);
127 // Someone's profile, opened from their card, beside the conversation.
128 const [profile, setProfile] = useState<string | null>(null);
129 const [editing, setEditing] = useState<{ id: string; body: string } | null>(null);
Chat and workspace agents: channels, DMs and named agents you talk to130 const scroller = useRef<HTMLDivElement>(null);
131 const stick = useRef(true);
132
133 const entry = sidebar?.entries.find((e) => e.channel.id === data.channel.id);
134 const starred = entry?.starred ?? members.find((m) => m.member.kind === "user" && m.member.id === me?.id)?.starred ?? false;
135 const muted = entry?.muted ?? false;
136 const others = members.filter((m) => !(m.member.kind === "user" && m.member.id === me?.id)).map((m) => m.member);
137 const isDm = data.channel.kind === "dm";
138
139 // The details panel: open by default on a wide screen, as last left.
140 useEffect(() => {
141 try {
142 const saved = localStorage.getItem("chat-info");
143 setInfo(saved ? saved === "1" : window.matchMedia("(min-width: 1280px)").matches && !isDm);
144 } catch {
145 setInfo(false);
146 }
147 }, [isDm]);
148 const toggleInfo = () =>
149 setInfo((now) => {
150 try {
151 localStorage.setItem("chat-info", now ? "0" : "1");
152 } catch {
153 // Not kept: fine.
154 }
155 return !now;
156 });
157
158 const people = useMemo<Mentionable[]>(
159 () => [
160 ...(chatData?.agents ?? []).map((agent) => ({ kind: "agent" as const, name: agent.handle, display_name: agent.display_name, avatar: agent.avatar, role: agent.role })),
161 ...(chatData?.people ?? []).filter((p) => p.name !== me?.username),
162 ],
163 [chatData, me?.username],
164 );
165 const context = useMemo<TextContext>(
166 () => ({
167 slug,
168 me: me?.username ?? null,
169 agents: new Set((chatData?.agents ?? []).map((a) => a.handle.toLowerCase())),
170 channels: new Set((sidebar?.entries ?? []).filter((e) => e.channel.kind === "channel").map((e) => e.channel.name ?? "")),
171 project,
172 codeLink: code ? undefined : (href: string) => (/^\/[^/]+\/(?!-\/)[^/]+/.test(href) ? codeAccessPath(slug, href) : href),
173 }),
174 [slug, me?.username, chatData?.agents, sidebar?.entries, project, code],
175 );
176
177 // Keeps the newest message in view while the reader is at the bottom.
178 const toBottom = useCallback((smooth = false) => {
179 const element = scroller.current;
180 if (element) element.scrollTo({ top: element.scrollHeight, behavior: smooth ? "smooth" : "auto" });
181 }, []);
182 useEffect(() => toBottom(), [toBottom]);
183 useEffect(() => {
184 if (stick.current) toBottom(true);
185 }, [messages.length, typing.size, toBottom]);
186
187 // Read up to the newest message whenever it is in view.
188 const lastRead = useRef<string | null>(null);
189 const markRead = useCallback(() => {
190 if (!joined || document.visibilityState !== "visible") return;
191 const newest = [...messages].reverse().find((m) => !m.pending);
192 if (!newest || newest.id === lastRead.current) return;
193 lastRead.current = newest.id;
194 void send({ intent: "read", channel_id: data.channel.id, id: newest.id });
195 window.dispatchEvent(new CustomEvent("g1t:chat-read", { detail: data.channel.id }));
196 }, [messages, joined, send, data.channel.id]);
197 useEffect(() => {
198 const timer = setTimeout(markRead, 400);
199 document.addEventListener("visibilitychange", markRead);
200 window.addEventListener("focus", markRead);
201 return () => {
202 clearTimeout(timer);
203 document.removeEventListener("visibilitychange", markRead);
204 window.removeEventListener("focus", markRead);
205 };
206 }, [markRead]);
207
208 // The thread open beside the conversation, from `?thread=`.
209 const loadThread = useCallback(async () => {
210 if (!threadId) return setThread(null);
211 try {
212 const response = await fetch(`/${slug}/-/chat/api?channel=${encodeURIComponent(data.channel.id)}&thread=${encodeURIComponent(threadId)}`);
213 const result = (await response.json()) as Result<{ messages: ChatMessage[] }>;
214 // The page reaches back to the message the thread is under: shown above it, not among the replies.
215 setThread(result.ok ? mergeMessages([], result.value.messages.filter((m) => m.id !== threadId && !m.deleted_at)) : []);
216 } catch {
217 setThread([]);
218 }
219 }, [threadId, slug, data.channel.id]);
220 useEffect(() => {
221 setThread(null);
222 void loadThread();
223 }, [loadThread]);
224
225 // Reply counts: counted here only until the service's own count for that
226 // message arrives (a `message.updated` of it), which can come before or
227 // after the reply itself; never twice for one reply.
228 const serverCounted = useRef(new Set<string>());
229 const countedReplies = useRef(new Set<string>());
230 const countReply = useCallback((root: string, reply: string, at: string) => {
231 if (serverCounted.current.has(root) || countedReplies.current.has(reply)) return;
232 countedReplies.current.add(reply);
233 setMessages((now) => now.map((m) => (m.id === root ? { ...m, reply_count: m.reply_count + 1, last_reply_at: at } : m)));
234 }, []);
235 const latest = useRef(messages);
236 latest.current = messages;
237
238 // Everything that happens while the page is open.
239 const onEvent = useCallback(
240 (event: ChatLiveEvent) => {
241 if (event.type === "message.created" || event.type === "message.updated") {
242 const message = event.message;
243 if (message.channel_id !== data.channel.id) return;
244 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)245 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 to246 if (event.type === "message.created") countReply(message.thread_root, message.id, message.created_at);
247 } else {
248 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)249 setMessages((now) => mergeMessages(now, [keepMine(now, message)]));
Chat and workspace agents: channels, DMs and named agents you talk to250 }
251 // Whoever just spoke is no longer typing.
252 setTyping((now) => {
253 const key = `${message.author.kind}:${message.author.id}`;
254 if (!now.has(key)) return now;
255 const next = new Map(now);
256 next.delete(key);
257 return next;
258 });
259 } else if (event.type === "message.deleted") {
260 if (event.channel_id !== data.channel.id) return;
261 const gone = (m: ShownMessage) => (m.id === event.id ? { ...m, deleted_at: new Date().toISOString() } : m);
262 setMessages((now) => now.map(gone));
263 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)264 } else if (event.type === "reaction.added" || event.type === "reaction.removed") {
265 if (event.channel_id !== data.channel.id) return;
266 setMessages((now) => applyReactionEvent(now, event, me?.id ?? null));
267 setThread((now) => (now ? applyReactionEvent(now, event, me?.id ?? null) : now));
Chat and workspace agents: channels, DMs and named agents you talk to268 } else if (event.type === "typing") {
269 if (event.channel_id !== data.channel.id) return;
270 if (event.member.kind === "user" && event.member.id === me?.id) return;
271 setTyping((now) => new Map(now).set(`${event.member.kind}:${event.member.id}`, { member: event.member, until: new Date(event.until).getTime() }));
272 }
273 },
274 [data.channel.id, threadId, me?.id, countReply],
275 );
276 // Back after a drop: what was missed.
277 const onReconnect = useCallback(async () => {
278 const base = `/${slug}/-/chat/api?channel=${encodeURIComponent(data.channel.id)}`;
279 try {
280 // Everything after the newest message held, page by page, oldest first;
281 // what was deleted meanwhile comes back empty and is dropped.
282 let after = [...latest.current].reverse().find((m) => !m.pending)?.id ?? null;
283 for (let pages = 0; after && pages < 20; pages++) {
284 const response = await fetch(`${base}&after=${encodeURIComponent(after)}`);
285 const result = (await response.json()) as Result<{ messages: ChatMessage[]; newer?: string | null }>;
286 if (!result.ok) break;
287 const gone = new Set(result.value.messages.filter((m) => m.deleted_at).map((m) => m.id));
288 const kept = result.value.messages.filter((m) => !m.deleted_at && !m.thread_root);
289 setMessages((now) => mergeMessages(now.filter((m) => !gone.has(m.id)), kept));
290 after = result.value.newer ?? null;
291 }
292 // Edits and deletions to what was already on screen: the latest page again.
293 const response = await fetch(base);
294 const result = (await response.json()) as Result<{ messages: ChatMessage[] }>;
295 if (result.ok) {
296 const gone = new Set(result.value.messages.filter((m) => m.deleted_at).map((m) => m.id));
297 setMessages((now) => mergeMessages(now.filter((m) => !gone.has(m.id)), result.value.messages.filter((m) => !m.deleted_at)));
298 }
299 } catch {
300 // The next event, or the next return, catches up.
301 }
302 void loadThread();
303 refresh();
304 }, [slug, data.channel.id, loadThread, refresh]);
305 const live = useChatLive(slug, data.channel.id, onEvent, onReconnect);
306
307 // Typing shows until its time runs out.
308 useEffect(() => {
309 if (typing.size === 0) return;
310 const timer = setInterval(() => {
311 const now = Date.now();
312 setTyping((current) => {
313 const next = new Map([...current].filter(([, value]) => value.until > now));
314 return next.size === current.size ? current : next;
315 });
316 }, 1000);
317 return () => clearInterval(timer);
318 }, [typing.size]);
319
320 // Says "is typing" to the others, at most every few seconds.
321 const lastTyping = useRef(0);
322 const onTyping = useCallback(() => {
323 const now = Date.now();
324 if (now - lastTyping.current < 3000) return;
325 lastTyping.current = now;
326 live.send({ type: "typing", channel_id: data.channel.id });
327 }, [live, data.channel.id]);
328
329 const post = useCallback(
330 async (body: string, root: string | null) => {
331 if (!me) return;
332 const clientId = newClientId();
333 const author: MemberProfile = { kind: "user", id: me.id, name: me.username, display_name: me.username, avatar: me.avatar, role: null };
334 const pending: ShownMessage = {
335 id: clientId,
336 client_id: clientId,
337 pending: true,
338 channel_id: data.channel.id,
339 author,
340 kind: "text",
341 body,
342 card: null,
343 thread_root: root,
344 reply_count: 0,
345 last_reply_at: null,
346 created_at: new Date().toISOString(),
347 edited_at: null,
348 deleted_at: null,
349 };
350 const put = (list: ShownMessage[]) => mergeMessages(list, [pending]);
351 if (root) setThread((now) => put(now ?? []));
352 else {
353 stick.current = true;
354 setMessages(put);
355 }
356 const saved = await send<ChatMessage>({ intent: "post", channel_id: data.channel.id, body, thread_root: root });
357 const settle = (list: ShownMessage[]) =>
358 saved.ok
359 ? mergeMessages(list, [{ ...saved.value, client_id: clientId }])
360 : list.map((m) => (m.client_id === clientId ? { ...m, failed: true, pending: true } : m));
361 if (root) {
362 setThread((now) => (now ? settle(now) : now));
363 if (saved.ok) countReply(root, saved.value.id, saved.value.created_at);
364 } else setMessages(settle);
365 },
366 [me, send, data.channel.id, countReply],
367 );
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)368 // ── Reactions (components/emoji): shown at once, then as the service counts them.
369 const customs = useCustomEmoji(slug);
370 const { usercontent } = useAddresses();
371 const meProfile = useMemo<MemberProfile | null>(
372 () => (me ? { kind: "user", id: me.id, name: me.username, display_name: me.username, avatar: me.avatar, role: null } : null),
373 [me],
374 );
375 const react = useCallback(
376 async (messageId: string, emoji: string, on: boolean) => {
377 if (!meProfile) return;
378 const change = (to: boolean) => (list: ShownMessage[]) =>
379 list.map((m) => (m.id === messageId ? { ...m, reactions: toggleReaction(m.reactions ?? [], emoji, to, meProfile) } : m));
380 setMessages(change(on));
381 setThread((now) => (now ? change(on)(now) : now));
382 try {
383 const response = await fetch(`/${slug}/-/chat/api`, {
384 method: "POST",
385 headers: { "content-type": "application/json" },
386 body: JSON.stringify({ intent: on ? "react" : "unreact", channel_id: data.channel.id, message_id: messageId, emoji }),
387 });
388 const result = (await response.json()) as Result<ChatMessage["reactions"]>;
389 if (!result.ok) throw new Error(result.error.message);
390 const settle = (list: ShownMessage[]) => list.map((m) => (m.id === messageId ? { ...m, reactions: result.value } : m));
391 setMessages(settle);
392 setThread((now) => (now ? settle(now) : now));
393 } catch {
394 // Not saved: back as it was.
395 setMessages(change(!on));
396 setThread((now) => (now ? change(!on)(now) : now));
397 }
398 },
399 [meProfile, slug, data.channel.id],
400 );
401 const emojiContext = useMemo(
402 () => ({ customs, usercontent, me: meProfile, react: (id: string, emoji: string, on: boolean) => void react(id, emoji, on), manageHref: `/${slug}/-/emoji` }),
403 [customs, usercontent, meProfile, react, slug],
404 );
405
Chat and workspace agents: channels, DMs and named agents you talk to406 const retry = (message: ShownMessage) => {
407 const drop = (list: ShownMessage[]) => list.filter((m) => m.client_id !== message.client_id);
408 if (message.thread_root) setThread((now) => (now ? drop(now) : now));
409 else setMessages(drop);
410 void post(message.body, message.thread_root);
411 };
412
413 const loadOlder = async () => {
414 if (!older || loadingOlder) return;
415 setLoadingOlder(true);
416 const element = scroller.current;
417 const fromBottom = element ? element.scrollHeight - element.scrollTop : 0;
418 try {
419 const response = await fetch(`/${slug}/-/chat/api?channel=${encodeURIComponent(data.channel.id)}&before=${encodeURIComponent(older)}`);
420 const result = (await response.json()) as Result<{ messages: ChatMessage[]; older: string | null }>;
421 if (result.ok) {
422 stick.current = false;
423 setMessages((now) => mergeMessages(now, result.value.messages));
424 setOlder(result.value.older);
425 requestAnimationFrame(() => {
426 if (element) element.scrollTop = element.scrollHeight - fromBottom;
427 });
428 }
429 } finally {
430 setLoadingOlder(false);
431 }
432 };
433
434 const openThread = (id: string | null) => {
435 const next = new URLSearchParams(params);
436 if (id) next.set("thread", id);
437 else next.delete("thread");
438 setParams(next, { preventScrollReset: true, replace: !!threadId && !!id });
439 };
440
441 const toggleStar = async () => {
442 await send({ intent: "preferences", channel_id: data.channel.id, starred: !starred });
443 refresh();
444 };
445
446 const join = async () => {
447 const done = await send({ intent: "join", channel_id: data.channel.id });
448 if (done.ok) {
449 setJoined(true);
450 if (me) {
451 setMembers((now) => [
452 ...now,
453 {
454 channel_id: data.channel.id,
455 member: { kind: "user", id: me.id, name: me.username, display_name: me.username, avatar: me.avatar, role: null },
456 role: "member",
457 starred: false,
458 muted: false,
459 last_read_id: null,
460 joined_at: new Date().toISOString(),
461 },
462 ]);
463 }
464 }
465 };
466
467 const rows = useMemo(() => timeline(messages, new Date(), zone), [messages, zone]);
468 const typers = [...typing.values()].map((t) => t.member);
469 const name = data.channel.name ?? "";
470 const placeholder = isDm
471 ? `Message ${others.map(shownName).join(", ") || "yourself"}`
472 : `Message #${name}. @ a teammate or an agent`;
473 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)474 const cardContext = useMemo(
475 () => ({
476 slug,
477 agents: chatData?.agents ?? [],
478 onViewProfile: (username: string) => {
479 if (threadId) openThread(null);
480 setProfile(username);
481 },
482 }),
483 // eslint-disable-next-line react-hooks/exhaustive-deps
484 [slug, chatData?.agents, threadId],
485 );
Chat and workspace agents: channels, DMs and named agents you talk to486
487 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)488 // On a phone the conversation is the whole screen, as tall as what the
489 // keyboard leaves (components/mobile.tsx), so the composer sits on it.
490 <EmojiProvider value={emojiContext}>
491 <CardContext.Provider value={cardContext}>
492 <div
493 {...swipe}
494 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"
495 >
Chat and workspace agents: channels, DMs and named agents you talk to496 <section aria-label={data.title} className="flex min-w-0 grow flex-col">
497 <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)498 onBack={back}
Chat and workspace agents: channels, DMs and named agents you talk to499 data={data}
500 others={others}
501 starred={starred}
502 muted={muted}
503 memberCount={members.length}
504 live={live.state}
505 info={info}
506 onStar={() => void toggleStar()}
507 onInfo={toggleInfo}
508 />
509 <div
510 ref={scroller}
511 onScroll={(event) => {
512 const element = event.currentTarget;
513 stick.current = element.scrollHeight - element.scrollTop - element.clientHeight < 120;
514 }}
515 className="min-h-0 grow overflow-y-auto overscroll-contain [scrollbar-gutter:stable] [scrollbar-width:thin]"
516 >
517 <div className="mx-auto flex min-h-full max-w-[56rem] flex-col justify-end px-2 pt-6 pb-3 sm:px-4">
518 {older ? (
519 <div className="flex justify-center pb-4">
520 <button
521 type="button"
522 onClick={() => void loadOlder()}
523 className="rounded-full border border-line px-3 py-1 text-xs text-muted transition-colors hover:border-line-strong hover:text-fg"
524 >
525 {loadingOlder ? "Loading…" : "Load earlier messages"}
526 </button>
527 </div>
528 ) : (
529 <ConversationStart data={data} others={others} />
530 )}
531 {rows.map((row) =>
532 row.kind === "day" ? (
533 <DayRule key={row.key} label={row.label} />
534 ) : (
535 <MessageRow
536 key={row.key}
537 message={row.message}
538 head={row.head}
539 zone={zone}
540 context={context}
541 code={code}
542 slug={slug}
543 onThread={() => openThread(row.message.id)}
544 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)545 onLongPress={() => setActions(row.message)}
Chat and workspace agents: channels, DMs and named agents you talk to546 threadOpen={row.message.id === threadId}
547 />
548 ),
549 )}
550 </div>
551 </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)552 <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 to553 <div className="mx-auto max-w-[56rem]">
554 <TypingLine members={typers} />
555 {joined ? (
556 <Composer draftKey={data.channel.id} placeholder={placeholder} people={people} onSend={(body) => void post(body, null)} onTyping={onTyping} autoFocus />
557 ) : (
558 <div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-line bg-surface px-4 py-3">
559 <p className="text-sm text-muted">
560 You're reading <span className="font-medium text-fg">#{name}</span>. Join to post and get its notifications.
561 </p>
562 <button
563 type="button"
564 onClick={() => void join()}
565 className="inline-flex h-9 items-center rounded-md bg-accent px-3.5 text-sm font-medium text-bg hover:bg-accent-hover"
566 >
567 Join channel
568 </button>
569 </div>
570 )}
571 </div>
572 </div>
573 </section>
574 {threadId ? (
575 <SidePanel title="Thread" subtitle={isDm ? undefined : `#${name}`} onClose={() => openThread(null)}>
576 <ThreadPanel
577 root={rootMessage}
578 replies={thread}
579 zone={zone}
580 context={context}
581 code={code}
582 slug={slug}
583 people={people}
584 joined={joined}
585 onSend={(body) => void post(body, threadId)}
586 onRetry={retry}
587 draftKey={`${data.channel.id}:${threadId}`}
588 />
589 </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)590 ) : profile ? (
591 <SidePanel title="Profile" onClose={() => setProfile(null)}>
592 <ProfilePanel slug={slug} username={profile} messages={messages} zone={zone} context={context} />
593 </SidePanel>
Chat and workspace agents: channels, DMs and named agents you talk to594 ) : (
595 info && (
596 <SidePanel title={isDm ? "Details" : "About this channel"} onClose={toggleInfo}>
597 <InfoPanel data={data} members={members} slug={slug} people={people} onAdded={(m) => setMembers((now) => [...now, m])} joined={joined} />
598 </SidePanel>
599 )
600 )}
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)601 <MessageActions
602 message={actions}
603 mine={actions != null && actions.author.kind === "user" && actions.author.id === me?.id}
604 onClose={() => setActions(null)}
605 onThread={(id) => openThread(id)}
606 onEdit={(message) => setEditing({ id: message.id, body: message.body })}
607 onDelete={async (message) => {
608 // Gone at once; back if the service refuses.
609 setMessages((now) => now.map((m) => (m.id === message.id ? { ...m, deleted_at: new Date().toISOString() } : m)));
610 const done = await send({ intent: "remove", channel_id: data.channel.id, id: message.id });
611 if (!done.ok) setMessages((now) => now.map((m) => (m.id === message.id ? { ...m, deleted_at: null } : m)));
612 }}
613 />
614 <BottomSheet open={editing != null} onOpenChange={(open) => !open && setEditing(null)} title="Edit message">
615 {editing && (
616 <form
617 className="space-y-3 px-1 pt-1"
618 onSubmit={async (event) => {
619 event.preventDefault();
620 const body = editing.body.trim();
621 if (!body) return;
622 // Shown edited at once; put back if the service refuses.
623 const before = messages.find((m) => m.id === editing.id);
624 setMessages((now) => now.map((m) => (m.id === editing.id ? { ...m, body, edited_at: new Date().toISOString() } : m)));
625 setEditing(null);
626 const saved = await send<ChatMessage>({ intent: "edit", channel_id: data.channel.id, id: editing.id, body });
627 if (saved.ok) setMessages((now) => mergeMessages(now, [saved.value]));
628 else if (before) setMessages((now) => now.map((m) => (m.id === before.id ? before : m)));
629 }}
630 >
631 <h2 className="px-2 text-base font-semibold">Edit message</h2>
632 <textarea
633 autoFocus
634 value={editing.body}
635 onChange={(event) => setEditing({ ...editing, body: event.target.value })}
636 rows={4}
637 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"
638 />
639 <div className="flex gap-2">
640 <button type="button" onClick={() => setEditing(null)} className="h-11 grow rounded-xl border border-line text-[0.9375rem] font-medium active:bg-raised">
641 Cancel
642 </button>
643 <button type="submit" className="h-11 grow rounded-xl bg-accent text-[0.9375rem] font-medium text-bg active:bg-accent-hover">
644 Save
645 </button>
646 </div>
647 </form>
648 )}
649 </BottomSheet>
Chat and workspace agents: channels, DMs and named agents you talk to650 </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)651 </CardContext.Provider>
652 </EmojiProvider>
Chat and workspace agents: channels, DMs and named agents you talk to653 );
654}
655
656/** The top of a conversation: what it is, and its controls. */
657function 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)658 onBack,
Chat and workspace agents: channels, DMs and named agents you talk to659 data,
660 others,
661 starred,
662 muted,
663 memberCount,
664 live,
665 info,
666 onStar,
667 onInfo,
668}: {
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)669 /** A phone: back to the list of conversations. */
670 onBack: () => void;
Chat and workspace agents: channels, DMs and named agents you talk to671 data: Loaded;
672 others: MemberProfile[];
673 starred: boolean;
674 muted: boolean;
675 memberCount: number;
676 live: LiveState;
677 info: boolean;
678 onStar: () => void;
679 onInfo: () => void;
680}) {
681 const channel = data.channel;
682 const agentDm = channel.kind === "dm" && others.length === 1 && others[0]!.kind === "agent" ? others[0]! : null;
683 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)684 <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">
685 <button
686 type="button"
687 onClick={onBack}
688 aria-label="Back to conversations"
689 className="flex size-10 shrink-0 items-center justify-center rounded-full text-muted active:bg-raised md:hidden"
690 >
691 <ChevronLeft size={22} />
692 </button>
Chat and workspace agents: channels, DMs and named agents you talk to693 <div className="flex min-w-0 grow items-center gap-2.5">
694 {channel.kind === "channel" ? (
695 <>
696 <h1 className="flex min-w-0 shrink-0 items-center gap-1 text-[0.9375rem] font-semibold">
697 {channel.private ? <Lock size={15} className="text-faint" /> : <Hash size={16} className="text-faint" />}
698 <span className="truncate">{channel.name}</span>
699 </h1>
700 <StarButton starred={starred} onClick={onStar} />
701 {muted && (
702 <Hint label="Muted: no unread count or notifications">
703 <span className="text-faint">
704 <BellOff size={14} aria-label="Muted" />
705 </span>
706 </Hint>
707 )}
708 {channel.topic && (
709 <>
710 <span aria-hidden="true" className="hidden h-4 w-px shrink-0 bg-line sm:block" />
711 <p className="hidden min-w-0 truncate text-sm text-muted sm:block">{channel.topic}</p>
712 </>
713 )}
714 </>
715 ) : (
716 <>
717 <span className="flex shrink-0 -space-x-1.5">
718 {others.slice(0, 3).map((member) => (
719 <span key={`${member.kind}:${member.id}`} className="rounded-full ring-2 ring-bg">
720 <MemberAvatar member={member} size={26} />
721 </span>
722 ))}
723 </span>
724 <div className="min-w-0">
725 <h1 className="flex min-w-0 items-center gap-1.5 text-[0.9375rem] leading-tight font-semibold">
726 <span className="truncate">{others.map(shownName).join(", ") || "Just you"}</span>
727 {agentDm && <AgentPill />}
728 </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)729 {agentDm && (titleOf(agentDm) ?? agentDm.role) && (
730 <p className="truncate text-xs leading-tight text-muted">{titleOf(agentDm) ?? agentDm.role}</p>
731 )}
Chat and workspace agents: channels, DMs and named agents you talk to732 </div>
733 <StarButton starred={starred} onClick={onStar} />
734 </>
735 )}
736 </div>
737 {live !== "open" && (
738 <span role="status" className="hidden items-center gap-1.5 text-xs text-faint sm:flex">
739 <span className="size-1.5 animate-pulse rounded-full bg-warn" />
740 {live === "reconnecting" ? "Reconnecting…" : "Connecting…"}
741 </span>
742 )}
743 {channel.kind === "channel" && (
744 <Hint label={info ? "Hide details" : "Members and details"}>
745 <button
746 type="button"
747 onClick={onInfo}
748 aria-pressed={info}
749 aria-label="Members and details"
750 className={`flex h-8 items-center gap-1.5 rounded-md border px-2 text-xs tabular-nums transition-colors ${
751 info ? "border-line-strong bg-raised text-fg" : "border-line text-muted hover:border-line-strong hover:text-fg"
752 }`}
753 >
754 <UsersStack count={memberCount} />
755 </button>
756 </Hint>
757 )}
758 {channel.kind === "dm" && (
759 <Hint label={info ? "Hide details" : "Details"}>
760 <button
761 type="button"
762 onClick={onInfo}
763 aria-pressed={info}
764 aria-label="Details"
765 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"}`}
766 >
767 <PanelRight size={16} />
768 </button>
769 </Hint>
770 )}
771 </header>
772 );
773}
774
775function UsersStack({ count }: { count: number }) {
776 return (
777 <>
778 <Users size={14} />
779 {count}
780 </>
781 );
782}
783
784function StarButton({ starred, onClick }: { starred: boolean; onClick: () => void }) {
785 return (
786 <Hint label={starred ? "Unstar" : "Star: keep it at the top of the sidebar"}>
787 <button
788 type="button"
789 aria-label={starred ? "Unstar" : "Star"}
790 aria-pressed={starred}
791 onClick={onClick}
792 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"}`}
793 >
794 <Star size={15} fill={starred ? "currentColor" : "none"} />
795 </button>
796 </Hint>
797 );
798}
799
800/** How a conversation begins, above its first message. */
801function ConversationStart({ data, others }: { data: Loaded; others: MemberProfile[] }) {
802 const channel = data.channel;
803 if (channel.kind === "dm") {
804 const agent = others.length === 1 && others[0]!.kind === "agent" ? others[0]! : null;
805 return (
806 <div className="px-2 pb-6 sm:px-3">
807 <div className="flex -space-x-2">
808 {others.slice(0, 4).map((m) => (
809 <span key={m.id} className="rounded-full ring-4 ring-bg">
810 <MemberAvatar member={m} size={56} />
811 </span>
812 ))}
813 </div>
814 <h2 className="mt-3 flex items-center gap-2 text-xl font-semibold tracking-tight">
815 {others.map(shownName).join(", ") || "Just you"}
816 {agent && <AgentPill />}
817 </h2>
818 <p className="mt-1 max-w-xl text-sm text-muted">
819 {agent
820 ? `${agent.role ? `${agent.role}. ` : ""}Ask a question or give it a job: it answers here, and opens a task when the work needs one.`
821 : "This is the start of your conversation."}
822 </p>
823 </div>
824 );
825 }
826 return (
827 <div className="px-2 pb-6 sm:px-3">
828 <span className="flex size-12 items-center justify-center rounded-xl bg-raised text-muted">
829 {channel.private ? <Lock size={22} /> : <Hash size={24} />}
830 </span>
831 <h2 className="mt-3 text-xl font-semibold tracking-tight">Welcome to #{channel.name}</h2>
832 <p className="mt-1 max-w-xl text-sm text-muted">{channel.topic ?? "This is the very beginning of the channel."}</p>
833 </div>
834 );
835}
836
837function DayRule({ label }: { label: string }) {
838 return (
839 <div role="separator" aria-label={label} className="relative my-3 flex items-center justify-center">
840 <span aria-hidden="true" className="absolute inset-x-2 top-1/2 h-px bg-line" />
841 <span className="relative rounded-full border border-line bg-bg px-3 py-0.5 text-[0.6875rem] font-medium text-muted" suppressHydrationWarning>
842 {label}
843 </span>
844 </div>
845 );
846}
847
848const CARD_ICONS: Record<string, ReactNode> = {
849 pull: <GitPullRequest size={16} />,
850 issue: <CircleDot size={16} />,
851 task: <ListChecks size={16} />,
852 deploy: <Rocket size={16} />,
853 approval: <ShieldCheck size={16} />,
854};
855
856/** How loud a card's state reads, from what it says. */
857function stateTone(state: string): BadgeTone {
858 const s = state.toLowerCase();
859 if (/merged/.test(s)) return "merged";
860 if (/fail|error|blocked|over budget|rejected/.test(s)) return "danger";
861 if (/approval|waiting|review|queued|needs/.test(s)) return "warn";
862 if (/pass|done|deployed|ready|approved|live|success/.test(s)) return "success";
863 if (/working|running|in progress|open/.test(s)) return "info";
864 return "neutral";
865}
866
867/** A card g1t or an agent posted: what it is about, a line of detail, and its state. */
868export function CardBox({ card, href }: { card: MessageCard; href: string | null }) {
869 const inner = (
870 <>
871 <span className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg bg-raised text-muted group-hover:text-fg">
872 {CARD_ICONS[card.kind] ?? <Sparkles size={16} />}
873 </span>
874 <span className="min-w-0 grow">
875 <span className="line-clamp-2 text-sm font-medium text-fg">{card.title}</span>
876 {card.detail && <span className="mt-0.5 block truncate font-mono text-xs text-muted">{card.detail}</span>}
877 </span>
878 {card.state && (
879 <Badge tone={stateTone(card.state)} className="mt-0.5">
880 {card.state}
881 </Badge>
882 )}
883 </>
884 );
885 const box = "group mt-1.5 flex max-w-xl items-start gap-3 rounded-xl border border-line bg-surface px-3.5 py-3 transition-colors";
886 return href ? (
887 <Link to={href} className={`${box} hover:border-line-strong hover:bg-raised/40`}>
888 {inner}
889 </Link>
890 ) : (
891 <div className={box}>{inner}</div>
892 );
893}
894
895function cardHref(card: MessageCard, slug: string, code: boolean): string | null {
896 if (!card.href) return null;
897 if (code || !/^\/[^/]+\/(?!-\/)[^/]+/.test(card.href)) return card.href;
898 return codeAccessPath(slug, card.href);
899}
900
901/** 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)902/**
903 * Someone's profile beside the conversation, opened from their card: who
904 * they are, their teams and local time, and what they said here lately.
905 * The conversation stays where it was.
906 */
907function ProfilePanel({
908 slug,
909 username,
910 messages,
911 zone,
912 context,
913}: {
914 slug: string;
915 username: string;
916 messages: ShownMessage[];
917 zone: string | undefined;
918 context: TextContext;
919}) {
920 const [card, setCard] = useState<PersonCard | null | undefined>(undefined);
921 useEffect(() => {
922 let live = true;
923 setCard(undefined);
924 void personCard(slug, username).then((value) => {
925 if (live) setCard(value);
926 });
927 return () => {
928 live = false;
929 };
930 }, [slug, username]);
931 const theirs = messages
932 .filter((m) => m.author.kind === "user" && m.author.name === username && !m.deleted_at && m.body)
933 .slice(-5)
934 .reverse();
935 const time = card ? localTime(card.timezone, Date.now()) : null;
936 return (
937 <div className="min-h-0 grow overflow-y-auto p-5 [scrollbar-width:thin]">
938 <Avatar name={username} image={card?.avatar ?? null} size={88} />
939 <h3 className="mt-3 text-xl font-semibold tracking-tight">{card?.name?.trim() || username}</h3>
940 <p className="font-mono text-sm text-muted">
941 @{username}
942 {card?.pronouns ? <span className="font-sans"> · {card.pronouns}</span> : null}
943 </p>
944 {card?.bio && <p className="mt-3 text-sm leading-relaxed text-fg-soft">{card.bio}</p>}
945 <dl className="mt-4 space-y-2 text-sm">
946 {card && card.teams.length > 0 && (
947 <div className="flex gap-3">
948 <dt className="w-20 shrink-0 text-faint">Teams</dt>
949 <dd className="text-fg-soft">{card.teams.map((team) => team.name).join(", ")}</dd>
950 </div>
951 )}
952 {time && (
953 <div className="flex gap-3">
954 <dt className="w-20 shrink-0 text-faint">Local time</dt>
955 <dd className="text-fg-soft" suppressHydrationWarning>
956 {time}
957 </dd>
958 </div>
959 )}
960 {card?.location && (
961 <div className="flex gap-3">
962 <dt className="w-20 shrink-0 text-faint">Location</dt>
963 <dd className="text-fg-soft">{card.location}</dd>
964 </div>
965 )}
966 {card && card.workspaces.length > 0 && (
967 <div className="flex gap-3">
968 <dt className="w-20 shrink-0 text-faint">Workspaces</dt>
969 <dd className="text-fg-soft">{card.workspaces.map((w) => w.name).join(", ")}</dd>
970 </div>
971 )}
972 </dl>
973 <h4 className="mt-6 mb-2 text-xs font-medium text-faint">Lately in this conversation</h4>
974 {theirs.length === 0 ? (
975 <p className="text-sm text-muted">Nothing here yet.</p>
976 ) : (
977 <ul className="space-y-3">
978 {theirs.map((m) => (
979 <li key={m.id} className="rounded-lg border border-line bg-surface px-3 py-2.5">
980 <p className="mb-1 text-[0.6875rem] text-faint" suppressHydrationWarning>
981 {clock(m.created_at, zone)}
982 </p>
983 <div className="line-clamp-4 text-sm">
984 <MessageText body={m.body} context={context} />
985 </div>
986 </li>
987 ))}
988 </ul>
989 )}
990 <Link to={`/u/${username}`} className="mt-6 inline-block text-sm text-accent hover:underline">
991 Open their full profile
992 </Link>
993 </div>
994 );
995}
996
997/** An agent's title ("QA Engineer"), when the chat service sends it with the member. */
998function titleOf(member: MemberProfile): string | null {
999 return (member as MemberProfile & { title?: string | null }).title ?? null;
1000}
1001
1002/** A long press (half a second, without moving) on a touch screen. */
1003function useLongPress(onLongPress?: () => void) {
1004 const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
1005 const cancel = () => {
1006 if (timer.current) clearTimeout(timer.current);
1007 timer.current = null;
1008 };
1009 if (!onLongPress) return {};
1010 return {
1011 onTouchStart: () => {
1012 cancel();
1013 timer.current = setTimeout(() => {
1014 timer.current = null;
1015 if (typeof navigator !== "undefined" && "vibrate" in navigator) navigator.vibrate?.(8);
1016 onLongPress();
1017 }, 480);
1018 },
1019 onTouchMove: cancel,
1020 onTouchEnd: cancel,
1021 onTouchCancel: cancel,
1022 onContextMenu: (event: React.MouseEvent) => {
1023 // A phone's own long-press menu (copy, look up) would cover ours.
1024 if (window.matchMedia("(pointer: coarse)").matches) event.preventDefault();
1025 },
1026 };
1027}
1028
1029/** A long-pressed message's actions, in a sheet from the bottom: reply, copy, and for your own, edit and delete. */
1030function MessageActions({
1031 message,
1032 mine,
1033 onClose,
1034 onThread,
1035 onEdit,
1036 onDelete,
1037}: {
1038 message: ShownMessage | null;
1039 mine: boolean;
1040 onClose: () => void;
1041 onThread: (id: string) => void;
1042 onEdit: (message: ShownMessage) => void;
1043 onDelete: (message: ShownMessage) => void;
1044}) {
1045 const [confirm, setConfirm] = useState(false);
1046 useEffect(() => setConfirm(false), [message]);
1047 const act = (run: () => void) => () => {
1048 run();
1049 onClose();
1050 };
1051 return (
1052 <BottomSheet open={message != null} onOpenChange={(open) => !open && onClose()} title="Message actions">
1053 {message && (
1054 <>
1055 <p className="mt-1 mb-2 line-clamp-2 px-3 text-sm text-muted">
1056 <span className="font-medium text-fg-soft">{shownName(message.author)}:</span> {message.body || message.card?.title}
1057 </p>
1058 {!message.pending && <QuickReactions messageId={message.id} onDone={onClose} />}
1059 {!message.thread_root && (
1060 <SheetRow icon={<MessageSquareText />} onClick={act(() => onThread(message.id))}>
1061 Reply in thread
1062 </SheetRow>
1063 )}
1064 <SheetRow icon={<Copy />} onClick={act(() => void navigator.clipboard?.writeText(message.body || message.card?.title || ""))}>
1065 Copy text
1066 </SheetRow>
1067 {mine && message.kind === "text" && (
1068 <SheetRow icon={<Pencil />} onClick={act(() => onEdit(message))}>
1069 Edit
1070 </SheetRow>
1071 )}
1072 {mine &&
1073 (confirm ? (
1074 <SheetRow icon={<Trash2 className="text-danger" />} onClick={act(() => onDelete(message))}>
1075 <span className="text-danger">Delete for everyone</span>
1076 </SheetRow>
1077 ) : (
1078 <SheetRow icon={<Trash2 />} onClick={() => setConfirm(true)}>
1079 Delete
1080 </SheetRow>
1081 ))}
1082 </>
1083 )}
1084 </BottomSheet>
1085 );
1086}
1087
Chat and workspace agents: channels, DMs and named agents you talk to1088function MessageRow({
1089 message,
1090 head,
1091 zone,
1092 context,
1093 code,
1094 slug,
1095 onThread,
1096 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)1097 onLongPress,
Chat and workspace agents: channels, DMs and named agents you talk to1098 threadOpen,
1099 inThread,
1100}: {
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)1101 /** A phone: a long press opens the message's actions. */
1102 onLongPress?: () => void;
Chat and workspace agents: channels, DMs and named agents you talk to1103 message: ShownMessage;
1104 head: boolean;
1105 zone: string | undefined;
1106 context: TextContext;
1107 code: boolean;
1108 slug: string;
1109 onThread?: () => void;
1110 onRetry: () => void;
1111 threadOpen?: boolean;
1112 inThread?: boolean;
1113}) {
1114 const author = message.author;
1115 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)1116 const press = useLongPress(onLongPress);
Chat and workspace agents: channels, DMs and named agents you talk to1117 return (
1118 <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)1119 {...press}
Chat and workspace agents: channels, DMs and named agents you talk to1120 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)1121 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 to1122 threadOpen ? "bg-accent/[0.06] hover:bg-accent/[0.08]" : ""
1123 }`}
1124 >
1125 <div className="w-9 shrink-0">
1126 {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)1127 <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 to1128 <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)1129 </MemberCard>
Chat and workspace agents: channels, DMs and named agents you talk to1130 ) : (
1131 <time
1132 dateTime={message.created_at}
1133 suppressHydrationWarning
1134 className="mt-1 block text-right text-[0.625rem] leading-5 text-faint tabular-nums opacity-0 group-hover/message:opacity-100"
1135 >
1136 {time.replace(/\s?[AP]M$/, "")}
1137 </time>
1138 )}
1139 </div>
1140 <div className="min-w-0 grow">
1141 {head && (
1142 <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)1143 <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 to1144 {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)1145 </MemberCard>
Chat and workspace agents: channels, DMs and named agents you talk to1146 {author.kind === "agent" && <AgentPill className="self-center" />}
1147 <time dateTime={message.created_at} suppressHydrationWarning className="shrink-0 text-xs text-faint tabular-nums">
1148 {time}
1149 </time>
1150 </div>
1151 )}
1152 <div className={message.pending && !message.failed ? "opacity-60" : undefined}>
1153 {message.body && <MessageText body={message.body} context={context} />}
1154 {message.card && <CardBox card={message.card} href={cardHref(message.card, slug, code)} />}
1155 {message.edited_at && <span className="text-[0.6875rem] text-faint"> (edited)</span>}
1156 </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)1157 {!message.pending && !message.deleted_at && <ReactionBar messageId={message.id} reactions={message.reactions} />}
Chat and workspace agents: channels, DMs and named agents you talk to1158 {message.failed && (
1159 <p className="mt-0.5 text-xs text-danger">
1160 Not sent.{" "}
1161 <button type="button" onClick={onRetry} className="font-medium underline underline-offset-2 hover:text-fg">
1162 Try again
1163 </button>
1164 </p>
1165 )}
1166 {!inThread && message.reply_count > 0 && onThread && (
1167 <button
1168 type="button"
1169 onClick={onThread}
1170 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"
1171 >
1172 <MessageSquareText size={14} />
1173 {message.reply_count} {message.reply_count === 1 ? "reply" : "replies"}
1174 {message.last_reply_at && (
1175 <span className="font-normal text-faint" suppressHydrationWarning>
1176 Last reply {clock(message.last_reply_at, zone)}
1177 </span>
1178 )}
1179 </button>
1180 )}
1181 </div>
1182 {!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)1183 <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">
1184 <AddReaction messageId={message.id} />
Chat and workspace agents: channels, DMs and named agents you talk to1185 <Hint label="Reply in thread">
1186 <button
1187 type="button"
1188 aria-label="Reply in thread"
1189 onClick={onThread}
1190 className="flex size-7 items-center justify-center rounded-md text-muted hover:bg-raised hover:text-fg"
1191 >
1192 <MessageSquareText size={15} />
1193 </button>
1194 </Hint>
1195 </div>
1196 )}
1197 </article>
1198 );
1199}
1200
1201/** "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)1202/** What an agent working on a reply says it is doing: one phrase per agent, so each sounds like itself. */
1203const 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"];
1204
1205function agentDoing(id: string): string {
1206 let hash = 0;
1207 for (const char of id) hash = (hash * 31 + char.charCodeAt(0)) | 0;
1208 return AGENT_DOING[Math.abs(hash) % AGENT_DOING.length]!;
1209}
1210
Chat and workspace agents: channels, DMs and named agents you talk to1211function TypingLine({ members }: { members: MemberProfile[] }) {
1212 const names = members.map(shownName);
1213 const who = names.length === 1 ? names[0] : names.length === 2 ? `${names[0]} and ${names[1]}` : names.length > 2 ? "Several people" : "";
1214 return (
1215 <div aria-live="polite" className="flex h-6 items-center gap-1.5 px-1 text-xs text-muted">
1216 {who && (
1217 <>
1218 <span aria-hidden="true" className="flex gap-0.5">
1219 {[0, 1, 2].map((i) => (
1220 <span key={i} className="size-1 animate-bounce rounded-full bg-accent" style={{ animationDelay: `${i * 140}ms` }} />
1221 ))}
1222 </span>
1223 <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)1224 <span className="font-medium text-fg-soft">{who}</span>{" "}
1225 {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 to1226 </span>
1227 </>
1228 )}
1229 </div>
1230 );
1231}
1232
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)1233/**
1234 * A panel beside the conversation: a thread, or the details. On a phone it
1235 * is a screen of its own, pushed over the conversation, as tall as what
1236 * the keyboard leaves.
1237 */
Chat and workspace agents: channels, DMs and named agents you talk to1238function 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)1239 const swipe = useSwipeBack(onClose);
Chat and workspace agents: channels, DMs and named agents you talk to1240 return (
1241 <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)1242 {...swipe}
Chat and workspace agents: channels, DMs and named agents you talk to1243 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)1244 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 to1245 >
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)1246 <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">
1247 <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">
1248 <ChevronLeft size={22} />
Chat and workspace agents: channels, DMs and named agents you talk to1249 </button>
1250 <h2 className="text-[0.9375rem] font-semibold">{title}</h2>
1251 {subtitle && <span className="truncate text-sm text-faint">{subtitle}</span>}
1252 <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">
1253 <X size={16} />
1254 </button>
1255 </div>
1256 <div className="flex min-h-0 grow flex-col">{children}</div>
1257 </aside>
1258 );
1259}
1260
1261function ThreadPanel({
1262 root,
1263 replies,
1264 zone,
1265 context,
1266 code,
1267 slug,
1268 people,
1269 joined,
1270 onSend,
1271 onRetry,
1272 draftKey,
1273}: {
1274 root: ShownMessage | null;
1275 replies: ShownMessage[] | null;
1276 zone: string | undefined;
1277 context: TextContext;
1278 code: boolean;
1279 slug: string;
1280 people: Mentionable[];
1281 joined: boolean;
1282 onSend: (body: string) => void;
1283 onRetry: (message: ShownMessage) => void;
1284 draftKey: string;
1285}) {
1286 const rows = useMemo(() => timeline(replies ?? [], new Date(), zone).filter((row) => row.kind === "message"), [replies, zone]);
1287 return (
1288 <>
1289 <div className="min-h-0 grow overflow-y-auto px-1 py-3 [scrollbar-width:thin]">
1290 {root ? (
1291 <MessageRow message={root} head zone={zone} context={context} code={code} slug={slug} onRetry={() => onRetry(root)} inThread />
1292 ) : (
1293 <p className="px-4 py-2 text-sm text-faint">The message this thread is under is further back.</p>
1294 )}
1295 <div className="my-2 flex items-center gap-3 px-4 text-xs text-faint">
1296 {replies == null ? "Loading replies…" : `${replies.filter((r) => !r.deleted_at).length} ${replies.length === 1 ? "reply" : "replies"}`}
1297 <span className="h-px grow bg-line" />
1298 </div>
1299 {rows.map((row) =>
1300 row.kind === "message" ? (
1301 <MessageRow
1302 key={row.key}
1303 message={row.message}
1304 head={row.head}
1305 zone={zone}
1306 context={context}
1307 code={code}
1308 slug={slug}
1309 onRetry={() => onRetry(row.message)}
1310 inThread
1311 />
1312 ) : null,
1313 )}
1314 </div>
1315 {joined && (
1316 <div className="shrink-0 p-3">
1317 <Composer draftKey={draftKey} placeholder="Reply…" people={people} onSend={onSend} compact autoFocus />
1318 </div>
1319 )}
1320 </>
1321 );
1322}
1323
1324function InfoPanel({
1325 data,
1326 members,
1327 slug,
1328 people,
1329 onAdded,
1330 joined,
1331}: {
1332 data: Loaded;
1333 members: ChannelMember[];
1334 slug: string;
1335 people: Mentionable[];
1336 onAdded: (member: ChannelMember) => void;
1337 joined: boolean;
1338}) {
1339 const channel = data.channel;
1340 const agents = members.filter((m) => m.member.kind === "agent");
1341 const humans = members.filter((m) => m.member.kind === "user");
1342 return (
1343 <div className="min-h-0 grow overflow-y-auto p-4 [scrollbar-width:thin]">
1344 {channel.kind === "channel" && (
1345 <section className="rounded-xl border border-line bg-surface p-3.5">
1346 <h3 className="text-xs font-medium text-faint">Topic</h3>
1347 <p className="mt-1 text-sm text-fg-soft">{channel.topic || "No topic yet."}</p>
1348 <h3 className="mt-3 text-xs font-medium text-faint">Visibility</h3>
1349 <p className="mt-1 flex items-center gap-1.5 text-sm text-fg-soft">
1350 {channel.private ? <Lock size={13} /> : <Hash size={13} />}
1351 {channel.private ? "Private: invited members only" : "Public to the workspace"}
1352 </p>
1353 </section>
1354 )}
1355 {agents.length > 0 && (
1356 <section className="mt-5">
1357 <h3 className="mb-2 px-1 text-xs font-medium text-faint">Agents · {agents.length}</h3>
1358 <div className="flex flex-wrap gap-1.5">
1359 {agents.map(({ member }) => (
1360 <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)1361 <MemberCard
1362 member={member}
Chat and workspace agents: channels, DMs and named agents you talk to1363 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"
1364 >
1365 <MemberAvatar member={member} size={20} />
1366 {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)1367 </MemberCard>
Chat and workspace agents: channels, DMs and named agents you talk to1368 </Hint>
1369 ))}
1370 </div>
1371 </section>
1372 )}
1373 <section className="mt-5">
1374 <div className="mb-1 flex items-center justify-between px-1">
1375 <h3 className="text-xs font-medium text-faint">People · {humans.length}</h3>
1376 {joined && channel.kind === "channel" && <AddMember slug={slug} channelId={channel.id} people={people} members={members} onAdded={onAdded} />}
1377 </div>
1378 <ul className="space-y-px">
1379 {humans.map(({ member, role }) => (
1380 <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)1381 <MemberCard member={member} className="flex w-full items-center gap-2.5 rounded-md px-1 py-1.5 transition-colors hover:bg-raised/60 max-md:min-h-11">
Chat and workspace agents: channels, DMs and named agents you talk to1382 <MemberAvatar member={member} size={26} />
1383 <span className="min-w-0 grow truncate text-sm">
1384 {shownName(member)}
1385 {member.display_name !== member.name && <span className="ml-1.5 text-faint">@{member.name}</span>}
1386 </span>
1387 {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)1388 </MemberCard>
Chat and workspace agents: channels, DMs and named agents you talk to1389 </li>
1390 ))}
1391 </ul>
1392 </section>
1393 </div>
1394 );
1395}
1396
1397/** Adding a person or an agent to the channel. An invite lets them read; it never grants write. */
1398function AddMember({
1399 slug,
1400 channelId,
1401 people,
1402 members,
1403 onAdded,
1404}: {
1405 slug: string;
1406 channelId: string;
1407 people: Mentionable[];
1408 members: ChannelMember[];
1409 onAdded: (member: ChannelMember) => void;
1410}) {
1411 const [open, setOpen] = useState(false);
1412 const [query, setQuery] = useState("");
1413 const [error, setError] = useState<string | null>(null);
1414 const send = useChatSend(slug);
1415 const chatData = useChatData();
1416 const inside = new Set(members.map((m) => `${m.member.kind}:${m.member.name.toLowerCase()}`));
1417 const q = query.trim().toLowerCase().replace(/^@/, "");
1418 const options = people
1419 .filter((p) => !inside.has(`${p.kind}:${p.name.toLowerCase()}`))
1420 .filter((p) => !q || p.name.toLowerCase().includes(q) || p.display_name.toLowerCase().includes(q))
1421 .slice(0, 8);
1422 const add = async (person: Mentionable) => {
1423 const agent = person.kind === "agent" ? chatData?.agents.find((a) => a.handle === person.name) : null;
1424 const key = agent ? `agent:${agent.id}` : `user:${person.name}`;
1425 setError(null);
1426 const done = await send({ intent: "invite", channel_id: channelId, member: key });
1427 if (!done.ok) return setError(done.error.message);
1428 onAdded({
1429 channel_id: channelId,
1430 member: { kind: person.kind, id: agent?.id ?? person.name, name: person.name, display_name: person.display_name, avatar: person.avatar, role: person.role ?? null },
1431 role: "member",
1432 starred: false,
1433 muted: false,
1434 last_read_id: null,
1435 joined_at: new Date().toISOString(),
1436 });
1437 setOpen(false);
1438 };
1439 return (
1440 <Popover open={open} onOpenChange={setOpen}>
1441 <PopoverTrigger asChild>
1442 <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">
1443 <UserPlus size={13} />
1444 Add
1445 </button>
1446 </PopoverTrigger>
1447 <PopoverContent align="end" className="w-72 p-1.5">
1448 <input
1449 autoFocus
1450 value={query}
1451 onChange={(event) => setQuery(event.target.value)}
1452 placeholder="Add a person or an agent"
1453 aria-label="Add a person or an agent"
1454 autoComplete="off"
1455 data-1p-ignore
1456 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"
1457 />
1458 {options.length === 0 && <p className="px-2 py-3 text-xs text-faint">Everyone who matches is already here.</p>}
1459 {options.map((person) => (
1460 <button
1461 key={`${person.kind}:${person.name}`}
1462 type="button"
1463 onClick={() => void add(person)}
1464 className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm hover:bg-line"
1465 >
1466 <MemberAvatar member={person} size={20} />
1467 <span className="min-w-0 grow truncate">{person.display_name}</span>
1468 {person.kind === "agent" && <AgentPill />}
1469 </button>
1470 ))}
1471 {error && <p className="px-2 pt-1 text-xs text-danger">{error}</p>}
1472 <p className="border-t border-line px-2 pt-1.5 pb-0.5 text-[0.6875rem] leading-snug text-faint">
1473 Adding an agent lets it read this channel. It never grants it write access.
1474 </p>
1475 </PopoverContent>
1476 </Popover>
1477 );
1478}

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