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