| 1 | import { ArrowRight } from "lucide-react"; |
| 2 | import { Suspense, useEffect, useState } from "react"; |
| 3 | import { Await, Form, Link, data, redirect, useNavigation } from "react-router"; |
| 4 | |
| 5 | import { hasCodeAccess } from "@g1t/contracts"; |
| 6 | |
| 7 | import type { Route } from "./+types/home"; |
| 8 | import { |
| 9 | Card, |
| 10 | DecisionsCard, |
| 11 | DeploysCard, |
| 12 | LandedCard, |
| 13 | NeedsList, |
| 14 | RowsSkeleton, |
| 15 | RunningList, |
| 16 | Sources, |
| 17 | SpanCard, |
| 18 | SpanSkeleton, |
| 19 | SpendCard, |
| 20 | SpendSkeleton, |
| 21 | StartHere, |
| 22 | WindowSwitch, |
| 23 | } from "../../components/home"; |
| 24 | import { G1tMark, isOrchestrator } from "../../components/orchestrator"; |
| 25 | import { Button } from "../../components/ui/button"; |
| 26 | import { Skeleton } from "../../components/ui/skeleton"; |
| 27 | import { channelPath } from "../../lib/chat"; |
| 28 | import { sidebarOrNull } from "../../lib/chat.server"; |
| 29 | import { |
| 30 | type Attention, |
| 31 | type SpanWork, |
| 32 | attention, |
| 33 | decisionsIn, |
| 34 | deploysIn, |
| 35 | landedIn, |
| 36 | parseWindow, |
| 37 | running, |
| 38 | runningChanges, |
| 39 | runningDeploys, |
| 40 | runningSessions, |
| 41 | spanFor, |
| 42 | spanSentence, |
| 43 | spanWork, |
| 44 | waitingSentence, |
| 45 | } from "../../lib/home"; |
| 46 | import { |
| 47 | loadAgentsOverview, |
| 48 | loadCodeWork, |
| 49 | loadDeploys, |
| 50 | loadInstallRequests, |
| 51 | loadLastVisit, |
| 52 | loadMemories, |
| 53 | loadSessions, |
| 54 | loadSpend, |
| 55 | reachFor, |
| 56 | } from "../../lib/home.server"; |
| 57 | import { readCookie } from "../../lib/mission"; |
| 58 | import { loadMissionControl } from "../../lib/mission-control.server"; |
| 59 | import { page } from "../../lib/meta"; |
| 60 | import { chat, inbox, workspaceAgents } from "../../lib/services.server"; |
| 61 | import { assertSameOrigin, requireUser, roleIn } from "../../lib/session.server"; |
| 62 | import { homePagePath } from "../../lib/workspace-nav"; |
| 63 | |
| 64 | export function meta({ params, ...args }: Route.MetaArgs) { |
| 65 | return page(args, { title: `Home · ${params.owner} · g1t` }); |
| 66 | } |
| 67 | |
| 68 | /** Unread notifications read for Needs you. */ |
| 69 | const NOTIFICATIONS_READ = 50; |
| 70 | |
| 71 | /** |
| 72 | * Home: the workspace's front page. Where you're needed now, with no time |
| 73 | * window; what happened since you were last here (or over the last 24 |
| 74 | * hours or 7 days, `?window=`); what is running; what the span cost; and |
| 75 | * one place to start. The last visit is kept by notify, per person and |
| 76 | * workspace, and marked by the page once it has been looked at |
| 77 | * (routes/workspace/home-seen.ts), so a refresh never wipes it. Only the |
| 78 | * agents list (for Ask g1t) and the last visit (for the span) are waited |
| 79 | * for; every section streams in on its own, and one whose service does |
| 80 | * not answer says so. Definitions: lib/home.ts and the Home guide. |
| 81 | */ |
| 82 | export async function loader({ params, context, request }: Route.LoaderArgs) { |
| 83 | const viewer = requireUser(context, request); |
| 84 | const slug = params.owner.toLowerCase(); |
| 85 | if (!roleIn(viewer, slug)) throw data(null, { status: 404 }); |
| 86 | const membership = viewer.workspaces?.find((m) => m.slug === slug) ?? null; |
| 87 | const code = hasCodeAccess(membership); |
| 88 | const now = Date.now(); |
| 89 | const tz = readCookie(request.headers.get("cookie"), "g1t_tz"); |
| 90 | const windowKey = parseWindow(new URL(request.url).searchParams.get("window")); |
| 91 | |
| 92 | // What needs you has no window: read at once, beside the last visit. |
| 93 | const agentsP = workspaceAgents |
| 94 | .list(slug, viewer) |
| 95 | .then((result) => (result.ok ? result.value.filter((agent) => !agent.archived_at) : null)) |
| 96 | .catch(() => null); |
| 97 | const overviewP = loadAgentsOverview(viewer, slug).catch(() => null); |
| 98 | const missionP = code ? loadMissionControl(viewer, request, slug).then(({ value }) => value).catch(() => null) : Promise.resolve(null); |
| 99 | const requestsP = loadInstallRequests(viewer, slug).catch(() => null); |
| 100 | const chatP = sidebarOrNull(slug, viewer); |
| 101 | const notificationsP = inbox |
| 102 | .list(viewer, { unread: true, limit: NOTIFICATIONS_READ }) |
| 103 | .then((result) => result.items) |
| 104 | .catch(() => null); |
| 105 | const lastSeenP = windowKey === "last" ? loadLastVisit(viewer, slug).catch(() => undefined) : Promise.resolve(null); |
| 106 | |
| 107 | const waiting: Promise<Attention> = Promise.all([missionP, overviewP, agentsP, requestsP, notificationsP, chatP]) |
| 108 | .then(([mission, overview, agents, requests, notifications, sidebar]) => { |
| 109 | const capped = overview?.waiting_on_you ?? null; |
| 110 | const latestCap = Math.max(0, ...(capped ?? []).map((session) => Date.parse(session.updated_at))); |
| 111 | const monthStart = Date.parse(`${new Date(now).toISOString().slice(0, 7)}-01T00:00:00Z`); |
| 112 | return attention({ |
| 113 | slug, |
| 114 | code, |
| 115 | codeNeeds: mission?.needs ?? null, |
| 116 | capped, |
| 117 | agents, |
| 118 | canManage: overview?.can_manage ?? false, |
| 119 | limit: overview?.can_manage ? { alert: overview.alert, spentMicros: overview.spent_month_micros, since: latestCap || monthStart } : null, |
| 120 | requests: requests?.can_resolve ? requests.requests : null, |
| 121 | notifications, |
| 122 | chat: sidebar?.entries ?? null, |
| 123 | }); |
| 124 | }) |
| 125 | .catch(() => ({ rows: [], start: null, missing: ["Code", "Agents", "Notifications", "Chat"] })); |
| 126 | |
| 127 | const span = spanFor(windowKey, now, await lastSeenP, tz); |
| 128 | |
| 129 | // What happened in the span. |
| 130 | const codeWorkP = code ? loadCodeWork(viewer, slug, reachFor(span)).catch(() => null) : Promise.resolve("no_access" as const); |
| 131 | const sessionsP = loadSessions(viewer, slug, span.from).catch(() => null); |
| 132 | const deploysP = code ? loadDeploys(viewer, slug, span.from).catch(() => null) : Promise.resolve(null); |
| 133 | const memoriesP = code ? loadMemories(viewer, slug).catch(() => null) : Promise.resolve(null); |
| 134 | |
| 135 | const work: Promise<SpanWork | null> = Promise.all([codeWorkP, sessionsP]) |
| 136 | .then(([codeWork, sessions]) => (codeWork == null && sessions == null ? null : spanWork({ span, slug, code: codeWork, sessions }))) |
| 137 | .catch(() => null); |
| 138 | const happened = Promise.all([codeWorkP, deploysP, memoriesP, requestsP]) |
| 139 | .then(([codeWork, deploys, memories, requests]) => ({ |
| 140 | landed: codeWork && codeWork !== "no_access" ? landedIn(codeWork.pulls, span) : null, |
| 141 | deploys: deploys ? deploysIn(deploys.projects, slug, span) : null, |
| 142 | deploysComplete: deploys?.complete ?? true, |
| 143 | decisions: decisionsIn({ memories, requests: requests?.requests ?? null }, slug, span), |
| 144 | decisionsRead: { memories: !code || memories != null, requests: requests != null }, |
| 145 | })) |
| 146 | .catch(() => null); |
| 147 | const runningNow = Promise.all([sessionsP, missionP, deploysP]) |
| 148 | .then(([sessions, mission, deploys]) => ({ |
| 149 | rows: running([ |
| 150 | runningSessions(sessions?.sessions ?? [], slug), |
| 151 | runningChanges(mission?.waiting ?? []), |
| 152 | runningDeploys(deploys?.overview ?? [], slug), |
| 153 | ]), |
| 154 | missing: [...(sessions ? [] : ["Agents"]), ...(code && !mission ? ["Code"] : []), ...(code && !deploys ? ["Deployments"] : [])], |
| 155 | })) |
| 156 | .catch(() => ({ rows: [], missing: ["Agents"] })); |
| 157 | const spend = loadSpend(viewer, slug, span).catch(() => null); |
| 158 | |
| 159 | const agents = await agentsP; |
| 160 | return { |
| 161 | slug, |
| 162 | code, |
| 163 | now, |
| 164 | tz, |
| 165 | span, |
| 166 | orchestrator: agents?.some(isOrchestrator) ?? false, |
| 167 | work, |
| 168 | happened, |
| 169 | attention: waiting, |
| 170 | running: runningNow, |
| 171 | /** The sentence under the heading needs the work, what landed and what waits. */ |
| 172 | head: Promise.all([work, happened, waiting]), |
| 173 | spend, |
| 174 | }; |
| 175 | } |
| 176 | |
| 177 | /** |
| 178 | * Ask g1t: the direct message with the workspace's orchestrator, opened or |
| 179 | * made, with what was asked posted in it; then that conversation. |
| 180 | */ |
| 181 | export async function action({ params, context, request }: Route.ActionArgs) { |
| 182 | assertSameOrigin(request); |
| 183 | const viewer = requireUser(context, request); |
| 184 | const slug = params.owner.toLowerCase(); |
| 185 | if (!roleIn(viewer, slug)) throw data(null, { status: 404 }); |
| 186 | const form = await request.formData(); |
| 187 | const body = String(form.get("body") ?? "").trim(); |
| 188 | if (!body) return { error: "Write what you need." }; |
| 189 | const listed = await workspaceAgents.list(slug, viewer).catch(() => null); |
| 190 | const g1t = listed?.ok ? listed.value.find(isOrchestrator) : null; |
| 191 | if (!g1t) return { error: "g1t isn't reachable right now. Try again in a moment." }; |
| 192 | const dm = await chat.openDm(slug, viewer, [{ kind: "agent", id: g1t.id }]).catch(() => null); |
| 193 | if (!dm?.ok) return { error: dm ? dm.error.message : "Chat didn't answer. Try again in a moment." }; |
| 194 | const posted = await chat.post(slug, dm.value.id, viewer, { body }).catch(() => null); |
| 195 | if (!posted?.ok) return { error: posted ? posted.error.message : "Your message didn't send. Try again." }; |
| 196 | throw redirect(channelPath(slug, dm.value)); |
| 197 | } |
| 198 | |
| 199 | /** Ask g1t, as one bar: Enter sends; Shift+Enter is a new line. Chat is one click away. */ |
| 200 | function AskG1t({ slug, available, error }: { slug: string; available: boolean; error: string | null }) { |
| 201 | const navigation = useNavigation(); |
| 202 | const here = homePagePath(slug); |
| 203 | const busy = navigation.state !== "idle" && navigation.formAction === here; |
| 204 | const [text, setText] = useState(""); |
| 205 | return ( |
| 206 | <Form method="post" action={here} className="rounded-2xl border border-line-strong bg-surface transition-colors focus-within:border-accent/40"> |
| 207 | <div className="flex items-start gap-3 px-3 py-2.5 sm:px-4"> |
| 208 | <span className="mt-1 shrink-0"> |
| 209 | <G1tMark size={20} /> |
| 210 | </span> |
| 211 | <textarea |
| 212 | name="body" |
| 213 | value={text} |
| 214 | onChange={(event) => setText(event.target.value)} |
| 215 | onKeyDown={(event) => { |
| 216 | if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) { |
| 217 | event.preventDefault(); |
| 218 | if (text.trim()) event.currentTarget.form?.requestSubmit(); |
| 219 | } |
| 220 | }} |
| 221 | rows={1} |
| 222 | disabled={!available} |
| 223 | aria-label="Ask g1t" |
| 224 | placeholder={available ? "Ask g1t for anything…" : "g1t isn't reachable right now."} |
| 225 | className="field-sizing-content block max-h-40 min-h-7 w-full grow resize-none bg-transparent py-1 text-[0.9375rem] leading-relaxed text-fg outline-none placeholder:text-faint disabled:cursor-not-allowed" |
| 226 | /> |
| 227 | <Button |
| 228 | type="submit" |
| 229 | disabled={!available || busy || !text.trim()} |
| 230 | variant="accent" |
| 231 | size="sm" |
| 232 | className="rounded-lg px-3 text-sm disabled:bg-line disabled:text-faint disabled:opacity-100" |
| 233 | > |
| 234 | {busy ? "Sending…" : "Ask"} |
| 235 | <ArrowRight size={14} /> |
| 236 | </Button> |
| 237 | </div> |
| 238 | {error && <p className="px-4 pb-2.5 text-sm text-danger">{error}</p>} |
| 239 | </Form> |
| 240 | ); |
| 241 | } |
| 242 | |
| 243 | /** How long the page must have been in view before it counts as a visit. */ |
| 244 | const SEEN_AFTER_MS = 5_000; |
| 245 | |
| 246 | /** |
| 247 | * Marks this visit once the page has been in view for a few seconds, with |
| 248 | * the time it loaded: what happened after that shows next time. Never on |
| 249 | * first paint, so a quick refresh keeps what was not looked at yet. |
| 250 | */ |
| 251 | function useMarkSeen(slug: string, loadedAt: number) { |
| 252 | useEffect(() => { |
| 253 | let timer: ReturnType<typeof setTimeout> | null = null; |
| 254 | let done = false; |
| 255 | const mark = () => { |
| 256 | if (done) return; |
| 257 | done = true; |
| 258 | void fetch(`${homePagePath(slug)}/seen`, { |
| 259 | method: "POST", |
| 260 | headers: { "content-type": "application/json" }, |
| 261 | body: JSON.stringify({ at: loadedAt }), |
| 262 | keepalive: true, |
| 263 | }).catch(() => undefined); |
| 264 | }; |
| 265 | const arm = () => { |
| 266 | if (timer) clearTimeout(timer); |
| 267 | timer = document.visibilityState === "visible" ? setTimeout(mark, SEEN_AFTER_MS) : null; |
| 268 | }; |
| 269 | arm(); |
| 270 | document.addEventListener("visibilitychange", arm); |
| 271 | return () => { |
| 272 | document.removeEventListener("visibilitychange", arm); |
| 273 | if (timer) clearTimeout(timer); |
| 274 | }; |
| 275 | }, [slug, loadedAt]); |
| 276 | } |
| 277 | |
| 278 | export default function Home({ loaderData, actionData }: Route.ComponentProps) { |
| 279 | const { slug, now, tz, span, orchestrator, work, happened, attention: waiting, running: runningNow, head, spend } = loaderData; |
| 280 | useMarkSeen(slug, now); |
| 281 | return ( |
| 282 | <div className="mx-auto w-full max-w-[1040px] space-y-5 px-4 py-5 md:px-10 md:py-8"> |
| 283 | <header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"> |
| 284 | <div className="min-w-0"> |
| 285 | <h1 className="text-[1.75rem] leading-tight font-semibold tracking-tight">{span.key === "last" && span.note == null ? "Welcome back" : "Home"}</h1> |
| 286 | <p className="mt-1.5 max-w-2xl text-sm leading-relaxed text-muted"> |
| 287 | <Suspense fallback={<Skeleton className="inline-block h-3.5 w-72 align-middle" />}> |
| 288 | <Await resolve={head}> |
| 289 | {([work, happened, att]) => ( |
| 290 | <> |
| 291 | <span className="text-fg-soft">{waitingSentence(att.rows.length)}</span> {spanSentence(span, work, happened?.landed?.length ?? null)} |
| 292 | </> |
| 293 | )} |
| 294 | </Await> |
| 295 | </Suspense> |
| 296 | </p> |
| 297 | </div> |
| 298 | <WindowSwitch span={span} /> |
| 299 | </header> |
| 300 | |
| 301 | <AskG1t slug={slug} available={orchestrator} error={actionData?.error ?? null} /> |
| 302 | |
| 303 | {/* 1. Where you're needed now: no window, first and biggest. Start here beside it on wide screens, under it on phones. */} |
| 304 | <div className="grid items-start gap-5 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]"> |
| 305 | <div className="lg:col-start-1 lg:row-start-1"> |
| 306 | <Card |
| 307 | title="Where you're needed" |
| 308 | hint="Everything waiting on you, however long ago it started" |
| 309 | link={{ label: "Notifications", to: "/notifications" }} |
| 310 | > |
| 311 | <Suspense fallback={<RowsSkeleton rows={4} />}> |
| 312 | <Await resolve={waiting}>{(att) => <NeedsList attention={att} now={now} />}</Await> |
| 313 | </Suspense> |
| 314 | </Card> |
| 315 | </div> |
| 316 | <div className="lg:col-start-2 lg:row-start-1"> |
| 317 | <Suspense |
| 318 | fallback={ |
| 319 | <Card title="Start here"> |
| 320 | <RowsSkeleton rows={1} /> |
| 321 | </Card> |
| 322 | } |
| 323 | > |
| 324 | <Await resolve={waiting}>{(att) => <StartHere attention={att} now={now} />}</Await> |
| 325 | </Suspense> |
| 326 | </div> |
| 327 | </div> |
| 328 | |
| 329 | {/* 2. What happened in the span. */} |
| 330 | <section aria-labelledby="since" className="space-y-4"> |
| 331 | <div className="flex flex-wrap items-baseline gap-x-3 gap-y-1 pt-2"> |
| 332 | <h2 id="since" className="text-lg font-semibold tracking-tight text-fg"> |
| 333 | {span.key === "last" && span.note == null ? "Since you were last here" : span.words} |
| 334 | </h2> |
| 335 | <p className="text-sm text-muted"> |
| 336 | {span.key === "last" && span.note == null ? `${span.words} · ${span.length}` : spanNote(span.note)} |
| 337 | </p> |
| 338 | </div> |
| 339 | <Suspense fallback={<SpanSkeleton />}> |
| 340 | <Await resolve={work}>{(value) => <SpanCard work={value} span={span} />}</Await> |
| 341 | </Suspense> |
| 342 | <div className="grid items-start gap-5 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]"> |
| 343 | <div className="space-y-5"> |
| 344 | <Suspense |
| 345 | fallback={ |
| 346 | <Card title="Landed"> |
| 347 | <RowsSkeleton rows={3} /> |
| 348 | </Card> |
| 349 | } |
| 350 | > |
| 351 | <Await resolve={happened}>{(value) => <LandedCard landed={value?.landed ?? null} code={loaderData.code} now={now} tz={tz} />}</Await> |
| 352 | </Suspense> |
| 353 | <Suspense fallback={null}> |
| 354 | <Await resolve={happened}> |
| 355 | {(value) => <DeploysCard deploys={value?.deploys ?? null} complete={value?.deploysComplete ?? true} code={loaderData.code} now={now} tz={tz} />} |
| 356 | </Await> |
| 357 | </Suspense> |
| 358 | <Suspense fallback={null}> |
| 359 | <Await resolve={happened}> |
| 360 | {(value) => <DecisionsCard decisions={value?.decisions ?? null} read={value?.decisionsRead ?? null} now={now} tz={tz} />} |
| 361 | </Await> |
| 362 | </Suspense> |
| 363 | </div> |
| 364 | <div className="space-y-5"> |
| 365 | <Suspense |
| 366 | fallback={ |
| 367 | <Card title="Spent"> |
| 368 | <SpendSkeleton /> |
| 369 | </Card> |
| 370 | } |
| 371 | > |
| 372 | <Await resolve={spend}>{(value) => <SpendCard spend={value} slug={slug} span={span} />}</Await> |
| 373 | </Suspense> |
| 374 | <Suspense fallback={null}> |
| 375 | <Await resolve={work}>{(value) => <Sources work={value} />}</Await> |
| 376 | </Suspense> |
| 377 | </div> |
| 378 | </div> |
| 379 | </section> |
| 380 | |
| 381 | {/* 3. Running now. */} |
| 382 | <Card title="Running now" link={{ label: "Agents", to: `/${slug}/-/agents` }}> |
| 383 | <Suspense fallback={<RowsSkeleton rows={2} />}> |
| 384 | <Await resolve={runningNow}>{(value) => <RunningList rows={value.rows} missing={value.missing} now={now} />}</Await> |
| 385 | </Suspense> |
| 386 | </Card> |
| 387 | </div> |
| 388 | ); |
| 389 | } |
| 390 | |
| 391 | function spanNote(note: "first" | "capped" | "unknown" | null): string { |
| 392 | if (note === "first") return "Your first visit here, so this is the last 24 hours."; |
| 393 | if (note === "capped") return "You were away longer than that; this is as far back as Home looks."; |
| 394 | if (note === "unknown") return "Your last visit couldn't be read, so this is the last 24 hours."; |
| 395 | return ""; |
| 396 | } |