Skip to content
396 linesCodeBlameRaw
1import { ArrowRight } from "lucide-react";
2import { Suspense, useEffect, useState } from "react";
3import { Await, Form, Link, data, redirect, useNavigation } from "react-router";
4
5import { hasCodeAccess } from "@g1t/contracts";
6
7import type { Route } from "./+types/home";
8import {
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";
24import { G1tMark, isOrchestrator } from "../../components/orchestrator";
25import { Button } from "../../components/ui/button";
26import { Skeleton } from "../../components/ui/skeleton";
27import { channelPath } from "../../lib/chat";
28import { sidebarOrNull } from "../../lib/chat.server";
29import {
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";
46import {
47 loadAgentsOverview,
48 loadCodeWork,
49 loadDeploys,
50 loadInstallRequests,
51 loadLastVisit,
52 loadMemories,
53 loadSessions,
54 loadSpend,
55 reachFor,
56} from "../../lib/home.server";
57import { readCookie } from "../../lib/mission";
58import { loadMissionControl } from "../../lib/mission-control.server";
59import { page } from "../../lib/meta";
60import { chat, inbox, workspaceAgents } from "../../lib/services.server";
61import { assertSameOrigin, requireUser, roleIn } from "../../lib/session.server";
62import { homePagePath } from "../../lib/workspace-nav";
63
64export function meta({ params, ...args }: Route.MetaArgs) {
65 return page(args, { title: `Home · ${params.owner} · g1t` });
66}
67
68/** Unread notifications read for Needs you. */
69const 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 */
82export 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 */
181export 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. */
200function 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. */
244const 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 */
251function 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
278export 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
391function 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}