| 1 | import { ArrowRight } from "lucide-react"; |
| 2 | import { Suspense, 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 { AttentionList, Card, DayCard, DaySkeleton, RowsSkeleton, Sources, SpendCard, SpendSkeleton, StartHere } from "../../components/today"; |
| 9 | import { G1tMark, isOrchestrator } from "../../components/orchestrator"; |
| 10 | import { Skeleton } from "../../components/ui/skeleton"; |
| 11 | import { channelPath } from "../../lib/chat"; |
| 12 | import { sidebarOrNull } from "../../lib/chat.server"; |
| 13 | import { readCookie } from "../../lib/mission"; |
| 14 | import { loadMissionControl } from "../../lib/mission-control.server"; |
| 15 | import { page } from "../../lib/meta"; |
| 16 | import { chat, inbox, workspaceAgents } from "../../lib/services.server"; |
| 17 | import { assertSameOrigin, requireUser, roleIn } from "../../lib/session.server"; |
| 18 | import { type Attention, type DayWork, attention, daySentence, dayWork, shortDate, waitingSentence } from "../../lib/today"; |
| 19 | import { loadCapped, loadCodeWork, loadSessions, loadSpend } from "../../lib/today.server"; |
| 20 | |
| 21 | export function meta({ params, ...args }: Route.MetaArgs) { |
| 22 | return page(args, { title: `Today · ${params.owner} · g1t` }); |
| 23 | } |
| 24 | |
| 25 | /** Unread notifications read for Needs attention. */ |
| 26 | const 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 | */ |
| 35 | export 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 | */ |
| 101 | export 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. */ |
| 120 | function 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 | |
| 161 | export 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. */ |
| 232 | function 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 | } |