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 to | 1 | import { |
| Merge the workspace shell: navigation and phone shell, g1t as 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 to | 6 | 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"; | |
| 22 | import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| 23 | import { Link, useRouteLoaderData, useSearchParams } from "react-router"; | |
| 24 | ||
| 25 | import { type ChannelMember, type ChatLiveEvent, type ChatMessage, type MemberProfile, type MessageCard, type Result, hasCodeAccess } from "@g1t/contracts"; | |
| 26 | ||
| 27 | import { useChatData, useChatSend, useChatSidebar } from "./actions"; | |
| 28 | import { Composer } from "./composer"; | |
| 29 | import { type LiveState, useChatLive } from "./live"; | |
| 30 | import { 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) | 31 | import { CardContext, MemberCard, type PersonCard, personCard } from "./profile-card"; |
| 32 | import { Avatar } from "../ui"; | |
| 33 | import { localTime } from "../../lib/time-zone"; | |
| 34 | import { BottomSheet, SheetRow, useBack, useSwipeBack } from "../mobile"; | |
| Chat and workspace agents: channels, DMs and named agents you talk to | 35 | import { MessageText, type TextContext } from "./text"; |
| 36 | import { Badge, type BadgeTone } from "../ui/badge"; | |
| 37 | import { Hint } from "../ui/hint"; | |
| 38 | import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; | |
| 39 | import { type Mentionable, type ShownMessage, mergeMessages, shownName, timeline } from "../../lib/chat"; | |
| 40 | import { 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) | 41 | import { conversationCache } from "./conversation-cache"; |
| 42 | // Reactions and the workspace's own emoji (components/emoji). | |
| 43 | import { EmojiProvider, useCustomEmoji } from "../emoji/context"; | |
| 44 | import { AddReaction, QuickReactions, ReactionBar } from "../emoji/reaction-bar"; | |
| 45 | import { useAddresses } from "../../lib/addresses"; | |
| 46 | import { applyReactionEvent, keepMine, toggleReaction } from "../../lib/emoji"; | |
| Chat and workspace agents: channels, DMs and named agents you talk to | 47 | import type { ChannelData } from "../../routes/workspace/chat/channel"; |
| 48 | ||
| 49 | type Loaded = Extract<ChannelData, { unavailable: false }>; | |
| 50 | ||
| 51 | /** True once the page has hydrated: times and days are the reader's own from then on. */ | |
| 52 | function useHydrated(): boolean { | |
| 53 | const [hydrated, setHydrated] = useState(false); | |
| 54 | useEffect(() => setHydrated(true), []); | |
| 55 | return hydrated; | |
| 56 | } | |
| 57 | ||
| 58 | function 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"). */ | |
| 63 | export 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`. */ | |
| 69 | function 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 | ||
| 75 | function 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. */ | |
| 80 | export 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 to | 121 | 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 to | 130 | 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 to | 246 | 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 to | 250 | } |
| 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 to | 268 | } 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 to | 406 | 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 to | 486 | |
| 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 to | 496 | <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 to | 499 | 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 to | 546 | 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 to | 553 | <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 to | 594 | ) : ( |
| 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 to | 650 | </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 to | 653 | ); |
| 654 | } | |
| 655 | ||
| 656 | /** The top of a conversation: what it is, and its controls. */ | |
| 657 | function 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 to | 659 | 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 to | 671 | 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 to | 693 | <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 to | 732 | </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 | ||
| 775 | function UsersStack({ count }: { count: number }) { | |
| 776 | return ( | |
| 777 | <> | |
| 778 | <Users size={14} /> | |
| 779 | {count} | |
| 780 | </> | |
| 781 | ); | |
| 782 | } | |
| 783 | ||
| 784 | function 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. */ | |
| 801 | function 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 | ||
| 837 | function 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 | ||
| 848 | const 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. */ | |
| 857 | function 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. */ | |
| 868 | export 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 | ||
| 895 | function 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 | */ | |
| 907 | function 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. */ | |
| 998 | function 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. */ | |
| 1003 | function 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. */ | |
| 1030 | function 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 to | 1088 | function 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 to | 1098 | 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 to | 1103 | 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 to | 1117 | 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 to | 1120 | 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 to | 1122 | 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 to | 1128 | <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 to | 1130 | ) : ( |
| 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 to | 1144 | {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 to | 1146 | {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 to | 1158 | {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 to | 1185 | <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. */ |
| 1203 | const 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 | ||
| 1205 | function 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 to | 1211 | function 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 to | 1226 | </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 to | 1238 | function 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 to | 1240 | 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 to | 1243 | 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 to | 1245 | > |
| Merge the workspace shell: navigation and phone shell, g1t as 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 to | 1249 | </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 | ||
| 1261 | function 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 | ||
| 1324 | function 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 to | 1363 | 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 to | 1368 | </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 to | 1382 | <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 to | 1389 | </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. */ | |
| 1398 | function 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.