| 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 | } |