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