| 1 | import { ArrowRight, AtSign, GitPullRequest, Inbox, MessagesSquare, Sparkles } from "lucide-react"; |
| 2 | import { type ReactNode, Suspense, useEffect, useState } from "react"; |
| 3 | import { Await, Form, Link, data, redirect, useNavigation } from "react-router"; |
| 4 | |
| 5 | import { type ChatSidebarEntry, type InboxItem, type WorkspaceAgent, hasCodeAccess } 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 { InboxCard } from "../../components/inbox"; |
| 11 | import { G1tMark, isOrchestrator } from "../../components/orchestrator"; |
| 12 | import { Skeleton } from "../../components/ui/skeleton"; |
| 13 | import { channelPath } from "../../lib/chat"; |
| 14 | import { sidebarOrNull } from "../../lib/chat.server"; |
| 15 | import { greetingFor, hourIn, readCookie } from "../../lib/mission"; |
| 16 | import type { NeedRow } from "../../lib/mission-control"; |
| 17 | import { loadMissionControl } from "../../lib/mission-control.server"; |
| 18 | import { page } from "../../lib/meta"; |
| 19 | import { chat, inbox, workspaceAgents } from "../../lib/services.server"; |
| 20 | import { assertSameOrigin, requireUser, roleIn } from "../../lib/session.server"; |
| 21 | |
| 22 | export function meta({ params, ...args }: Route.MetaArgs) { |
| 23 | return page(args, { title: `Home · ${params.owner} · g1t` }); |
| 24 | } |
| 25 | |
| 26 | /** Unread inbox items read, and how many Home shows. */ |
| 27 | const INBOX_READ = 30; |
| 28 | const INBOX_SHOWN = 4; |
| 29 | /** Most rows in each of Home's lists. */ |
| 30 | const ROWS = 5; |
| 31 | |
| 32 | /** |
| 33 | * Home: what needs you across the workspace, never one mode's sidebar. |
| 34 | * Each section is read at once and on its own, and one whose service does |
| 35 | * not answer is left saying so. Reviews and approvals come from Code's |
| 36 | * Overview, the slowest part, so they arrive after the page does. |
| 37 | */ |
| 38 | export async function loader({ params, context, request }: Route.LoaderArgs) { |
| 39 | const viewer = requireUser(context, request); |
| 40 | const slug = params.owner.toLowerCase(); |
| 41 | if (!roleIn(viewer, slug)) throw data(null, { status: 404 }); |
| 42 | const membership = viewer.workspaces?.find((m) => m.slug === slug) ?? null; |
| 43 | const code = hasCodeAccess(membership); |
| 44 | // Streamed: the page shows without waiting for it. |
| 45 | const reviews: Promise<NeedRow[] | null> | null = code |
| 46 | ? loadMissionControl(viewer, request, slug) |
| 47 | .then(({ value }) => value.needs.slice(0, ROWS)) |
| 48 | .catch(() => null) |
| 49 | : null; |
| 50 | const [sidebar, agents, unread] = await Promise.all([ |
| 51 | sidebarOrNull(slug, viewer), |
| 52 | workspaceAgents.list(slug, viewer).catch(() => null), |
| 53 | inbox.list(viewer, { unread: true, limit: INBOX_READ }).catch(() => null), |
| 54 | ]); |
| 55 | const tz = readCookie(request.headers.get("cookie"), "g1t_tz"); |
| 56 | const listed = agents?.ok ? agents.value.filter((agent) => !agent.archived_at) : null; |
| 57 | const items: InboxItem[] | null = unread?.items ?? null; |
| 58 | return { |
| 59 | slug, |
| 60 | code, |
| 61 | name: viewer.username, |
| 62 | greeting: greetingFor(hourIn(Date.now(), tz)), |
| 63 | chat: sidebar |
| 64 | ? sidebar.entries |
| 65 | .filter((entry) => !entry.muted && (entry.mentions > 0 || (entry.channel.kind === "dm" && entry.unread > 0))) |
| 66 | .sort((a, b) => b.mentions - a.mentions || (b.channel.last_message_at ?? "").localeCompare(a.channel.last_message_at ?? "")) |
| 67 | : null, |
| 68 | agents: listed, |
| 69 | orchestrator: listed?.find(isOrchestrator) ?? null, |
| 70 | inbox: items ? [...items].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)).slice(0, INBOX_SHOWN) : null, |
| 71 | inboxTotal: items?.length ?? 0, |
| 72 | reviews, |
| 73 | }; |
| 74 | } |
| 75 | |
| 76 | /** |
| 77 | * Ask g1t: the direct message with the workspace's orchestrator, opened or |
| 78 | * made, with what was asked posted in it; then that conversation. |
| 79 | */ |
| 80 | export async function action({ params, context, request }: Route.ActionArgs) { |
| 81 | assertSameOrigin(request); |
| 82 | const viewer = requireUser(context, request); |
| 83 | const slug = params.owner.toLowerCase(); |
| 84 | if (!roleIn(viewer, slug)) throw data(null, { status: 404 }); |
| 85 | const form = await request.formData(); |
| 86 | const body = String(form.get("body") ?? "").trim(); |
| 87 | if (!body) return { error: "Write what you need." }; |
| 88 | const listed = await workspaceAgents.list(slug, viewer).catch(() => null); |
| 89 | const g1t = listed?.ok ? listed.value.find(isOrchestrator) : null; |
| 90 | if (!g1t) return { error: "g1t isn't reachable right now. Try again in a moment." }; |
| 91 | const dm = await chat.openDm(slug, viewer, [{ kind: "agent", id: g1t.id }]).catch(() => null); |
| 92 | if (!dm?.ok) return { error: dm ? dm.error.message : "Chat didn't answer. Try again in a moment." }; |
| 93 | const posted = await chat.post(slug, dm.value.id, viewer, { body }).catch(() => null); |
| 94 | if (!posted?.ok) return { error: posted ? posted.error.message : "Your message didn't send. Try again." }; |
| 95 | throw redirect(channelPath(slug, dm.value)); |
| 96 | } |
| 97 | |
| 98 | function Section({ |
| 99 | icon, |
| 100 | title, |
| 101 | count, |
| 102 | to, |
| 103 | action, |
| 104 | children, |
| 105 | }: { |
| 106 | icon: ReactNode; |
| 107 | title: string; |
| 108 | count?: number | null; |
| 109 | to: string; |
| 110 | action: string; |
| 111 | children: ReactNode; |
| 112 | }) { |
| 113 | return ( |
| 114 | <section className="flex flex-col rounded-2xl border border-line bg-surface"> |
| 115 | <header className="flex items-center gap-2.5 border-b border-line px-5 py-3.5"> |
| 116 | <span className="text-muted">{icon}</span> |
| 117 | <h3 className="text-sm font-semibold">{title}</h3> |
| 118 | {count != null && count > 0 && ( |
| 119 | <span className="rounded-full bg-accent/15 px-1.5 text-[0.6875rem] font-semibold text-accent tabular-nums">{count}</span> |
| 120 | )} |
| 121 | <Link to={to} className="ml-auto flex items-center gap-1 text-xs text-muted hover:text-fg"> |
| 122 | {action} |
| 123 | <ArrowRight size={12} /> |
| 124 | </Link> |
| 125 | </header> |
| 126 | <div className="grow p-2">{children}</div> |
| 127 | </section> |
| 128 | ); |
| 129 | } |
| 130 | |
| 131 | function Quiet({ children }: { children: ReactNode }) { |
| 132 | return <p className="px-3 py-6 text-sm text-muted">{children}</p>; |
| 133 | } |
| 134 | |
| 135 | function ChatRow({ entry, slug }: { entry: ChatSidebarEntry; slug: string }) { |
| 136 | const other = entry.others[0]; |
| 137 | return ( |
| 138 | <Link to={channelPath(slug, entry.channel)} className="flex h-11 items-center gap-3 rounded-lg px-3 transition-colors hover:bg-raised/60"> |
| 139 | <span className="flex w-6 justify-center"> |
| 140 | {entry.channel.kind === "dm" && other ? <MemberAvatar member={other} size={22} /> : <span className="font-semibold text-faint">#</span>} |
| 141 | </span> |
| 142 | <span className="min-w-0 grow"> |
| 143 | <span className="block truncate text-sm font-medium text-fg">{entry.title}</span> |
| 144 | <span className="block truncate text-xs text-faint"> |
| 145 | {entry.mentions > 0 ? `${entry.mentions} ${entry.mentions === 1 ? "mention" : "mentions"}` : `${entry.unread} unread`} |
| 146 | </span> |
| 147 | </span> |
| 148 | <span |
| 149 | className={`rounded-full px-1.5 text-[0.6875rem] leading-[1.125rem] font-semibold tabular-nums ${entry.mentions > 0 ? "bg-accent text-bg" : "bg-line-strong text-fg"}`} |
| 150 | > |
| 151 | {entry.mentions || entry.unread} |
| 152 | </span> |
| 153 | </Link> |
| 154 | ); |
| 155 | } |
| 156 | |
| 157 | function AgentRow({ agent, slug }: { agent: WorkspaceAgent; slug: string }) { |
| 158 | return ( |
| 159 | <Link to={`/${slug}/-/agents/${agent.handle}`} className="flex h-12 items-center gap-3 rounded-lg px-3 transition-colors hover:bg-raised/60"> |
| 160 | <AgentFace agent={{ ...agent, builtin: isOrchestrator(agent) }} size={26} /> |
| 161 | <span className="min-w-0 grow"> |
| 162 | <span className="block truncate text-sm font-medium">{agent.display_name}</span> |
| 163 | <span className="block truncate text-xs text-faint">{agent.role}</span> |
| 164 | </span> |
| 165 | <span className="flex shrink-0 items-center gap-1.5 text-xs text-muted"> |
| 166 | <StatusDot status={agent.status} /> |
| 167 | {statusLabel(agent.status)} |
| 168 | </span> |
| 169 | </Link> |
| 170 | ); |
| 171 | } |
| 172 | |
| 173 | function ReviewRow({ row }: { row: NeedRow }) { |
| 174 | return ( |
| 175 | <Link to={row.to} className="flex items-start gap-3 rounded-lg px-3 py-2.5 transition-colors hover:bg-raised/60"> |
| 176 | <GitPullRequest size={16} className="mt-0.5 shrink-0 text-warn" /> |
| 177 | <span className="min-w-0 grow"> |
| 178 | <span className="block truncate text-sm font-medium text-fg">{row.title}</span> |
| 179 | <span className="line-clamp-2 text-xs text-muted"> |
| 180 | {row.repo ? `${row.repo.name}${row.ref ? ` ${row.ref}` : ""} · ` : ""} |
| 181 | {row.ask} |
| 182 | </span> |
| 183 | </span> |
| 184 | </Link> |
| 185 | ); |
| 186 | } |
| 187 | |
| 188 | /** Ask g1t: the orchestrator, from Home. Enter sends; Shift+Enter is a new line. */ |
| 189 | function AskG1t({ slug, available, error }: { slug: string; available: boolean; error: string | null }) { |
| 190 | const navigation = useNavigation(); |
| 191 | const busy = navigation.state !== "idle" && navigation.formAction === `/${slug}/-/home`; |
| 192 | const [text, setText] = useState(""); |
| 193 | return ( |
| 194 | <Form method="post" action={`/${slug}/-/home`} className="rounded-2xl border border-line-strong bg-surface transition-colors focus-within:border-accent/40"> |
| 195 | <div className="flex items-center gap-2 px-4 pt-3.5 text-sm font-medium"> |
| 196 | <G1tMark size={20} /> |
| 197 | Ask g1t |
| 198 | <span className="hidden font-normal text-faint sm:inline">It knows the team and hands work to the right agent.</span> |
| 199 | </div> |
| 200 | <textarea |
| 201 | name="body" |
| 202 | value={text} |
| 203 | onChange={(event) => setText(event.target.value)} |
| 204 | onKeyDown={(event) => { |
| 205 | if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) { |
| 206 | event.preventDefault(); |
| 207 | if (text.trim()) event.currentTarget.form?.requestSubmit(); |
| 208 | } |
| 209 | }} |
| 210 | rows={2} |
| 211 | disabled={!available} |
| 212 | aria-label="Ask g1t" |
| 213 | placeholder={available ? "Get the flaky checks fixed and tell #support when it ships…" : "g1t isn't reachable right now."} |
| 214 | className="block w-full resize-none bg-transparent px-4 pt-2.5 pb-1 text-[0.9375rem] leading-relaxed text-fg outline-none placeholder:text-faint disabled:cursor-not-allowed" |
| 215 | /> |
| 216 | <div className="flex items-center gap-3 px-3 pb-3"> |
| 217 | {error ? <p className="text-sm text-danger">{error}</p> : <p className="hidden pl-1 text-xs text-faint sm:block">Opens your conversation with g1t in Chat.</p>} |
| 218 | <button |
| 219 | type="submit" |
| 220 | disabled={!available || busy || !text.trim()} |
| 221 | className="ml-auto inline-flex h-8 items-center gap-1.5 rounded-lg bg-accent px-3 text-sm font-medium text-bg transition-colors hover:bg-accent-hover disabled:bg-line disabled:text-faint" |
| 222 | > |
| 223 | {busy ? "Sending…" : "Ask"} |
| 224 | <ArrowRight size={14} /> |
| 225 | </button> |
| 226 | </div> |
| 227 | </Form> |
| 228 | ); |
| 229 | } |
| 230 | |
| 231 | export default function WorkspaceHome({ loaderData, actionData }: Route.ComponentProps) { |
| 232 | const { slug, code, name, chat: mentions, agents, orchestrator, inbox: items, inboxTotal, reviews } = loaderData; |
| 233 | const [greeting, setGreeting] = useState(loaderData.greeting); |
| 234 | // The reader's own clock. |
| 235 | useEffect(() => setGreeting(greetingFor(new Date().getHours())), []); |
| 236 | const waiting = agents?.filter((agent) => agent.status === "waiting") ?? null; |
| 237 | return ( |
| 238 | <div className="mx-auto max-w-5xl space-y-8"> |
| 239 | <header> |
| 240 | <h1 className="text-[1.75rem] leading-tight font-semibold tracking-tight" suppressHydrationWarning> |
| 241 | {greeting}, {name} |
| 242 | </h1> |
| 243 | <p className="mt-1.5 text-sm text-muted">Everything that needs you, across chat, agents{code ? ", code" : ""} and your inbox.</p> |
| 244 | </header> |
| 245 | |
| 246 | <AskG1t slug={slug} available={orchestrator != null} error={actionData?.error ?? null} /> |
| 247 | |
| 248 | <div> |
| 249 | <h2 className="mb-3 text-sm font-semibold text-fg-soft">Needs you across everything</h2> |
| 250 | <div className="grid gap-4 lg:grid-cols-2"> |
| 251 | <Section icon={<AtSign size={15} />} title="Mentions and DMs" count={mentions?.length} to={`/${slug}/-/chat`} action="Open Chat"> |
| 252 | {mentions == null ? ( |
| 253 | <Quiet>Chat didn't answer. Your mentions show here once it does.</Quiet> |
| 254 | ) : mentions.length === 0 ? ( |
| 255 | <Quiet>No unread mentions or messages.</Quiet> |
| 256 | ) : ( |
| 257 | mentions.slice(0, ROWS).map((entry) => <ChatRow key={entry.channel.id} entry={entry} slug={slug} />) |
| 258 | )} |
| 259 | </Section> |
| 260 | <Section icon={<Sparkles size={15} />} title="Agents waiting on you" count={waiting?.length} to={`/${slug}/-/agents`} action="Open Agents"> |
| 261 | {waiting == null ? ( |
| 262 | <Quiet>The agents service didn't answer.</Quiet> |
| 263 | ) : waiting.length === 0 ? ( |
| 264 | <Quiet>No agent is waiting on you.</Quiet> |
| 265 | ) : ( |
| 266 | waiting.slice(0, ROWS).map((agent) => <AgentRow key={agent.id} agent={agent} slug={slug} />) |
| 267 | )} |
| 268 | </Section> |
| 269 | {code && reviews && ( |
| 270 | <Section icon={<GitPullRequest size={15} />} title="Reviews and approvals" to={`/${slug}/-/overview`} action="Open Code"> |
| 271 | <Suspense fallback={<RowsSkeleton />}> |
| 272 | <Await resolve={reviews}> |
| 273 | {(rows) => |
| 274 | rows == null ? ( |
| 275 | <Quiet>Code's reviews couldn't be read right now.</Quiet> |
| 276 | ) : rows.length === 0 ? ( |
| 277 | <Quiet>Nothing to review or approve.</Quiet> |
| 278 | ) : ( |
| 279 | rows.map((row) => <ReviewRow key={row.key} row={row} />) |
| 280 | ) |
| 281 | } |
| 282 | </Await> |
| 283 | </Suspense> |
| 284 | </Section> |
| 285 | )} |
| 286 | <Section icon={<Inbox size={15} />} title="From your inbox" count={inboxTotal} to="/inbox" action="Open Inbox"> |
| 287 | {items == null ? ( |
| 288 | <Quiet>Your inbox couldn't be read right now.</Quiet> |
| 289 | ) : items.length === 0 ? ( |
| 290 | <Quiet>Nothing unread.</Quiet> |
| 291 | ) : ( |
| 292 | <ul className="space-y-2 p-1"> |
| 293 | {items.map((item) => ( |
| 294 | <InboxCard key={item.id} item={item} /> |
| 295 | ))} |
| 296 | </ul> |
| 297 | )} |
| 298 | </Section> |
| 299 | </div> |
| 300 | </div> |
| 301 | {!code && ( |
| 302 | <p className="flex items-center gap-2 text-xs text-faint"> |
| 303 | <MessagesSquare size={13} /> |
| 304 | Your membership includes Chat, Docs, Agents and the Inbox. |
| 305 | </p> |
| 306 | )} |
| 307 | </div> |
| 308 | ); |
| 309 | } |
| 310 | |
| 311 | function RowsSkeleton() { |
| 312 | return ( |
| 313 | <div className="space-y-3 px-3 py-3" aria-busy="true"> |
| 314 | {[70, 55, 62].map((w) => ( |
| 315 | <div key={w} className="flex items-center gap-3"> |
| 316 | <Skeleton className="size-4 rounded" /> |
| 317 | <Skeleton className="h-3" style={{ width: `${w}%` }} /> |
| 318 | </div> |
| 319 | ))} |
| 320 | </div> |
| 321 | ); |
| 322 | } |