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 { ArrowRight, BookOpen, Hash, Inbox, Lock, MessagesSquare, Sparkles } from "lucide-react"; |
| 2 | import type { ReactNode } from "react"; | |
| 3 | import { Link, data } from "react-router"; | |
| 4 | ||
| 5 | import type { ChatSidebarEntry } from "@g1t/contracts"; | |
| 6 | ||
| 7 | import type { Route } from "./+types/home"; | |
| 8 | import { AgentFace } from "../../components/agents-mode"; | |
| 9 | import { MemberAvatar, StatusDot, statusLabel } from "../../components/chat/marks"; | |
| 10 | import { channelPath, sections } from "../../lib/chat"; | |
| 11 | import { sidebarOrNull } from "../../lib/chat.server"; | |
| 12 | import { page } from "../../lib/meta"; | |
| 13 | import { inbox, workspaceAgents } from "../../lib/services.server"; | |
| 14 | import { requireUser, roleIn } from "../../lib/session.server"; | |
| 15 | ||
| 16 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 17 | return page(args, { title: `Home · ${params.owner} · g1t` }); | |
| 18 | } | |
| 19 | ||
| 20 | /** | |
| 21 | * Home for the workspace without Code (docs/WORKSPACE.md, "Members without | |
| 22 | * Code"): the conversations waiting, the agents, Docs and the inbox, in | |
| 23 | * place of projects. Anyone in the workspace can open it. | |
| 24 | */ | |
| 25 | export async function loader({ params, context, request }: Route.LoaderArgs) { | |
| 26 | const viewer = requireUser(context, request); | |
| 27 | if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 }); | |
| 28 | const slug = params.owner.toLowerCase(); | |
| 29 | const [sidebar, agents, counts] = await Promise.all([ | |
| 30 | sidebarOrNull(slug, viewer), | |
| 31 | workspaceAgents.list(slug, viewer).catch(() => null), | |
| 32 | inbox.counts(viewer.username).catch(() => null), | |
| 33 | ]); | |
| 34 | return { | |
| 35 | slug, | |
| 36 | name: viewer.username, | |
| 37 | sidebar, | |
| 38 | agents: agents?.ok ? agents.value.filter((a) => !a.archived_at) : null, | |
| 39 | unreadInbox: counts?.unread ?? 0, | |
| 40 | }; | |
| 41 | } | |
| 42 | ||
| 43 | function Card({ title, icon, to, action, children }: { title: string; icon: ReactNode; to: string; action: string; children: ReactNode }) { | |
| 44 | return ( | |
| 45 | <section className="flex flex-col rounded-2xl border border-line bg-surface"> | |
| 46 | <header className="flex items-center gap-2.5 border-b border-line px-5 py-3.5"> | |
| 47 | <span className="text-muted">{icon}</span> | |
| 48 | <h2 className="text-sm font-semibold">{title}</h2> | |
| 49 | <Link to={to} className="ml-auto flex items-center gap-1 text-xs text-muted hover:text-fg"> | |
| 50 | {action} | |
| 51 | <ArrowRight size={12} /> | |
| 52 | </Link> | |
| 53 | </header> | |
| 54 | <div className="grow p-2">{children}</div> | |
| 55 | </section> | |
| 56 | ); | |
| 57 | } | |
| 58 | ||
| 59 | function ConversationRow({ entry, slug }: { entry: ChatSidebarEntry; slug: string }) { | |
| 60 | const other = entry.others[0]; | |
| 61 | return ( | |
| 62 | <Link to={channelPath(slug, entry.channel)} className="flex h-10 items-center gap-2.5 rounded-lg px-3 text-sm transition-colors hover:bg-raised/60"> | |
| 63 | <span className="flex w-5 justify-center text-faint"> | |
| 64 | {entry.channel.kind === "dm" && other ? <MemberAvatar member={other} size={20} /> : entry.channel.private ? <Lock size={14} /> : <Hash size={15} />} | |
| 65 | </span> | |
| 66 | <span className={`min-w-0 grow truncate ${entry.unread > 0 ? "font-semibold text-fg" : "text-muted"}`}>{entry.title}</span> | |
| 67 | {entry.unread > 0 && ( | |
| 68 | <span className={`rounded-full px-1.5 text-[0.6875rem] leading-[1.125rem] font-semibold tabular-nums ${entry.mentions ? "bg-accent text-bg" : "bg-line-strong text-fg"}`}> | |
| 69 | {entry.mentions || entry.unread} | |
| 70 | </span> | |
| 71 | )} | |
| 72 | </Link> | |
| 73 | ); | |
| 74 | } | |
| 75 | ||
| 76 | export default function WorkspaceHome({ loaderData }: Route.ComponentProps) { | |
| 77 | const { slug, name, sidebar, agents, unreadInbox } = loaderData; | |
| 78 | const all = sidebar?.entries ?? []; | |
| 79 | const waiting = all.filter((e) => !e.muted && e.unread > 0); | |
| 80 | const { dms } = sections(all); | |
| 81 | return ( | |
| 82 | <div> | |
| 83 | <header className="mb-8"> | |
| 84 | <h1 className="text-2xl font-semibold tracking-tight">Welcome back, {name}</h1> | |
| 85 | <p className="mt-1.5 text-sm text-muted"> | |
| 86 | {waiting.length > 0 | |
| 87 | ? `${waiting.length} ${waiting.length === 1 ? "conversation has" : "conversations have"} something new.` | |
| 88 | : "You're caught up in chat."} | |
| 89 | {unreadInbox > 0 ? ` ${unreadInbox} unread in your inbox.` : ""} | |
| 90 | </p> | |
| 91 | </header> | |
| 92 | <div className="grid gap-4 lg:grid-cols-2"> | |
| 93 | <Card title="Unread" icon={<MessagesSquare size={16} />} to={`/${slug}/-/chat`} action="Open chat"> | |
| 94 | {sidebar == null ? ( | |
| 95 | <p className="px-3 py-6 text-sm text-muted">Chat didn't answer. Your conversations show here once it does.</p> | |
| 96 | ) : waiting.length === 0 ? ( | |
| 97 | <p className="px-3 py-6 text-sm text-muted">Nothing new. Conversations with new messages show here.</p> | |
| 98 | ) : ( | |
| 99 | waiting.slice(0, 6).map((entry) => <ConversationRow key={entry.channel.id} entry={entry} slug={slug} />) | |
| 100 | )} | |
| 101 | </Card> | |
| 102 | <Card title="Direct messages" icon={<MessagesSquare size={16} />} to={`/${slug}/-/chat`} action="All"> | |
| 103 | {dms.length === 0 ? ( | |
| 104 | <p className="px-3 py-6 text-sm text-muted">Message a teammate or an agent from Chat.</p> | |
| 105 | ) : ( | |
| 106 | dms.slice(0, 6).map((entry) => <ConversationRow key={entry.channel.id} entry={entry} slug={slug} />) | |
| 107 | )} | |
| 108 | </Card> | |
| 109 | <Card title="Agents" icon={<Sparkles size={16} />} to={`/${slug}/-/agents`} action="All agents"> | |
| 110 | {agents == null || agents.length === 0 ? ( | |
| 111 | <p className="px-3 py-6 text-sm text-muted"> | |
| 112 | {agents == null ? "The agents service didn't answer." : "No agents yet. Ask an owner to add one, or make one from a template."} | |
| 113 | </p> | |
| 114 | ) : ( | |
| 115 | agents.slice(0, 5).map((agent) => ( | |
| 116 | <Link key={agent.id} to={`/${slug}/-/agents/${agent.handle}`} className="flex h-12 items-center gap-3 rounded-lg px-3 transition-colors hover:bg-raised/60"> | |
| 117 | <AgentFace agent={agent} size={26} /> | |
| 118 | <span className="min-w-0 grow"> | |
| 119 | <span className="block truncate text-sm font-medium">{agent.display_name}</span> | |
| 120 | <span className="block truncate text-xs text-faint">{agent.role}</span> | |
| 121 | </span> | |
| 122 | <span className="flex items-center gap-1.5 text-xs text-muted"> | |
| 123 | <StatusDot status={agent.status} /> | |
| 124 | {statusLabel(agent.status)} | |
| 125 | </span> | |
| 126 | </Link> | |
| 127 | )) | |
| 128 | )} | |
| 129 | </Card> | |
| 130 | <div className="grid gap-4"> | |
| 131 | <Card title="Inbox" icon={<Inbox size={16} />} to="/inbox" action="Open"> | |
| 132 | <p className="px-3 py-4 text-sm text-muted"> | |
| 133 | {unreadInbox > 0 ? `${unreadInbox} unread: mentions, approvals and what needs you.` : "Nothing waiting on you."} | |
| 134 | </p> | |
| 135 | </Card> | |
| 136 | <Card title="Docs" icon={<BookOpen size={16} />} to={`/${slug}/-/docs`} action="Learn more"> | |
| 137 | <p className="px-3 py-4 text-sm text-muted">Specs, runbooks and decisions, kept current by agents. Coming soon.</p> | |
| 138 | </Card> | |
| 139 | </div> | |
| 140 | </div> | |
| 141 | </div> | |
| 142 | ); | |
| 143 | } |