Skip to content
143 linesCodeBlameRaw
1import { ArrowRight, BookOpen, Hash, Inbox, Lock, MessagesSquare, Sparkles } from "lucide-react";
2import type { ReactNode } from "react";
3import { Link, data } from "react-router";
4
5import type { ChatSidebarEntry } 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 { channelPath, sections } from "../../lib/chat";
11import { sidebarOrNull } from "../../lib/chat.server";
12import { page } from "../../lib/meta";
13import { inbox, workspaceAgents } from "../../lib/services.server";
14import { requireUser, roleIn } from "../../lib/session.server";
15
16export function meta({ params, ...args }: Route.MetaArgs) {
17 return page(args, { title: `Home · ${params.owner} · g1t` });
18}
19
20/**
21 * Home for the workspace without Code (docs/WORKSPACE.md, "Members without
22 * Code"): the conversations waiting, the agents, Docs and the inbox, in
23 * place of projects. Anyone in the workspace can open it.
24 */
25export async function loader({ params, context, request }: Route.LoaderArgs) {
26 const viewer = requireUser(context, request);
27 if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
28 const slug = params.owner.toLowerCase();
29 const [sidebar, agents, counts] = await Promise.all([
30 sidebarOrNull(slug, viewer),
31 workspaceAgents.list(slug, viewer).catch(() => null),
32 inbox.counts(viewer.username).catch(() => null),
33 ]);
34 return {
35 slug,
36 name: viewer.username,
37 sidebar,
38 agents: agents?.ok ? agents.value.filter((a) => !a.archived_at) : null,
39 unreadInbox: counts?.unread ?? 0,
40 };
41}
42
43function Card({ title, icon, to, action, children }: { title: string; icon: ReactNode; to: string; action: string; children: ReactNode }) {
44 return (
45 <section className="flex flex-col rounded-2xl border border-line bg-surface">
46 <header className="flex items-center gap-2.5 border-b border-line px-5 py-3.5">
47 <span className="text-muted">{icon}</span>
48 <h2 className="text-sm font-semibold">{title}</h2>
49 <Link to={to} className="ml-auto flex items-center gap-1 text-xs text-muted hover:text-fg">
50 {action}
51 <ArrowRight size={12} />
52 </Link>
53 </header>
54 <div className="grow p-2">{children}</div>
55 </section>
56 );
57}
58
59function ConversationRow({ entry, slug }: { entry: ChatSidebarEntry; slug: string }) {
60 const other = entry.others[0];
61 return (
62 <Link to={channelPath(slug, entry.channel)} className="flex h-10 items-center gap-2.5 rounded-lg px-3 text-sm transition-colors hover:bg-raised/60">
63 <span className="flex w-5 justify-center text-faint">
64 {entry.channel.kind === "dm" && other ? <MemberAvatar member={other} size={20} /> : entry.channel.private ? <Lock size={14} /> : <Hash size={15} />}
65 </span>
66 <span className={`min-w-0 grow truncate ${entry.unread > 0 ? "font-semibold text-fg" : "text-muted"}`}>{entry.title}</span>
67 {entry.unread > 0 && (
68 <span className={`rounded-full px-1.5 text-[0.6875rem] leading-[1.125rem] font-semibold tabular-nums ${entry.mentions ? "bg-accent text-bg" : "bg-line-strong text-fg"}`}>
69 {entry.mentions || entry.unread}
70 </span>
71 )}
72 </Link>
73 );
74}
75
76export default function WorkspaceHome({ loaderData }: Route.ComponentProps) {
77 const { slug, name, sidebar, agents, unreadInbox } = loaderData;
78 const all = sidebar?.entries ?? [];
79 const waiting = all.filter((e) => !e.muted && e.unread > 0);
80 const { dms } = sections(all);
81 return (
82 <div>
83 <header className="mb-8">
84 <h1 className="text-2xl font-semibold tracking-tight">Welcome back, {name}</h1>
85 <p className="mt-1.5 text-sm text-muted">
86 {waiting.length > 0
87 ? `${waiting.length} ${waiting.length === 1 ? "conversation has" : "conversations have"} something new.`
88 : "You're caught up in chat."}
89 {unreadInbox > 0 ? ` ${unreadInbox} unread in your inbox.` : ""}
90 </p>
91 </header>
92 <div className="grid gap-4 lg:grid-cols-2">
93 <Card title="Unread" icon={<MessagesSquare size={16} />} to={`/${slug}/-/chat`} action="Open chat">
94 {sidebar == null ? (
95 <p className="px-3 py-6 text-sm text-muted">Chat didn&apos;t answer. Your conversations show here once it does.</p>
96 ) : waiting.length === 0 ? (
97 <p className="px-3 py-6 text-sm text-muted">Nothing new. Conversations with new messages show here.</p>
98 ) : (
99 waiting.slice(0, 6).map((entry) => <ConversationRow key={entry.channel.id} entry={entry} slug={slug} />)
100 )}
101 </Card>
102 <Card title="Direct messages" icon={<MessagesSquare size={16} />} to={`/${slug}/-/chat`} action="All">
103 {dms.length === 0 ? (
104 <p className="px-3 py-6 text-sm text-muted">Message a teammate or an agent from Chat.</p>
105 ) : (
106 dms.slice(0, 6).map((entry) => <ConversationRow key={entry.channel.id} entry={entry} slug={slug} />)
107 )}
108 </Card>
109 <Card title="Agents" icon={<Sparkles size={16} />} to={`/${slug}/-/agents`} action="All agents">
110 {agents == null || agents.length === 0 ? (
111 <p className="px-3 py-6 text-sm text-muted">
112 {agents == null ? "The agents service didn't answer." : "No agents yet. Ask an owner to add one, or make one from a template."}
113 </p>
114 ) : (
115 agents.slice(0, 5).map((agent) => (
116 <Link key={agent.id} to={`/${slug}/-/agents/${agent.handle}`} className="flex h-12 items-center gap-3 rounded-lg px-3 transition-colors hover:bg-raised/60">
117 <AgentFace agent={agent} size={26} />
118 <span className="min-w-0 grow">
119 <span className="block truncate text-sm font-medium">{agent.display_name}</span>
120 <span className="block truncate text-xs text-faint">{agent.role}</span>
121 </span>
122 <span className="flex items-center gap-1.5 text-xs text-muted">
123 <StatusDot status={agent.status} />
124 {statusLabel(agent.status)}
125 </span>
126 </Link>
127 ))
128 )}
129 </Card>
130 <div className="grid gap-4">
131 <Card title="Inbox" icon={<Inbox size={16} />} to="/inbox" action="Open">
132 <p className="px-3 py-4 text-sm text-muted">
133 {unreadInbox > 0 ? `${unreadInbox} unread: mentions, approvals and what needs you.` : "Nothing waiting on you."}
134 </p>
135 </Card>
136 <Card title="Docs" icon={<BookOpen size={16} />} to={`/${slug}/-/docs`} action="Learn more">
137 <p className="px-3 py-4 text-sm text-muted">Specs, runbooks and decisions, kept current by agents. Coming soon.</p>
138 </Card>
139 </div>
140 </div>
141 </div>
142 );
143}