Skip to content
322 linesCodeBlameRaw
1import { ArrowRight, AtSign, GitPullRequest, Inbox, MessagesSquare, Sparkles } from "lucide-react";
2import { type ReactNode, Suspense, useEffect, useState } from "react";
3import { Await, Form, Link, data, redirect, useNavigation } from "react-router";
4
5import { type ChatSidebarEntry, type InboxItem, type WorkspaceAgent, hasCodeAccess } from "@g1t/contracts";
6
7import type { Route } from "./+types/home";
8import { AgentFace } from "../../components/agents-mode";
9import { MemberAvatar, StatusDot, statusLabel } from "../../components/chat/marks";
10import { InboxCard } from "../../components/inbox";
11import { G1tMark, isOrchestrator } from "../../components/orchestrator";
12import { Skeleton } from "../../components/ui/skeleton";
13import { channelPath } from "../../lib/chat";
14import { sidebarOrNull } from "../../lib/chat.server";
15import { greetingFor, hourIn, readCookie } from "../../lib/mission";
16import type { NeedRow } from "../../lib/mission-control";
17import { loadMissionControl } from "../../lib/mission-control.server";
18import { page } from "../../lib/meta";
19import { chat, inbox, workspaceAgents } from "../../lib/services.server";
20import { assertSameOrigin, requireUser, roleIn } from "../../lib/session.server";
21
22export function meta({ params, ...args }: Route.MetaArgs) {
23 return page(args, { title: `Home · ${params.owner} · g1t` });
24}
25
26/** Unread inbox items read, and how many Home shows. */
27const INBOX_READ = 30;
28const INBOX_SHOWN = 4;
29/** Most rows in each of Home's lists. */
30const ROWS = 5;
31
32/**
33 * Home: what needs you across the workspace, never one mode's sidebar.
34 * Each section is read at once and on its own, and one whose service does
35 * not answer is left saying so. Reviews and approvals come from Code's
36 * Overview, the slowest part, so they arrive after the page does.
37 */
38export async function loader({ params, context, request }: Route.LoaderArgs) {
39 const viewer = requireUser(context, request);
40 const slug = params.owner.toLowerCase();
41 if (!roleIn(viewer, slug)) throw data(null, { status: 404 });
42 const membership = viewer.workspaces?.find((m) => m.slug === slug) ?? null;
43 const code = hasCodeAccess(membership);
44 // Streamed: the page shows without waiting for it.
45 const reviews: Promise<NeedRow[] | null> | null = code
46 ? loadMissionControl(viewer, request, slug)
47 .then(({ value }) => value.needs.slice(0, ROWS))
48 .catch(() => null)
49 : null;
50 const [sidebar, agents, unread] = await Promise.all([
51 sidebarOrNull(slug, viewer),
52 workspaceAgents.list(slug, viewer).catch(() => null),
53 inbox.list(viewer, { unread: true, limit: INBOX_READ }).catch(() => null),
54 ]);
55 const tz = readCookie(request.headers.get("cookie"), "g1t_tz");
56 const listed = agents?.ok ? agents.value.filter((agent) => !agent.archived_at) : null;
57 const items: InboxItem[] | null = unread?.items ?? null;
58 return {
59 slug,
60 code,
61 name: viewer.username,
62 greeting: greetingFor(hourIn(Date.now(), tz)),
63 chat: sidebar
64 ? sidebar.entries
65 .filter((entry) => !entry.muted && (entry.mentions > 0 || (entry.channel.kind === "dm" && entry.unread > 0)))
66 .sort((a, b) => b.mentions - a.mentions || (b.channel.last_message_at ?? "").localeCompare(a.channel.last_message_at ?? ""))
67 : null,
68 agents: listed,
69 orchestrator: listed?.find(isOrchestrator) ?? null,
70 inbox: items ? [...items].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)).slice(0, INBOX_SHOWN) : null,
71 inboxTotal: items?.length ?? 0,
72 reviews,
73 };
74}
75
76/**
77 * Ask g1t: the direct message with the workspace's orchestrator, opened or
78 * made, with what was asked posted in it; then that conversation.
79 */
80export async function action({ params, context, request }: Route.ActionArgs) {
81 assertSameOrigin(request);
82 const viewer = requireUser(context, request);
83 const slug = params.owner.toLowerCase();
84 if (!roleIn(viewer, slug)) throw data(null, { status: 404 });
85 const form = await request.formData();
86 const body = String(form.get("body") ?? "").trim();
87 if (!body) return { error: "Write what you need." };
88 const listed = await workspaceAgents.list(slug, viewer).catch(() => null);
89 const g1t = listed?.ok ? listed.value.find(isOrchestrator) : null;
90 if (!g1t) return { error: "g1t isn't reachable right now. Try again in a moment." };
91 const dm = await chat.openDm(slug, viewer, [{ kind: "agent", id: g1t.id }]).catch(() => null);
92 if (!dm?.ok) return { error: dm ? dm.error.message : "Chat didn't answer. Try again in a moment." };
93 const posted = await chat.post(slug, dm.value.id, viewer, { body }).catch(() => null);
94 if (!posted?.ok) return { error: posted ? posted.error.message : "Your message didn't send. Try again." };
95 throw redirect(channelPath(slug, dm.value));
96}
97
98function Section({
99 icon,
100 title,
101 count,
102 to,
103 action,
104 children,
105}: {
106 icon: ReactNode;
107 title: string;
108 count?: number | null;
109 to: string;
110 action: string;
111 children: ReactNode;
112}) {
113 return (
114 <section className="flex flex-col rounded-2xl border border-line bg-surface">
115 <header className="flex items-center gap-2.5 border-b border-line px-5 py-3.5">
116 <span className="text-muted">{icon}</span>
117 <h3 className="text-sm font-semibold">{title}</h3>
118 {count != null && count > 0 && (
119 <span className="rounded-full bg-accent/15 px-1.5 text-[0.6875rem] font-semibold text-accent tabular-nums">{count}</span>
120 )}
121 <Link to={to} className="ml-auto flex items-center gap-1 text-xs text-muted hover:text-fg">
122 {action}
123 <ArrowRight size={12} />
124 </Link>
125 </header>
126 <div className="grow p-2">{children}</div>
127 </section>
128 );
129}
130
131function Quiet({ children }: { children: ReactNode }) {
132 return <p className="px-3 py-6 text-sm text-muted">{children}</p>;
133}
134
135function ChatRow({ entry, slug }: { entry: ChatSidebarEntry; slug: string }) {
136 const other = entry.others[0];
137 return (
138 <Link to={channelPath(slug, entry.channel)} className="flex h-11 items-center gap-3 rounded-lg px-3 transition-colors hover:bg-raised/60">
139 <span className="flex w-6 justify-center">
140 {entry.channel.kind === "dm" && other ? <MemberAvatar member={other} size={22} /> : <span className="font-semibold text-faint">#</span>}
141 </span>
142 <span className="min-w-0 grow">
143 <span className="block truncate text-sm font-medium text-fg">{entry.title}</span>
144 <span className="block truncate text-xs text-faint">
145 {entry.mentions > 0 ? `${entry.mentions} ${entry.mentions === 1 ? "mention" : "mentions"}` : `${entry.unread} unread`}
146 </span>
147 </span>
148 <span
149 className={`rounded-full px-1.5 text-[0.6875rem] leading-[1.125rem] font-semibold tabular-nums ${entry.mentions > 0 ? "bg-accent text-bg" : "bg-line-strong text-fg"}`}
150 >
151 {entry.mentions || entry.unread}
152 </span>
153 </Link>
154 );
155}
156
157function AgentRow({ agent, slug }: { agent: WorkspaceAgent; slug: string }) {
158 return (
159 <Link to={`/${slug}/-/agents/${agent.handle}`} className="flex h-12 items-center gap-3 rounded-lg px-3 transition-colors hover:bg-raised/60">
160 <AgentFace agent={{ ...agent, builtin: isOrchestrator(agent) }} size={26} />
161 <span className="min-w-0 grow">
162 <span className="block truncate text-sm font-medium">{agent.display_name}</span>
163 <span className="block truncate text-xs text-faint">{agent.role}</span>
164 </span>
165 <span className="flex shrink-0 items-center gap-1.5 text-xs text-muted">
166 <StatusDot status={agent.status} />
167 {statusLabel(agent.status)}
168 </span>
169 </Link>
170 );
171}
172
173function ReviewRow({ row }: { row: NeedRow }) {
174 return (
175 <Link to={row.to} className="flex items-start gap-3 rounded-lg px-3 py-2.5 transition-colors hover:bg-raised/60">
176 <GitPullRequest size={16} className="mt-0.5 shrink-0 text-warn" />
177 <span className="min-w-0 grow">
178 <span className="block truncate text-sm font-medium text-fg">{row.title}</span>
179 <span className="line-clamp-2 text-xs text-muted">
180 {row.repo ? `${row.repo.name}${row.ref ? ` ${row.ref}` : ""} · ` : ""}
181 {row.ask}
182 </span>
183 </span>
184 </Link>
185 );
186}
187
188/** Ask g1t: the orchestrator, from Home. Enter sends; Shift+Enter is a new line. */
189function AskG1t({ slug, available, error }: { slug: string; available: boolean; error: string | null }) {
190 const navigation = useNavigation();
191 const busy = navigation.state !== "idle" && navigation.formAction === `/${slug}/-/home`;
192 const [text, setText] = useState("");
193 return (
194 <Form method="post" action={`/${slug}/-/home`} className="rounded-2xl border border-line-strong bg-surface transition-colors focus-within:border-accent/40">
195 <div className="flex items-center gap-2 px-4 pt-3.5 text-sm font-medium">
196 <G1tMark size={20} />
197 Ask g1t
198 <span className="hidden font-normal text-faint sm:inline">It knows the team and hands work to the right agent.</span>
199 </div>
200 <textarea
201 name="body"
202 value={text}
203 onChange={(event) => setText(event.target.value)}
204 onKeyDown={(event) => {
205 if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
206 event.preventDefault();
207 if (text.trim()) event.currentTarget.form?.requestSubmit();
208 }
209 }}
210 rows={2}
211 disabled={!available}
212 aria-label="Ask g1t"
213 placeholder={available ? "Get the flaky checks fixed and tell #support when it ships…" : "g1t isn't reachable right now."}
214 className="block w-full resize-none bg-transparent px-4 pt-2.5 pb-1 text-[0.9375rem] leading-relaxed text-fg outline-none placeholder:text-faint disabled:cursor-not-allowed"
215 />
216 <div className="flex items-center gap-3 px-3 pb-3">
217 {error ? <p className="text-sm text-danger">{error}</p> : <p className="hidden pl-1 text-xs text-faint sm:block">Opens your conversation with g1t in Chat.</p>}
218 <button
219 type="submit"
220 disabled={!available || busy || !text.trim()}
221 className="ml-auto inline-flex h-8 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"
222 >
223 {busy ? "Sending…" : "Ask"}
224 <ArrowRight size={14} />
225 </button>
226 </div>
227 </Form>
228 );
229}
230
231export default function WorkspaceHome({ loaderData, actionData }: Route.ComponentProps) {
232 const { slug, code, name, chat: mentions, agents, orchestrator, inbox: items, inboxTotal, reviews } = loaderData;
233 const [greeting, setGreeting] = useState(loaderData.greeting);
234 // The reader's own clock.
235 useEffect(() => setGreeting(greetingFor(new Date().getHours())), []);
236 const waiting = agents?.filter((agent) => agent.status === "waiting") ?? null;
237 return (
238 <div className="mx-auto max-w-5xl space-y-8">
239 <header>
240 <h1 className="text-[1.75rem] leading-tight font-semibold tracking-tight" suppressHydrationWarning>
241 {greeting}, {name}
242 </h1>
243 <p className="mt-1.5 text-sm text-muted">Everything that needs you, across chat, agents{code ? ", code" : ""} and your inbox.</p>
244 </header>
245
246 <AskG1t slug={slug} available={orchestrator != null} error={actionData?.error ?? null} />
247
248 <div>
249 <h2 className="mb-3 text-sm font-semibold text-fg-soft">Needs you across everything</h2>
250 <div className="grid gap-4 lg:grid-cols-2">
251 <Section icon={<AtSign size={15} />} title="Mentions and DMs" count={mentions?.length} to={`/${slug}/-/chat`} action="Open Chat">
252 {mentions == null ? (
253 <Quiet>Chat didn&apos;t answer. Your mentions show here once it does.</Quiet>
254 ) : mentions.length === 0 ? (
255 <Quiet>No unread mentions or messages.</Quiet>
256 ) : (
257 mentions.slice(0, ROWS).map((entry) => <ChatRow key={entry.channel.id} entry={entry} slug={slug} />)
258 )}
259 </Section>
260 <Section icon={<Sparkles size={15} />} title="Agents waiting on you" count={waiting?.length} to={`/${slug}/-/agents`} action="Open Agents">
261 {waiting == null ? (
262 <Quiet>The agents service didn&apos;t answer.</Quiet>
263 ) : waiting.length === 0 ? (
264 <Quiet>No agent is waiting on you.</Quiet>
265 ) : (
266 waiting.slice(0, ROWS).map((agent) => <AgentRow key={agent.id} agent={agent} slug={slug} />)
267 )}
268 </Section>
269 {code && reviews && (
270 <Section icon={<GitPullRequest size={15} />} title="Reviews and approvals" to={`/${slug}/-/overview`} action="Open Code">
271 <Suspense fallback={<RowsSkeleton />}>
272 <Await resolve={reviews}>
273 {(rows) =>
274 rows == null ? (
275 <Quiet>Code&apos;s reviews couldn&apos;t be read right now.</Quiet>
276 ) : rows.length === 0 ? (
277 <Quiet>Nothing to review or approve.</Quiet>
278 ) : (
279 rows.map((row) => <ReviewRow key={row.key} row={row} />)
280 )
281 }
282 </Await>
283 </Suspense>
284 </Section>
285 )}
286 <Section icon={<Inbox size={15} />} title="From your inbox" count={inboxTotal} to="/inbox" action="Open Inbox">
287 {items == null ? (
288 <Quiet>Your inbox couldn&apos;t be read right now.</Quiet>
289 ) : items.length === 0 ? (
290 <Quiet>Nothing unread.</Quiet>
291 ) : (
292 <ul className="space-y-2 p-1">
293 {items.map((item) => (
294 <InboxCard key={item.id} item={item} />
295 ))}
296 </ul>
297 )}
298 </Section>
299 </div>
300 </div>
301 {!code && (
302 <p className="flex items-center gap-2 text-xs text-faint">
303 <MessagesSquare size={13} />
304 Your membership includes Chat, Docs, Agents and the Inbox.
305 </p>
306 )}
307 </div>
308 );
309}
310
311function RowsSkeleton() {
312 return (
313 <div className="space-y-3 px-3 py-3" aria-busy="true">
314 {[70, 55, 62].map((w) => (
315 <div key={w} className="flex items-center gap-3">
316 <Skeleton className="size-4 rounded" />
317 <Skeleton className="h-3" style={{ width: `${w}%` }} />
318 </div>
319 ))}
320 </div>
321 );
322}