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