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