Skip to content
244 linesCodeBlameRaw
1import { ArrowRight } from "lucide-react";
2import { Suspense, 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 { AttentionList, Card, DayCard, DaySkeleton, RowsSkeleton, Sources, SpendCard, SpendSkeleton, StartHere } from "../../components/today";
9import { G1tMark, isOrchestrator } from "../../components/orchestrator";
10import { Skeleton } from "../../components/ui/skeleton";
11import { channelPath } from "../../lib/chat";
12import { sidebarOrNull } from "../../lib/chat.server";
13import { readCookie } from "../../lib/mission";
14import { loadMissionControl } from "../../lib/mission-control.server";
15import { page } from "../../lib/meta";
16import { chat, inbox, workspaceAgents } from "../../lib/services.server";
17import { assertSameOrigin, requireUser, roleIn } from "../../lib/session.server";
18import { type Attention, type DayWork, attention, daySentence, dayWork, shortDate, waitingSentence } from "../../lib/today";
19import { loadCapped, loadCodeWork, loadSessions, loadSpend } from "../../lib/today.server";
20
21export function meta({ params, ...args }: Route.MetaArgs) {
22 return page(args, { title: `Today · ${params.owner} · g1t` });
23}
24
25/** Unread notifications read for Needs attention. */
26const NOTIFICATIONS_READ = 50;
27
28/**
29 * Today: the workspace's front page. A sentence about the day, how the
30 * agents' work went, what needs you with the action right there, what the
31 * day cost, and one place to start. Only the agents list (for Ask g1t) is
32 * waited for; every section streams in on its own, and one whose service
33 * does not answer says so. Definitions: lib/today.ts and the Today guide.
34 */
35export async function loader({ params, context, request }: Route.LoaderArgs) {
36 const viewer = requireUser(context, request);
37 const slug = params.owner.toLowerCase();
38 if (!roleIn(viewer, slug)) throw data(null, { status: 404 });
39 const membership = viewer.workspaces?.find((m) => m.slug === slug) ?? null;
40 const code = hasCodeAccess(membership);
41 const now = Date.now();
42 const tz = readCookie(request.headers.get("cookie"), "g1t_tz");
43
44 const agentsP = workspaceAgents
45 .list(slug, viewer)
46 .then((result) => (result.ok ? result.value.filter((agent) => !agent.archived_at) : null))
47 .catch(() => null);
48 const codeWorkP = code ? loadCodeWork(viewer, slug, now).catch(() => null) : Promise.resolve("no_access" as const);
49 const sessionsP = loadSessions(viewer, slug, now, tz).catch(() => null);
50 const codeNeedsP = code
51 ? loadMissionControl(viewer, request, slug)
52 .then(({ value }) => value.needs)
53 .catch(() => null)
54 : Promise.resolve(null);
55 const cappedP = loadCapped(viewer, slug).catch(() => null);
56 const chatP = sidebarOrNull(slug, viewer);
57 const notificationsP = inbox
58 .list(viewer, { unread: true, limit: NOTIFICATIONS_READ })
59 .then((result) => result.items)
60 .catch(() => null);
61
62 const day: Promise<DayWork | null> = Promise.all([codeWorkP, sessionsP])
63 .then(([codeWork, sessions]) =>
64 codeWork == null && sessions == null ? null : dayWork({ now, timeZone: tz, slug, code: codeWork, sessions }),
65 )
66 .catch(() => null);
67 const waiting: Promise<Attention> = Promise.all([codeNeedsP, cappedP, agentsP, notificationsP, chatP]).then(
68 ([codeNeeds, capped, agents, notifications, sidebar]) =>
69 attention({
70 slug,
71 code,
72 codeNeeds,
73 capped: capped?.capped ?? null,
74 agents,
75 canManage: capped?.canManage ?? false,
76 notifications,
77 chat: sidebar?.entries ?? null,
78 }),
79 ).catch(() => ({ rows: [], start: null, missing: ["Code", "Agents", "Notifications", "Chat"] }));
80 const spend = loadSpend(viewer, slug, now).catch(() => null);
81
82 const agents = await agentsP;
83 return {
84 slug,
85 code,
86 now,
87 date: shortDate(now, tz),
88 orchestrator: agents?.some(isOrchestrator) ?? false,
89 day,
90 attention: waiting,
91 /** The sentence under the heading needs both. */
92 head: Promise.all([day, waiting]),
93 spend,
94 };
95}
96
97/**
98 * Ask g1t: the direct message with the workspace's orchestrator, opened or
99 * made, with what was asked posted in it; then that conversation.
100 */
101export async function action({ params, context, request }: Route.ActionArgs) {
102 assertSameOrigin(request);
103 const viewer = requireUser(context, request);
104 const slug = params.owner.toLowerCase();
105 if (!roleIn(viewer, slug)) throw data(null, { status: 404 });
106 const form = await request.formData();
107 const body = String(form.get("body") ?? "").trim();
108 if (!body) return { error: "Write what you need." };
109 const listed = await workspaceAgents.list(slug, viewer).catch(() => null);
110 const g1t = listed?.ok ? listed.value.find(isOrchestrator) : null;
111 if (!g1t) return { error: "g1t isn't reachable right now. Try again in a moment." };
112 const dm = await chat.openDm(slug, viewer, [{ kind: "agent", id: g1t.id }]).catch(() => null);
113 if (!dm?.ok) return { error: dm ? dm.error.message : "Chat didn't answer. Try again in a moment." };
114 const posted = await chat.post(slug, dm.value.id, viewer, { body }).catch(() => null);
115 if (!posted?.ok) return { error: posted ? posted.error.message : "Your message didn't send. Try again." };
116 throw redirect(channelPath(slug, dm.value));
117}
118
119/** Ask g1t, as one bar: Enter sends; Shift+Enter is a new line. Chat is one click away. */
120function AskG1t({ slug, available, error }: { slug: string; available: boolean; error: string | null }) {
121 const navigation = useNavigation();
122 const here = `/${slug}/-/today`;
123 const busy = navigation.state !== "idle" && navigation.formAction === here;
124 const [text, setText] = useState("");
125 return (
126 <Form method="post" action={here} className="rounded-2xl border border-line-strong bg-surface transition-colors focus-within:border-accent/40">
127 <div className="flex items-start gap-3 px-3 py-2.5 sm:px-4">
128 <span className="mt-1 shrink-0">
129 <G1tMark size={20} />
130 </span>
131 <textarea
132 name="body"
133 value={text}
134 onChange={(event) => setText(event.target.value)}
135 onKeyDown={(event) => {
136 if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
137 event.preventDefault();
138 if (text.trim()) event.currentTarget.form?.requestSubmit();
139 }
140 }}
141 rows={1}
142 disabled={!available}
143 aria-label="Ask g1t"
144 placeholder={available ? "Ask g1t for anything…" : "g1t isn't reachable right now."}
145 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"
146 />
147 <button
148 type="submit"
149 disabled={!available || busy || !text.trim()}
150 className="inline-flex h-8 shrink-0 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"
151 >
152 {busy ? "Sending…" : "Ask"}
153 <ArrowRight size={14} />
154 </button>
155 </div>
156 {error && <p className="px-4 pb-2.5 text-sm text-danger">{error}</p>}
157 </Form>
158 );
159}
160
161export default function Today({ loaderData, actionData }: Route.ComponentProps) {
162 const { slug, now, date, orchestrator, day, attention: waiting, head, spend } = loaderData;
163 return (
164 <div className="mx-auto w-full max-w-[1040px] space-y-5 px-4 py-5 md:px-10 md:py-8">
165 <header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
166 <div className="min-w-0">
167 <h1 className="text-[1.75rem] leading-tight font-semibold tracking-tight">Today</h1>
168 <p className="mt-1.5 max-w-2xl text-sm leading-relaxed text-muted">
169 <span className="text-fg-soft">{date}</span> ·{" "}
170 <Suspense fallback={<Skeleton className="inline-block h-3.5 w-72 align-middle" />}>
171 <Await resolve={head}>
172 {([work, att]) => (
173 <>
174 {work ? daySentence(work) : "Today's agent work couldn't be read."} {waitingSentence(att.rows.length)}
175 </>
176 )}
177 </Await>
178 </Suspense>
179 </p>
180 </div>
181 <Suspense fallback={<ReviewButton count={null} />}>
182 <Await resolve={waiting}>{(att) => <ReviewButton count={att.rows.length} />}</Await>
183 </Suspense>
184 </header>
185
186 <AskG1t slug={slug} available={orchestrator} error={actionData?.error ?? null} />
187
188 <Suspense fallback={<DaySkeleton />}>
189 <Await resolve={day}>{(work) => <DayCard work={work} />}</Await>
190 </Suspense>
191
192 {/* Phones put the one place to start first; wide screens keep it beside the list. */}
193 <div className="grid items-start gap-5 lg:grid-cols-[minmax(0,1.45fr)_minmax(0,1fr)]">
194 <div className="lg:col-start-1 lg:row-span-2 lg:row-start-1">
195 <Card title="Needs attention" link={{ label: "Notifications", to: "/notifications" }}>
196 <Suspense fallback={<RowsSkeleton />}>
197 <Await resolve={waiting}>{(att) => <AttentionList attention={att} />}</Await>
198 </Suspense>
199 <Suspense fallback={null}>
200 <Await resolve={day}>{(work) => <Sources work={work} />}</Await>
201 </Suspense>
202 </Card>
203 </div>
204 <div className="order-first lg:order-none lg:col-start-2 lg:row-start-2">
205 <Suspense
206 fallback={
207 <Card title="Start here">
208 <RowsSkeleton rows={1} />
209 </Card>
210 }
211 >
212 <Await resolve={waiting}>{(att) => <StartHere attention={att} now={now} />}</Await>
213 </Suspense>
214 </div>
215 <div className="lg:col-start-2 lg:row-start-1">
216 <Suspense
217 fallback={
218 <Card title="Spent today">
219 <SpendSkeleton />
220 </Card>
221 }
222 >
223 <Await resolve={spend}>{(value) => <SpendCard spend={value} slug={slug} />}</Await>
224 </Suspense>
225 </div>
226 </div>
227 </div>
228 );
229}
230
231/** What's waiting, one click away: loud while there is something, quiet when there isn't. */
232function ReviewButton({ count }: { count: number | null }) {
233 return (
234 <Link
235 to="/notifications"
236 className={`inline-flex h-9 shrink-0 items-center gap-1.5 self-start rounded-lg px-3.5 text-sm font-medium transition-colors ${
237 count ? "bg-accent text-bg hover:bg-accent-hover" : "border border-line text-fg/90 hover:border-line-strong hover:bg-raised"
238 }`}
239 >
240 {count ? `Review ${count.toLocaleString("en-US")}` : "Notifications"}
241 <ArrowRight size={14} />
242 </Link>
243 );
244}