Skip to content
248 linesCodeBlameRaw
1import { Bell, Building2, Code2, House, LayoutGrid, Lock, MessagesSquare, Pin, Search, Shapes, SlidersHorizontal, Sparkles, Users } from "lucide-react";
2import { type ReactNode, useEffect, useMemo, useState } from "react";
3import { Link, useFetcher, useFetchers } from "react-router";
4
5import { customizePath } from "@g1t/contracts/customize";
6
7import { ConnectorMark } from "./connectors";
8import { Card } from "./ui/card";
9import { Skeleton } from "./ui/skeleton";
10import { type BuiltinApp, type InstalledAppData, type PinnableApp, appPinFromForm, installedAppOf, withPin } from "../lib/apps";
11import { cn } from "../lib/cn";
12import { Hint } from "./ui/hint";
13
14/** Each built-in app's icon, at the size asked for. */
15export function appIcon(key: BuiltinApp, size = 18): ReactNode {
16 const icons: Record<BuiltinApp, ReactNode> = {
17 home: <House size={size} />,
18 chat: <MessagesSquare size={size} />,
19 notifications: <Bell size={size} />,
20 agents: <Sparkles size={size} />,
21 code: <Code2 size={size} />,
22 artifacts: <Shapes size={size} />,
23 people: <Users size={size} />,
24 workspace: <Building2 size={size} />,
25 };
26 return icons[key];
27}
28
29/** An installed app's mark: its connector's, or its letters on a steady colour. */
30export function InstalledMark({ app, size = 22 }: { app: { connector: InstalledAppData["connector"] }; size?: number }) {
31 return <ConnectorMark view={{ id: app.connector.id, name: app.connector.name, provider: app.connector.provider ?? null }} size={size} />;
32}
33
34/** A pinned app as the rail draws it, from its key alone; null when the key names nothing installable. */
35export function pinnedApp(key: PinnableApp, slug: string): { key: string; name: string; to: string; icon: (size: number) => ReactNode } | null {
36 const app = installedAppOf(key);
37 if (!app) return null;
38 return { key, name: app.name, to: app.path(slug), icon: (size) => <InstalledMark app={app} size={size} /> };
39}
40
41/** Every pin form shares this key, so the rail, the launcher and the Apps page see one change. */
42const PIN_FETCHER = "app-pin";
43
44/**
45 * Someone's pins in the workspace `slug`, as saved (`saved`, from the
46 * root's data), with any pin or unpin on its way already applied, so the
47 * rail changes the moment a pin is pressed. `toggle` pins or unpins.
48 */
49export function useAppPins(slug: string, saved: PinnableApp[]) {
50 const fetcher = useFetcher({ key: PIN_FETCHER });
51 const fetchers = useFetchers();
52 const pins = useMemo(() => {
53 let pins = saved;
54 for (const pending of fetchers) {
55 if (pending.key !== PIN_FETCHER || !pending.formData) continue;
56 const change = appPinFromForm(pending.formData);
57 if (change) pins = withPin(pins, change.app, change.pinned);
58 }
59 return pins;
60 }, [saved, fetchers]);
61 const toggle = (app: PinnableApp) => {
62 const form = new FormData();
63 form.set("intent", pins.includes(app) ? "unpin" : "pin");
64 form.set("app", app);
65 fetcher.submit(form, { method: "post", action: `/${slug}/-/apps` });
66 };
67 return { pins, toggle };
68}
69
70/**
71 * One installed app: its mark and name, opening it; and a pin that shows
72 * on hover or focus and stays lit while it is pinned. An app the person
73 * can't use says Request access instead, and has no pin.
74 */
75export function AppTile({
76 app,
77 pinned,
78 onToggle,
79 onOpen,
80 card = false,
81}: {
82 app: InstalledAppData;
83 pinned: boolean;
84 onToggle: (app: PinnableApp) => void;
85 onOpen?: () => void;
86 /** On the Apps page: a card with what the app is. */
87 card?: boolean;
88}) {
89 const body = (
90 <>
91 <span className="relative flex shrink-0">
92 <InstalledMark app={app} size={40} />
93 {!app.usable && (
94 <span className="absolute -right-1 -bottom-1 flex size-4 items-center justify-center rounded-full bg-surface text-faint ring-1 ring-line">
95 <Lock size={9} />
96 </span>
97 )}
98 </span>
99 {card ? (
100 <span className="min-w-0">
101 <span className="flex min-w-0 items-baseline gap-2">
102 <span className="truncate text-sm font-medium text-fg">{app.name}</span>
103 <span className="shrink-0 text-[0.6875rem] text-faint">{app.from}</span>
104 </span>
105 <span className="block truncate text-xs text-muted">{app.usable ? app.about : "Request access"}</span>
106 </span>
107 ) : (
108 <span className="flex w-full min-w-0 flex-col items-center gap-1">
109 <span className="block w-full truncate text-xs font-medium text-fg-soft">{app.name}</span>
110 <span className={cn("block truncate text-[0.625rem]", app.usable ? "text-faint" : "text-accent")}>{app.usable ? app.from : "Request access"}</span>
111 </span>
112 )}
113 </>
114 );
115 const linkClass = cn(
116 "flex w-full rounded-xl text-center outline-none focus-visible:ring-2 focus-visible:ring-accent",
117 card ? "items-center gap-3 p-3.5 pr-11 text-left" : "flex-col items-center gap-1.5 px-1 pt-3 pb-2.5",
118 );
119 return (
120 <div
121 className={cn(
122 "group/tile relative rounded-xl transition-colors",
123 card ? "border border-line bg-surface hover:border-line-strong" : "hover:bg-raised focus-within:bg-raised",
124 )}
125 >
126 {/* An app the person can't use leads (`href`) to where they ask for it. */}
127 <Link to={app.href} prefetch="intent" onClick={onOpen} className={linkClass}>
128 {body}
129 </Link>
130 {app.usable && app.pinnable && (
131 <Hint label={pinned ? "Unpin from your rail" : "Pin to your rail"}>
132 <button
133 type="button"
134 aria-label={pinned ? `Unpin ${app.name} from your rail` : `Pin ${app.name} to your rail`}
135 aria-pressed={pinned}
136 onClick={() => onToggle(app.key)}
137 className={cn(
138 "absolute flex size-6 items-center justify-center rounded-md outline-none transition-opacity hover:bg-line focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-accent [@media(hover:none)]:opacity-100",
139 card ? "top-1/2 right-2.5 -translate-y-1/2" : "top-1 right-1",
140 pinned ? "text-accent opacity-100" : "text-faint opacity-0 group-hover/tile:opacity-100 hover:text-fg",
141 )}
142 >
143 <Pin size={13} className={pinned ? "fill-current" : undefined} />
144 </button>
145 </Hint>
146 )}
147 </div>
148 );
149}
150
151/** When nothing is installed: what Apps are, and the way to Customize. */
152export function NoApps({ slug, onOpen, compact = false }: { slug: string; onOpen?: () => void; compact?: boolean }) {
153 return (
154 <div className={cn("flex flex-col items-center text-center", compact ? "px-4 py-6" : "rounded-xl border border-dashed border-line px-6 py-12")}>
155 <span className="flex size-10 items-center justify-center rounded-xl bg-raised text-muted ring-1 ring-line">
156 <SlidersHorizontal size={18} />
157 </span>
158 <p className="mt-3 text-sm font-medium">No apps installed yet</p>
159 <p className="mt-1 max-w-xs text-[0.8125rem] text-muted">Apps come from Customize: the plugins your workspace installs, and the connectors and integrations it connects. Each one you can use shows here.</p>
160 <Link to={customizePath("", slug)} onClick={onOpen} className="mt-4 inline-flex h-8 items-center gap-1.5 rounded-md bg-fg px-3 text-[0.8125rem] font-medium text-bg hover:bg-fg-hover">
161 <SlidersHorizontal size={14} />
162 Customize
163 </Link>
164 </div>
165 );
166}
167
168/**
169 * The Apps launcher's body: a search, every app the workspace installed
170 * that the person can use (read from the Apps page as it opens), and the
171 * way to all of them and to Customize.
172 */
173export function AppsLauncher({ slug, pins, onToggle, onClose }: { slug: string; pins: PinnableApp[]; onToggle: (app: PinnableApp) => void; onClose: () => void }) {
174 const [query, setQuery] = useState("");
175 const loader = useFetcher<{ apps: InstalledAppData[] | null }>();
176 useEffect(() => {
177 if (loader.state === "idle" && !loader.data) loader.load(`/${slug}/-/apps`);
178 // Once per opening: the launcher's body mounts when it opens.
179 // eslint-disable-next-line react-hooks/exhaustive-deps
180 }, [slug]);
181 const installed = loader.data?.apps;
182 const wanted = query.trim().toLowerCase();
183 const apps = (installed ?? []).filter((app) => !wanted || app.name.toLowerCase().includes(wanted) || app.about.toLowerCase().includes(wanted));
184 const loading = installed == null && !loader.data;
185 const failed = installed == null && !!loader.data;
186 return (
187 <div className="flex flex-col gap-2">
188 {/* The search is there from the first frame, so the launcher keeps its shape while the apps are read. */}
189 {(loading || (installed && installed.length > 0)) && (
190 <Card asChild tone="bg" radius="lg" className="flex h-9 items-center gap-2 px-2.5 text-sm focus-within:border-line-strong">
191 <label>
192 <Search size={15} className="shrink-0 text-faint" />
193 <input
194 value={query}
195 onChange={(event) => setQuery(event.target.value)}
196 placeholder="Find an app"
197 aria-label="Find an app"
198 autoComplete="off"
199 className="min-w-0 grow bg-transparent outline-none placeholder:text-faint"
200 />
201 </label>
202 </Card>
203 )}
204 {failed ? (
205 <p className="px-2 py-6 text-center text-sm text-muted">Apps can't be listed right now. Try again in a moment.</p>
206 ) : loading ? (
207 <div className="grid grid-cols-4 gap-1 max-sm:grid-cols-3" aria-busy="true" aria-label="Loading apps">
208 {Array.from({ length: 4 }, (_, n) => (
209 <div key={n} className="flex flex-col items-center gap-2 px-1 pt-3 pb-2.5">
210 {/* On the launcher's raised surface the usual raised block would vanish: a line-coloured one shows. */}
211 <Skeleton className="size-10 rounded-xl bg-line" />
212 <Skeleton className="h-2.5 w-12 bg-line" />
213 <Skeleton className="h-2 w-8 bg-line" />
214 </div>
215 ))}
216 </div>
217 ) : installed!.length === 0 ? (
218 <NoApps slug={slug} onOpen={onClose} compact />
219 ) : apps.length === 0 ? (
220 <p className="px-2 py-6 text-center text-sm text-muted">No installed app matches.</p>
221 ) : (
222 <div className="grid max-h-[min(23.5rem,60dvh)] grid-cols-4 gap-1 overflow-y-auto max-sm:grid-cols-3">
223 {apps.map((app) => (
224 <AppTile key={app.key} app={app} pinned={pins.includes(app.key)} onToggle={onToggle} onOpen={onClose} />
225 ))}
226 </div>
227 )}
228 <div className="flex items-center justify-between border-t border-line pt-2">
229 <Link
230 to={`/${slug}/-/apps`}
231 onClick={onClose}
232 className="flex h-8 items-center gap-1.5 rounded-md px-2 text-[0.8125rem] text-muted transition-colors hover:bg-line hover:text-fg"
233 >
234 <LayoutGrid size={14} />
235 All apps
236 </Link>
237 <Link
238 to={customizePath("", slug)}
239 onClick={onClose}
240 className="flex h-8 items-center gap-1.5 rounded-md px-2 text-[0.8125rem] text-muted transition-colors hover:bg-line hover:text-fg"
241 >
242 <SlidersHorizontal size={14} />
243 Customize
244 </Link>
245 </div>
246 </div>
247 );
248}