Skip to content
216 linesCodeBlameRaw
1import {
2 BarChart3,
3 Bell,
4 Boxes,
5 Brain,
6 Building2,
7 Code2,
8 History,
9 LayoutGrid,
10 MessagesSquare,
11 Network,
12 Package,
13 Pin,
14 Plug,
15 Search,
16 Shapes,
17 ShieldCheck,
18 Sparkles,
19 Store,
20 Sun,
21 Users,
22 UsersRound,
23 Waypoints,
24} from "lucide-react";
25import { type ReactNode, useMemo, useState } from "react";
26import { Link, useFetcher, useFetchers } from "react-router";
27
28import { type AppInfo, type AppKey, type PinnableApp, appPinFromForm, appsFor, withPin } from "../lib/apps";
29import { cn } from "../lib/cn";
30import { Hint } from "./ui/hint";
31
32/** Each app's icon, at the size asked for. */
33export function appIcon(key: AppKey, size = 18): ReactNode {
34 const icons: Record<AppKey, ReactNode> = {
35 today: <Sun size={size} />,
36 chat: <MessagesSquare size={size} />,
37 notifications: <Bell size={size} />,
38 agents: <Sparkles size={size} />,
39 code: <Code2 size={size} />,
40 artifacts: <Shapes size={size} />,
41 people: <Users size={size} />,
42 workspace: <Building2 size={size} />,
43 projects: <Boxes size={size} />,
44 packages: <Package size={size} />,
45 security: <ShieldCheck size={size} />,
46 context: <Network size={size} />,
47 memory: <Brain size={size} />,
48 teams: <UsersRound size={size} />,
49 usage: <BarChart3 size={size} />,
50 gateway: <Waypoints size={size} />,
51 integrations: <Plug size={size} />,
52 audit: <History size={size} />,
53 };
54 return icons[key];
55}
56
57/** Every pin form shares this key, so the dock, the launcher and the Apps page see one change. */
58const PIN_FETCHER = "app-pin";
59
60/**
61 * Someone's pins in the workspace `slug`, as saved (`saved`, from the
62 * root's data), with any pin or unpin on its way already applied, so the
63 * dock changes the moment a pin is pressed. `toggle` pins or unpins.
64 */
65export function useAppPins(slug: string, saved: PinnableApp[]) {
66 const fetcher = useFetcher({ key: PIN_FETCHER });
67 const fetchers = useFetchers();
68 const pins = useMemo(() => {
69 let pins = saved;
70 for (const pending of fetchers) {
71 if (pending.key !== PIN_FETCHER || !pending.formData) continue;
72 const change = appPinFromForm(pending.formData);
73 if (change) pins = withPin(pins, change.app, change.pinned);
74 }
75 return pins;
76 }, [saved, fetchers]);
77 const toggle = (app: PinnableApp) => {
78 const form = new FormData();
79 form.set("intent", pins.includes(app) ? "unpin" : "pin");
80 form.set("app", app);
81 fetcher.submit(form, { method: "post", action: `/${slug}/-/apps` });
82 };
83 return { pins, toggle };
84}
85
86/**
87 * One app: its icon and name, opening it; and, for an app people pin, a
88 * pin that shows on hover or focus and stays lit while it is pinned.
89 */
90export function AppTile({
91 app,
92 slug,
93 pinned,
94 onToggle,
95 onOpen,
96 card = false,
97}: {
98 app: AppInfo;
99 slug: string;
100 pinned: boolean;
101 onToggle: (app: PinnableApp) => void;
102 onOpen?: () => void;
103 /** On the Apps page: a card with what the app is. */
104 card?: boolean;
105}) {
106 return (
107 <div
108 className={cn(
109 "group/tile relative rounded-xl transition-colors",
110 card ? "border border-line bg-surface hover:border-line-strong" : "hover:bg-raised focus-within:bg-raised",
111 )}
112 >
113 <Link
114 to={app.path(slug)}
115 prefetch="intent"
116 onClick={onOpen}
117 className={cn(
118 "flex w-full rounded-xl text-center outline-none focus-visible:ring-2 focus-visible:ring-accent",
119 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",
120 )}
121 >
122 <span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-raised text-fg ring-1 ring-line group-hover/tile:ring-line-strong">
123 {appIcon(app.key, 19)}
124 </span>
125 {card ? (
126 <span className="min-w-0">
127 <span className="block truncate text-sm font-medium text-fg">{app.name}</span>
128 <span className="block truncate text-xs text-muted">{app.about}</span>
129 </span>
130 ) : (
131 <span className="w-full truncate text-xs font-medium text-fg-soft">{app.name}</span>
132 )}
133 </Link>
134 {!app.builtin && (
135 <Hint label={pinned ? "Unpin from your dock" : "Pin to your dock"}>
136 <button
137 type="button"
138 aria-label={pinned ? `Unpin ${app.name} from your dock` : `Pin ${app.name} to your dock`}
139 aria-pressed={pinned}
140 onClick={() => onToggle(app.key as PinnableApp)}
141 className={cn(
142 "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",
143 card ? "top-1/2 right-2.5 -translate-y-1/2" : "top-1 right-1",
144 pinned ? "text-accent opacity-100" : "text-faint opacity-0 group-hover/tile:opacity-100 hover:text-fg",
145 )}
146 >
147 <Pin size={13} className={pinned ? "fill-current" : undefined} />
148 </button>
149 </Hint>
150 )}
151 </div>
152 );
153}
154
155/**
156 * The Apps launcher's body: a search, every app in the workspace the
157 * person can use, and the way to all of them and to the Marketplace.
158 */
159export function AppsLauncher({
160 slug,
161 code,
162 pins,
163 onToggle,
164 onClose,
165}: {
166 slug: string;
167 code: boolean;
168 pins: PinnableApp[];
169 onToggle: (app: PinnableApp) => void;
170 onClose: () => void;
171}) {
172 const [query, setQuery] = useState("");
173 const wanted = query.trim().toLowerCase();
174 const apps = appsFor(code).filter((app) => !wanted || app.name.toLowerCase().includes(wanted) || app.about.toLowerCase().includes(wanted));
175 return (
176 <div className="flex flex-col gap-2">
177 <label className="flex h-9 items-center gap-2 rounded-lg border border-line bg-bg px-2.5 text-sm focus-within:border-line-strong">
178 <Search size={15} className="shrink-0 text-faint" />
179 <input
180 value={query}
181 onChange={(event) => setQuery(event.target.value)}
182 placeholder="Find an app"
183 aria-label="Find an app"
184 autoComplete="off"
185 className="min-w-0 grow bg-transparent outline-none placeholder:text-faint"
186 />
187 </label>
188 {apps.length === 0 ? (
189 <p className="px-2 py-6 text-center text-sm text-muted">No app matches.</p>
190 ) : (
191 <div className="grid max-h-[min(23.5rem,60dvh)] grid-cols-4 gap-1 overflow-y-auto max-sm:grid-cols-3">
192 {apps.map((app) => (
193 <AppTile key={app.key} app={app} slug={slug} pinned={pins.includes(app.key as PinnableApp)} onToggle={onToggle} onOpen={onClose} />
194 ))}
195 </div>
196 )}
197 <div className="flex items-center justify-between border-t border-line pt-2">
198 <Link
199 to={`/${slug}/-/apps`}
200 onClick={onClose}
201 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"
202 >
203 <LayoutGrid size={14} />
204 All apps
205 </Link>
206 <Hint label="There is no marketplace yet." disabled>
207 <button type="button" disabled className="flex h-8 items-center gap-1.5 rounded-md px-2 text-[0.8125rem] text-faint">
208 <Store size={14} />
209 Marketplace
210 <span className="rounded-full bg-line px-1.5 text-[0.625rem] font-medium text-muted">Coming</span>
211 </button>
212 </Hint>
213 </div>
214 </div>
215 );
216}