@loopbacktools/app 0.0.0-stage → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Bison Labs Limited
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,85 @@
1
- # Temporary Holding Version
1
+ # @loopbacktools/app
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The [Loopback](https://github.com/LoopBackTools/loopback) app: the inbox, routed by path, with its
4
+ header, the page on connecting an agent, and the reload when Loopback is updated under it. Every
5
+ Loopback deployment renders the same app and supplies only what differs: who is signed in, which
6
+ workspaces they can open, and any pages of its own. The deployment this repository ships,
7
+ `apps/loopback`, is one short entry file built on it.
8
+
9
+ ```sh
10
+ pnpm add @loopbacktools/app @loopbacktools/inbox @loopbacktools/sdk @cloudflare/kumo @effect/atom-react effect react react-dom react-router
11
+ ```
12
+
13
+ ## Composing it
14
+
15
+ ```tsx
16
+ import { RegistryProvider } from "@effect/atom-react";
17
+ import { createLoopbackApp, followSystemTheme } from "@loopbacktools/app";
18
+ import { make } from "@loopbacktools/sdk";
19
+ import { useMemo } from "react";
20
+ import { createRoot } from "react-dom/client";
21
+
22
+ const App = createLoopbackApp({
23
+ session: {
24
+ // Who is signed in: undefined while loading, null for nobody.
25
+ useSignedIn: () => {
26
+ const user = useYourSession();
27
+ return user === undefined ? undefined : user === null ? null : { name: user.email, signOut };
28
+ },
29
+ // Shown at /sign-in; once someone is signed in, the app sends them on to `next`.
30
+ SignIn: ({ next }) => <YourSignIn returnTo={next} />,
31
+ },
32
+ workspaces: {
33
+ useList: () => useYourWorkspaces(), // [{ id, name }], or undefined while loading
34
+ useClient: (workspace) =>
35
+ useMemo(
36
+ () => make({ baseUrl: location.origin, token: () => tokenFor(workspace) }),
37
+ [workspace],
38
+ ),
39
+ create: async () => createYourWorkspace(), // optional: the new one's id
40
+ },
41
+ extensions: {
42
+ pages: [{ path: "/account", Page: Account, signedIn: true }],
43
+ accountMenu: [{ label: "Your account", to: "/account" }],
44
+ workspaceMenu: (workspace) => [{ label: "Members", to: `/w/${workspace}/members` }],
45
+ Banner: ({ workspace }) => <YourBanner workspace={workspace} />,
46
+ },
47
+ });
48
+
49
+ followSystemTheme();
50
+ createRoot(document.getElementById("root")!).render(
51
+ <RegistryProvider>
52
+ <App />
53
+ </RegistryProvider>,
54
+ );
55
+ ```
56
+
57
+ Every port is a React hook or a component, so a deployment reads its session and workspaces however
58
+ it keeps them. The app imports no sign-in library; a test keeps it that way.
59
+
60
+ ## What it serves
61
+
62
+ - `/w/:workspace` and `/w/:workspace/asks/:id`: a workspace's inbox, through the client
63
+ `useClient` gives for it. The server's links to asks are of this form.
64
+ - `/w/:workspace/connect`: how to connect an agent. Whether agents sign in with OAuth or send a token
65
+ comes from the server itself.
66
+ - `/sign-in`: the deployment's own sign-in page.
67
+ - `/`: the workspace the person prefers (`usePreferred`), or their first. An old link, `/#/<id>`, is
68
+ followed to that ask.
69
+ - The deployment's pages. They stand alone, without the inbox's header; `Page` and `Section` give
70
+ them the app's look.
71
+
72
+ The workspace menu appears where there is more than one workspace, a way to make one, or items of the
73
+ deployment's own. With no account items and no name to show, signing out is a button of its own, and
74
+ with nothing to sign out of, there is none.
75
+
76
+ ## Styling
77
+
78
+ Beside the inbox's, in your stylesheet:
79
+
80
+ ```css
81
+ @import "@loopbacktools/inbox/styles.css";
82
+ @import "@loopbacktools/app/styles.css";
83
+ ```
84
+
85
+ MIT licensed.
@@ -0,0 +1,8 @@
1
+ import { type ComponentType } from "react";
2
+ import type { LoopbackAppOptions } from "./ports.ts";
3
+ /**
4
+ * The Loopback app, for a deployment to render: its inbox, a page on connecting agents, and the
5
+ * deployment's own pages, routed by path. Render it inside a `RegistryProvider`, as the inbox needs.
6
+ * The deployment's hooks are bound here, once: the app's pages call the same ones on every render.
7
+ */
8
+ export declare const createLoopbackApp: (options: LoopbackAppOptions) => ComponentType;
@@ -0,0 +1,136 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Button, ClipboardText, Empty, LinkButton, LinkProvider, Loader, Text, } from "@cloudflare/kumo";
3
+ import { useAtomValue } from "@effect/atom-react";
4
+ import { Inbox, InboxProvider } from "@loopbacktools/inbox";
5
+ import { Plus, Tray } from "@phosphor-icons/react";
6
+ import { AsyncResult, Atom } from "effect/reactivity";
7
+ import { createContext, forwardRef, use, useCallback, } from "react";
8
+ import { BrowserRouter, Link, Navigate, Route, Routes, useLocation, useNavigate, useParams, useSearchParams, } from "react-router";
9
+ import { Header } from "./header.js";
10
+ import { Page, Section } from "./page.js";
11
+ import { askInOldLink, safeNext, workspacePath } from "./paths.js";
12
+ function Waiting() {
13
+ return (_jsx("main", { className: "flex h-full items-center justify-center", children: _jsx(Loader, {}) }));
14
+ }
15
+ /** Kumo's links, followed by the app's router rather than by loading the page again. */
16
+ const RouterLink = forwardRef(({ href, to, ...rest }, ref) => _jsx(Link, { ref: ref, to: href ?? to ?? "", ...rest }));
17
+ const SignedInContext = createContext(null);
18
+ /** Who is signed in, for a page behind `SignedIn`. */
19
+ const useSignedInHere = () => {
20
+ const signedIn = use(SignedInContext);
21
+ if (signedIn === null)
22
+ throw new Error("Only a page for someone signed in knows who they are.");
23
+ return signedIn;
24
+ };
25
+ /** A workspace that isn't the person's: they may have been removed, or the link is wrong. */
26
+ function NotTheirs() {
27
+ return (_jsx("main", { className: "flex h-full items-center justify-center p-4", children: _jsx(Empty, { icon: _jsx(Tray, { size: 32 }), title: "Not one of your workspaces", description: "You may have been removed from it, or the link is wrong.", contents: _jsx(LinkButton, { href: "/", variant: "secondary", children: "Go to your inbox" }) }) }));
28
+ }
29
+ /** How agents sign in to a client's deployment, as its server says. */
30
+ const agentSignInAtom = Atom.family((client) => Atom.make(client.agentSignIn));
31
+ /** How to connect an agent to a workspace, as the server says its agents sign in. */
32
+ function Connect({ workspace, client, }) {
33
+ const signIn = useAtomValue(agentSignInAtom(client));
34
+ if (AsyncResult.isInitial(signIn))
35
+ return _jsx(Waiting, {});
36
+ const mcp = `${window.location.origin}/mcp`;
37
+ const oauth = AsyncResult.isSuccess(signIn) && signIn.value === "oauth";
38
+ return (_jsxs(Page, { back: { to: workspacePath(workspace), label: "Back to the inbox" }, title: "Connect an agent", description: oauth
39
+ ? "Agents connect to Loopback over MCP and sign in as you. When one connects, you pick the workspace it asks in."
40
+ : "Agents connect to Loopback over MCP, sending this deployment's token.", children: [_jsxs(Section, { title: "Claude Code", children: [_jsx(ClipboardText, { text: oauth
41
+ ? `claude mcp add --transport http loopback ${mcp}`
42
+ : `claude mcp add --transport http loopback ${mcp} --header "Authorization: Bearer $LOOPBACK_TOKEN"` }), _jsx(Text, { variant: "secondary", size: "sm", children: oauth
43
+ ? "Then run /mcp in Claude Code and sign in when it asks."
44
+ : "With the token in LOOPBACK_TOKEN, in the shell you run it from." })] }), _jsxs(Section, { title: "Any other MCP client", children: [_jsx(ClipboardText, { text: mcp }), _jsx(Text, { variant: "secondary", size: "sm", children: oauth
45
+ ? "Add it as a remote server over streamable HTTP. It signs in with OAuth."
46
+ : "Add it as a remote server over streamable HTTP, with the header Authorization: Bearer and the token." })] })] }));
47
+ }
48
+ const noPreference = () => undefined;
49
+ /**
50
+ * The Loopback app, for a deployment to render: its inbox, a page on connecting agents, and the
51
+ * deployment's own pages, routed by path. Render it inside a `RegistryProvider`, as the inbox needs.
52
+ * The deployment's hooks are bound here, once: the app's pages call the same ones on every render.
53
+ */
54
+ export const createLoopbackApp = (options) => {
55
+ const { SignIn } = options.session;
56
+ const useSignedIn = options.session.useSignedIn;
57
+ const useList = options.workspaces.useList;
58
+ const usePreferred = options.workspaces.usePreferred ?? noPreference;
59
+ const useClient = options.workspaces.useClient;
60
+ const { create } = options.workspaces;
61
+ const { pages = [], workspaceMenu, accountMenu = [], Banner } = options.extensions ?? {};
62
+ /** A page for someone signed in; anyone else is sent to sign in, and brought back after. */
63
+ function SignedIn({ children }) {
64
+ const signedIn = useSignedIn();
65
+ const location = useLocation();
66
+ if (signedIn === undefined)
67
+ return _jsx(Waiting, {});
68
+ if (signedIn === null) {
69
+ const next = `${location.pathname}${location.search}${location.hash}`;
70
+ return _jsx(Navigate, { to: `/sign-in?next=${encodeURIComponent(next)}`, replace: true });
71
+ }
72
+ return _jsx(SignedInContext, { value: signedIn, children: children });
73
+ }
74
+ /** The deployment's sign-in page, until the person is signed in; then where they were going. */
75
+ function SignInPage() {
76
+ const signedIn = useSignedIn();
77
+ const [search] = useSearchParams();
78
+ const next = safeNext(search.get("next"));
79
+ if (signedIn === undefined)
80
+ return _jsx(Waiting, {});
81
+ if (signedIn !== null)
82
+ return _jsx(Navigate, { to: next, replace: true });
83
+ return _jsx(SignIn, { next: next });
84
+ }
85
+ /**
86
+ * The app's front door: the workspace the person prefers, or their first, and the ask an old link
87
+ * names in it. Someone in no workspace is offered a new one, where the deployment makes them.
88
+ */
89
+ function Home() {
90
+ const navigate = useNavigate();
91
+ const location = useLocation();
92
+ const list = useList();
93
+ const preferred = usePreferred();
94
+ if (list === undefined)
95
+ return _jsx(Waiting, {});
96
+ const workspace = list.find((candidate) => candidate.id === preferred)?.id ?? list[0]?.id;
97
+ if (workspace !== undefined) {
98
+ return _jsx(Navigate, { to: workspacePath(workspace, askInOldLink(location.hash)), replace: true });
99
+ }
100
+ return (_jsx("main", { className: "flex h-full items-center justify-center p-4", children: _jsx(Empty, { icon: _jsx(Tray, { size: 32 }), title: "You're not in a workspace", description: create === undefined
101
+ ? "Ask someone to invite you to theirs."
102
+ : "Make one to connect your agents to, or ask someone to invite you to theirs.", contents: create === undefined ? undefined : (_jsx(Button, { variant: "primary", icon: _jsx(Plus, {}), onClick: () => void create().then(async (id) => {
103
+ if (id !== undefined)
104
+ await navigate(workspacePath(id));
105
+ }), children: "New workspace" })) }) }));
106
+ }
107
+ /**
108
+ * One workspace's inbox, live, through the client the deployment gives for it. Another workspace
109
+ * is another client, so switching reconnects to that one.
110
+ */
111
+ function WorkspaceInbox({ workspace, list, }) {
112
+ const signedIn = useSignedInHere();
113
+ const client = useClient(workspace);
114
+ const { askId } = useParams();
115
+ const navigate = useNavigate();
116
+ const onSelect = useCallback((id) => void navigate(workspacePath(workspace, id ?? undefined)), [navigate, workspace]);
117
+ const hrefFor = useCallback((id) => workspacePath(workspace, id), [workspace]);
118
+ return (_jsx(InboxProvider, { client: client, children: _jsxs("div", { className: "flex h-full flex-col", children: [_jsx(Header, { workspace: workspace, signedIn: signedIn, workspaces: list, workspaceItems: workspaceMenu?.(workspace) ?? [], accountItems: accountMenu, create: create }), Banner === undefined ? null : _jsx(Banner, { workspace: workspace }), _jsx(Inbox, { selectedId: askId ?? null, onSelect: onSelect, hrefFor: hrefFor })] }) }));
119
+ }
120
+ function ConnectPage({ workspace }) {
121
+ return _jsx(Connect, { workspace: workspace, client: useClient(workspace) });
122
+ }
123
+ /** A page of one of the person's workspaces, or a page saying it isn't one of theirs. */
124
+ function Theirs({ page }) {
125
+ const { workspace = "" } = useParams();
126
+ const list = useList();
127
+ if (list === undefined)
128
+ return _jsx(Waiting, {});
129
+ if (!list.some((candidate) => candidate.id === workspace))
130
+ return _jsx(NotTheirs, {});
131
+ return page === "inbox" ? (_jsx(WorkspaceInbox, { workspace: workspace, list: list })) : (_jsx(ConnectPage, { workspace: workspace }));
132
+ }
133
+ return function LoopbackApp() {
134
+ return (_jsx(BrowserRouter, { children: _jsx(LinkProvider, { component: RouterLink, children: _jsxs(Routes, { children: [_jsx(Route, { path: "/sign-in", element: _jsx(SignInPage, {}) }), _jsx(Route, { path: "/w/:workspace", element: _jsx(SignedIn, { children: _jsx(Theirs, { page: "inbox" }) }) }), _jsx(Route, { path: "/w/:workspace/asks/:askId", element: _jsx(SignedIn, { children: _jsx(Theirs, { page: "inbox" }) }) }), _jsx(Route, { path: "/w/:workspace/connect", element: _jsx(SignedIn, { children: _jsx(Theirs, { page: "connect" }) }) }), pages.map(({ path, Page: Shown, signedIn }) => (_jsx(Route, { path: path, element: signedIn ? (_jsx(SignedIn, { children: _jsx(Shown, {}) })) : (_jsx(Shown, {})) }, path))), _jsx(Route, { path: "*", element: _jsx(SignedIn, { children: _jsx(Home, {}) }) })] }) }) }));
135
+ };
136
+ };
@@ -0,0 +1,11 @@
1
+ import type { MenuItem, SignedIn, WorkspaceSummary } from "./ports.ts";
2
+ /** The bar above a workspace's inbox. */
3
+ export declare function Header({ workspace, signedIn, workspaces, workspaceItems, accountItems, create, }: {
4
+ readonly workspace: string;
5
+ readonly signedIn: SignedIn;
6
+ /** Every workspace the person can open. */
7
+ readonly workspaces: readonly WorkspaceSummary[];
8
+ readonly workspaceItems: readonly MenuItem[];
9
+ readonly accountItems: readonly MenuItem[];
10
+ readonly create: (() => Promise<string | undefined>) | undefined;
11
+ }): import("react").JSX.Element;
@@ -0,0 +1,67 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Button, DropdownMenu, LinkButton, Text } from "@cloudflare/kumo";
3
+ import { useAttention, useInbox, useNotifications } from "@loopbacktools/inbox";
4
+ import { Bell, BellSlash, CaretDown, Check, Plugs, Plus, SignOut } from "@phosphor-icons/react";
5
+ import { useCallback } from "react";
6
+ import { Link, useNavigate } from "react-router";
7
+ import { connectPath, workspacePath } from "./paths.js";
8
+ const CONNECTION = {
9
+ connecting: { label: "Connecting", tone: "bg-kumo-line" },
10
+ live: { label: "Live", tone: "bg-kumo-success" },
11
+ reconnecting: { label: "Reconnecting", tone: "bg-kumo-warning" },
12
+ };
13
+ /** Goes where a menu item goes, or does what it does. */
14
+ const useChoose = () => {
15
+ const navigate = useNavigate();
16
+ return (item) => {
17
+ if (item.to !== undefined)
18
+ void navigate(item.to);
19
+ void item.onSelect?.();
20
+ };
21
+ };
22
+ /**
23
+ * The workspace open, and the way to the others: only where there is more than one, or a way to make
24
+ * one, or the deployment has items of its own for it.
25
+ */
26
+ function WorkspaceMenu({ workspace, list, items, create, }) {
27
+ const navigate = useNavigate();
28
+ const choose = useChoose();
29
+ if (list.length < 2 && create === undefined && items.length === 0)
30
+ return null;
31
+ const current = list.find((candidate) => candidate.id === workspace);
32
+ const make = async () => {
33
+ const id = await create?.();
34
+ if (id !== undefined)
35
+ await navigate(workspacePath(id));
36
+ };
37
+ return (_jsxs(DropdownMenu, { children: [_jsx(DropdownMenu.Trigger, { children: _jsx(Button, { variant: "ghost", size: "sm", icon: _jsx(CaretDown, {}), children: current?.name ?? "Workspace" }) }), _jsxs(DropdownMenu.Content, { children: [list.map((candidate) => (_jsx(DropdownMenu.Item, { icon: candidate.id === workspace ? Check : undefined, onClick: () => void navigate(workspacePath(candidate.id)), children: candidate.name }, candidate.id))), items.length > 0 || create !== undefined ? _jsx(DropdownMenu.Separator, {}) : null, items.map((item) => (_jsx(DropdownMenu.Item, { icon: item.icon, onClick: () => choose(item), children: item.label }, item.label))), create === undefined ? null : (_jsx(DropdownMenu.Item, { icon: Plus, onClick: () => void make(), children: "New workspace" }))] })] }));
38
+ }
39
+ /**
40
+ * The person signed in: a menu of the deployment's items and signing out. With no items and no name
41
+ * to show, signing out is a button of its own, and with nothing to sign out of either, there is none.
42
+ */
43
+ function AccountMenu({ signedIn, items, }) {
44
+ const navigate = useNavigate();
45
+ const choose = useChoose();
46
+ const { name, signOut } = signedIn;
47
+ const leave = signOut === undefined
48
+ ? undefined
49
+ : async () => {
50
+ await signOut();
51
+ await navigate("/");
52
+ };
53
+ if (items.length === 0 && name === undefined) {
54
+ return leave === undefined ? null : (_jsx(Button, { variant: "ghost", size: "sm", onClick: () => void leave(), children: "Sign out" }));
55
+ }
56
+ return (_jsxs(DropdownMenu, { children: [_jsx(DropdownMenu.Trigger, { children: _jsx(Button, { variant: "ghost", size: "sm", children: name ?? "Account" }) }), _jsxs(DropdownMenu.Content, { children: [items.map((item) => (_jsx(DropdownMenu.Item, { icon: item.icon, onClick: () => choose(item), children: item.label }, item.label))), leave === undefined ? null : (_jsxs(_Fragment, { children: [items.length > 0 ? _jsx(DropdownMenu.Separator, {}) : null, _jsx(DropdownMenu.Item, { icon: SignOut, onClick: () => void leave(), children: "Sign out" })] }))] })] }));
57
+ }
58
+ /** The bar above a workspace's inbox. */
59
+ export function Header({ workspace, signedIn, workspaces, workspaceItems, accountItems, create, }) {
60
+ const navigate = useNavigate();
61
+ const inbox = useInbox();
62
+ const notifications = useNotifications();
63
+ const open = useCallback((askId) => void navigate(workspacePath(workspace, askId)), [navigate, workspace]);
64
+ useAttention(inbox, notifications.enabled, open);
65
+ const connection = CONNECTION[inbox.status];
66
+ return (_jsxs("header", { className: "flex items-center gap-2 border-b border-kumo-line bg-kumo-base px-4 py-2", children: [_jsxs(Link, { to: workspacePath(workspace), className: "flex items-center gap-2", children: [_jsx("img", { src: "/favicon.svg", alt: "", className: "size-6" }), _jsx(Text, { bold: true, children: "Loopback" })] }), _jsx(WorkspaceMenu, { workspace: workspace, list: workspaces, items: workspaceItems, create: create }), _jsxs("span", { className: "inline-flex items-center gap-1.5 text-xs text-kumo-subtle", children: [_jsx("span", { className: `size-2 rounded-full ${connection.tone}` }), connection.label] }), _jsxs("span", { className: "ml-auto flex items-center gap-1", children: [_jsx(LinkButton, { href: connectPath(workspace), variant: "ghost", size: "sm", icon: _jsx(Plugs, {}), children: "Connect an agent" }), notifications.supported ? (_jsx(Button, { variant: "ghost", size: "sm", shape: "square", "aria-label": notifications.enabled ? "Turn notifications off" : "Notify me when an agent asks", icon: notifications.enabled ? _jsx(Bell, {}) : _jsx(BellSlash, {}), onClick: () => void notifications.toggle() })) : null, _jsx(AccountMenu, { signedIn: signedIn, items: accountItems })] })] }));
67
+ }
@@ -0,0 +1,5 @@
1
+ export { createLoopbackApp } from "./app.tsx";
2
+ export { Page, Section } from "./page.tsx";
3
+ export { connectPath, workspacePath } from "./paths.ts";
4
+ export type { ExtensionPage, Extensions, LoopbackAppOptions, MenuItem, Session, SignedIn, Workspaces, WorkspaceSummary, } from "./ports.ts";
5
+ export { followSystemTheme } from "./theme.ts";
@@ -0,0 +1,4 @@
1
+ export { createLoopbackApp } from "./app.js";
2
+ export { Page, Section } from "./page.js";
3
+ export { connectPath, workspacePath } from "./paths.js";
4
+ export { followSystemTheme } from "./theme.js";
@@ -0,0 +1,18 @@
1
+ import type { ReactNode } from "react";
2
+ /** A page of its own, off the inbox: a way back, a heading, and its sections. */
3
+ export declare function Page({ back, title, description, children, }: {
4
+ readonly back: {
5
+ readonly to: string;
6
+ readonly label: string;
7
+ };
8
+ readonly title: string;
9
+ readonly description?: string;
10
+ readonly children: ReactNode;
11
+ }): import("react").JSX.Element;
12
+ /** One part of a page: a card with a heading. A dangerous one is bordered in red. */
13
+ export declare function Section({ title, description, danger, children, }: {
14
+ readonly title: string;
15
+ readonly description?: string | undefined;
16
+ readonly danger?: boolean;
17
+ readonly children: ReactNode;
18
+ }): import("react").JSX.Element;
@@ -0,0 +1,11 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Text } from "@cloudflare/kumo";
3
+ import { Link } from "react-router";
4
+ /** A page of its own, off the inbox: a way back, a heading, and its sections. */
5
+ export function Page({ back, title, description, children, }) {
6
+ return (_jsxs("main", { className: "mx-auto flex max-w-2xl flex-col gap-6 px-4 py-10", children: [_jsxs(Link, { to: back.to, className: "text-sm text-kumo-subtle", children: ["\u2190 ", back.label] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Text, { variant: "heading", as: "h1", children: title }), description === undefined ? null : _jsx(Text, { variant: "secondary", children: description })] }), children] }));
7
+ }
8
+ /** One part of a page: a card with a heading. A dangerous one is bordered in red. */
9
+ export function Section({ title, description, danger = false, children, }) {
10
+ return (_jsxs("section", { className: `flex flex-col gap-4 rounded-xl border bg-kumo-elevated p-5 ${danger ? "border-kumo-danger" : "border-kumo-line"}`, children: [_jsxs("div", { className: "flex flex-col gap-1", children: [_jsx(Text, { bold: true, children: title }), description === undefined ? null : (_jsx(Text, { variant: "secondary", size: "sm", children: description }))] }), children] }));
11
+ }
@@ -0,0 +1,11 @@
1
+ /** Where a workspace's inbox, or one ask in it, is in the app. */
2
+ export declare const workspacePath: (workspace: string, askId?: string) => string;
3
+ /** Where the page on connecting an agent to a workspace is. */
4
+ export declare const connectPath: (workspace: string) => string;
5
+ /**
6
+ * Where to send someone once they have signed in: a path in this app, never another site, so a link
7
+ * to the sign-in page can't be made to send people elsewhere.
8
+ */
9
+ export declare const safeNext: (next: string | null) => string;
10
+ /** The ask an old link names, from before links had paths: `/#/<id>`. */
11
+ export declare const askInOldLink: (hash: string) => string | undefined;
@@ -0,0 +1,16 @@
1
+ /** Where a workspace's inbox, or one ask in it, is in the app. */
2
+ export const workspacePath = (workspace, askId) => `/w/${encodeURIComponent(workspace)}${askId === undefined ? "" : `/asks/${encodeURIComponent(askId)}`}`;
3
+ /** Where the page on connecting an agent to a workspace is. */
4
+ export const connectPath = (workspace) => `${workspacePath(workspace)}/connect`;
5
+ /**
6
+ * Where to send someone once they have signed in: a path in this app, never another site, so a link
7
+ * to the sign-in page can't be made to send people elsewhere.
8
+ */
9
+ export const safeNext = (next) => next !== null && next.startsWith("/") && !next.startsWith("//") && !next.startsWith("/\\")
10
+ ? next
11
+ : "/";
12
+ /** The ask an old link names, from before links had paths: `/#/<id>`. */
13
+ export const askInOldLink = (hash) => {
14
+ const id = /^#\/(.+)$/.exec(hash)?.[1];
15
+ return id === undefined ? undefined : decodeURIComponent(id);
16
+ };
@@ -0,0 +1,73 @@
1
+ import type { LoopbackApi } from "@loopbacktools/sdk";
2
+ import type { Icon } from "@phosphor-icons/react";
3
+ import type { ComponentType } from "react";
4
+ /** Someone signed in, as the app shows them. */
5
+ export interface SignedIn {
6
+ /** How to show them, such as their name or email address, when the deployment knows. */
7
+ readonly name?: string;
8
+ /** Signs them out, where there is something to sign out of. */
9
+ readonly signOut?: () => void | Promise<void>;
10
+ }
11
+ export interface Session {
12
+ /**
13
+ * Who is signed in: `undefined` while that is being found out, `null` when nobody is. A React
14
+ * hook, so the deployment can read its own session however it keeps it.
15
+ */
16
+ readonly useSignedIn: () => SignedIn | null | undefined;
17
+ /**
18
+ * The page for someone not signed in, at `/sign-in`. Once they are, the app sends them on to
19
+ * `next`, the path they were going to.
20
+ */
21
+ readonly SignIn: ComponentType<{
22
+ readonly next: string;
23
+ }>;
24
+ }
25
+ /** A workspace someone can open: one Loopback space. */
26
+ export interface WorkspaceSummary {
27
+ readonly id: string;
28
+ readonly name: string;
29
+ }
30
+ export interface Workspaces {
31
+ /** The workspaces the person signed in can open: `undefined` while loading. A React hook. */
32
+ readonly useList: () => readonly WorkspaceSummary[] | undefined;
33
+ /** Which of them the app opens first: the one they used last, say. A React hook. */
34
+ readonly usePreferred?: () => string | undefined;
35
+ /**
36
+ * The client for one workspace, carrying a credential for it. A React hook, giving back the same
37
+ * client for as long as the workspace stays open.
38
+ */
39
+ readonly useClient: (workspace: string) => LoopbackApi;
40
+ /** Makes a new workspace: its id, or `undefined` when none was made. */
41
+ readonly create?: () => Promise<string | undefined>;
42
+ }
43
+ /** An entry in one of the app's menus: somewhere to go, or something to do. */
44
+ export interface MenuItem {
45
+ readonly label: string;
46
+ readonly icon?: Icon;
47
+ readonly to?: string;
48
+ readonly onSelect?: () => void | Promise<void>;
49
+ }
50
+ /** A page of the deployment's own. It stands alone, without the inbox's header: see `Page`. */
51
+ export interface ExtensionPage {
52
+ /** Where it is, as a route path: `/account`, or `/w/:workspace/settings`. */
53
+ readonly path: string;
54
+ readonly Page: ComponentType;
55
+ /** Whether it is for someone signed in; anyone else is sent to sign in, and back. */
56
+ readonly signedIn: boolean;
57
+ }
58
+ export interface Extensions {
59
+ readonly pages?: readonly ExtensionPage[];
60
+ /** Items for a workspace's menu, below the workspaces. */
61
+ readonly workspaceMenu?: (workspace: string) => readonly MenuItem[];
62
+ /** Items for the menu of the person signed in, above signing out. */
63
+ readonly accountMenu?: readonly MenuItem[];
64
+ /** Shown above a workspace's inbox. */
65
+ readonly Banner?: ComponentType<{
66
+ readonly workspace: string;
67
+ }>;
68
+ }
69
+ export interface LoopbackAppOptions {
70
+ readonly session: Session;
71
+ readonly workspaces: Workspaces;
72
+ readonly extensions?: Extensions;
73
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,2 @@
1
+ /** Kumo themes by `data-mode` on the root element, so it follows the system's light or dark setting. */
2
+ export declare const followSystemTheme: () => void;
@@ -0,0 +1,7 @@
1
+ /** Kumo themes by `data-mode` on the root element, so it follows the system's light or dark setting. */
2
+ export const followSystemTheme = () => {
3
+ const dark = window.matchMedia("(prefers-color-scheme: dark)");
4
+ const apply = () => document.documentElement.setAttribute("data-mode", dark.matches ? "dark" : "light");
5
+ apply();
6
+ dark.addEventListener("change", apply);
7
+ };
package/package.json CHANGED
@@ -1,6 +1,70 @@
1
1
  {
2
2
  "name": "@loopbacktools/app",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.4.0",
4
+ "description": "The Loopback app: the inbox, routed, with its header and pages, for a deployment to compose with its own sign-in and workspaces.",
5
+ "homepage": "https://github.com/LoopBackTools/loopback/tree/main/packages/app#readme",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/LoopBackTools/loopback.git",
10
+ "directory": "packages/app"
11
+ },
12
+ "files": [
13
+ "dist",
14
+ "src",
15
+ "styles.css"
16
+ ],
17
+ "type": "module",
18
+ "sideEffects": [
19
+ "*.css"
20
+ ],
21
+ "exports": {
22
+ ".": {
23
+ "types": "./dist/src/index.d.ts",
24
+ "default": "./dist/src/index.js"
25
+ },
26
+ "./styles.css": "./styles.css"
27
+ },
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
31
+ "dependencies": {
32
+ "@phosphor-icons/react": "2.1.10"
33
+ },
34
+ "devDependencies": {
35
+ "@cloudflare/kumo": "2.15.0",
36
+ "@effect/atom-react": "4.0.6",
37
+ "@testing-library/jest-dom": "7.0.1",
38
+ "@testing-library/react": "16.3.3",
39
+ "@testing-library/user-event": "14.6.8",
40
+ "@types/react": "19.3.0",
41
+ "@types/react-dom": "19.3.0",
42
+ "@vitejs/plugin-react": "6.1.2",
43
+ "effect": "4.0.6",
44
+ "es-module-lexer": "3.0.4",
45
+ "jsdom": "30.1.3",
46
+ "react": "19.3.1",
47
+ "react-dom": "19.3.1",
48
+ "react-router": "8.3.0",
49
+ "typescript": "7.0.2",
50
+ "vite": "8.3.5",
51
+ "vitest": "5.0.5",
52
+ "@loopbacktools/inbox": "0.4.0",
53
+ "@loopbacktools/sdk": "0.4.0"
54
+ },
55
+ "peerDependencies": {
56
+ "@cloudflare/kumo": "^2.15.0",
57
+ "@effect/atom-react": "^4.0.6",
58
+ "effect": "^4.0.6",
59
+ "react": "^19.2.0",
60
+ "react-dom": "^19.2.0",
61
+ "react-router": "^8.3.0",
62
+ "@loopbacktools/inbox": "^0.4.0",
63
+ "@loopbacktools/sdk": "^0.4.0"
64
+ },
65
+ "scripts": {
66
+ "typecheck": "tsc --noEmit",
67
+ "test": "vitest run",
68
+ "build": "tsc -p tsconfig.build.json"
69
+ }
6
70
  }
package/src/app.tsx ADDED
@@ -0,0 +1,326 @@
1
+ import {
2
+ Button,
3
+ ClipboardText,
4
+ Empty,
5
+ type LinkComponentProps,
6
+ LinkButton,
7
+ LinkProvider,
8
+ Loader,
9
+ Text,
10
+ } from "@cloudflare/kumo";
11
+ import { useAtomValue } from "@effect/atom-react";
12
+ import { Inbox, InboxProvider } from "@loopbacktools/inbox";
13
+ import type { LoopbackApi } from "@loopbacktools/sdk";
14
+ import { Plus, Tray } from "@phosphor-icons/react";
15
+ import { AsyncResult, Atom } from "effect/reactivity";
16
+ import {
17
+ type ComponentType,
18
+ createContext,
19
+ forwardRef,
20
+ type ReactNode,
21
+ use,
22
+ useCallback,
23
+ } from "react";
24
+ import {
25
+ BrowserRouter,
26
+ Link,
27
+ Navigate,
28
+ Route,
29
+ Routes,
30
+ useLocation,
31
+ useNavigate,
32
+ useParams,
33
+ useSearchParams,
34
+ } from "react-router";
35
+ import { Header } from "./header.tsx";
36
+ import { Page, Section } from "./page.tsx";
37
+ import { askInOldLink, safeNext, workspacePath } from "./paths.ts";
38
+ import type { LoopbackAppOptions, SignedIn as Signed } from "./ports.ts";
39
+
40
+ function Waiting() {
41
+ return (
42
+ <main className="flex h-full items-center justify-center">
43
+ <Loader />
44
+ </main>
45
+ );
46
+ }
47
+
48
+ /** Kumo's links, followed by the app's router rather than by loading the page again. */
49
+ const RouterLink = forwardRef<HTMLAnchorElement, LinkComponentProps>(
50
+ ({ href, to, ...rest }, ref) => <Link ref={ref} to={href ?? to ?? ""} {...rest} />,
51
+ );
52
+
53
+ const SignedInContext = createContext<Signed | null>(null);
54
+
55
+ /** Who is signed in, for a page behind `SignedIn`. */
56
+ const useSignedInHere = (): Signed => {
57
+ const signedIn = use(SignedInContext);
58
+ if (signedIn === null) throw new Error("Only a page for someone signed in knows who they are.");
59
+ return signedIn;
60
+ };
61
+
62
+ /** A workspace that isn't the person's: they may have been removed, or the link is wrong. */
63
+ function NotTheirs() {
64
+ return (
65
+ <main className="flex h-full items-center justify-center p-4">
66
+ <Empty
67
+ icon={<Tray size={32} />}
68
+ title="Not one of your workspaces"
69
+ description="You may have been removed from it, or the link is wrong."
70
+ contents={
71
+ <LinkButton href="/" variant="secondary">
72
+ Go to your inbox
73
+ </LinkButton>
74
+ }
75
+ />
76
+ </main>
77
+ );
78
+ }
79
+
80
+ /** How agents sign in to a client's deployment, as its server says. */
81
+ const agentSignInAtom = Atom.family((client: LoopbackApi) => Atom.make(client.agentSignIn));
82
+
83
+ /** How to connect an agent to a workspace, as the server says its agents sign in. */
84
+ function Connect({
85
+ workspace,
86
+ client,
87
+ }: {
88
+ readonly workspace: string;
89
+ readonly client: LoopbackApi;
90
+ }) {
91
+ const signIn = useAtomValue(agentSignInAtom(client));
92
+ if (AsyncResult.isInitial(signIn)) return <Waiting />;
93
+ const mcp = `${window.location.origin}/mcp`;
94
+ const oauth = AsyncResult.isSuccess(signIn) && signIn.value === "oauth";
95
+ return (
96
+ <Page
97
+ back={{ to: workspacePath(workspace), label: "Back to the inbox" }}
98
+ title="Connect an agent"
99
+ description={
100
+ oauth
101
+ ? "Agents connect to Loopback over MCP and sign in as you. When one connects, you pick the workspace it asks in."
102
+ : "Agents connect to Loopback over MCP, sending this deployment's token."
103
+ }
104
+ >
105
+ <Section title="Claude Code">
106
+ <ClipboardText
107
+ text={
108
+ oauth
109
+ ? `claude mcp add --transport http loopback ${mcp}`
110
+ : `claude mcp add --transport http loopback ${mcp} --header "Authorization: Bearer $LOOPBACK_TOKEN"`
111
+ }
112
+ />
113
+ <Text variant="secondary" size="sm">
114
+ {oauth
115
+ ? "Then run /mcp in Claude Code and sign in when it asks."
116
+ : "With the token in LOOPBACK_TOKEN, in the shell you run it from."}
117
+ </Text>
118
+ </Section>
119
+ <Section title="Any other MCP client">
120
+ <ClipboardText text={mcp} />
121
+ <Text variant="secondary" size="sm">
122
+ {oauth
123
+ ? "Add it as a remote server over streamable HTTP. It signs in with OAuth."
124
+ : "Add it as a remote server over streamable HTTP, with the header Authorization: Bearer and the token."}
125
+ </Text>
126
+ </Section>
127
+ </Page>
128
+ );
129
+ }
130
+
131
+ const noPreference = (): string | undefined => undefined;
132
+
133
+ /**
134
+ * The Loopback app, for a deployment to render: its inbox, a page on connecting agents, and the
135
+ * deployment's own pages, routed by path. Render it inside a `RegistryProvider`, as the inbox needs.
136
+ * The deployment's hooks are bound here, once: the app's pages call the same ones on every render.
137
+ */
138
+ export const createLoopbackApp = (options: LoopbackAppOptions): ComponentType => {
139
+ const { SignIn } = options.session;
140
+ const useSignedIn = options.session.useSignedIn;
141
+ const useList = options.workspaces.useList;
142
+ const usePreferred = options.workspaces.usePreferred ?? noPreference;
143
+ const useClient = options.workspaces.useClient;
144
+ const { create } = options.workspaces;
145
+ const { pages = [], workspaceMenu, accountMenu = [], Banner } = options.extensions ?? {};
146
+
147
+ /** A page for someone signed in; anyone else is sent to sign in, and brought back after. */
148
+ function SignedIn({ children }: { readonly children: ReactNode }) {
149
+ const signedIn = useSignedIn();
150
+ const location = useLocation();
151
+ if (signedIn === undefined) return <Waiting />;
152
+ if (signedIn === null) {
153
+ const next = `${location.pathname}${location.search}${location.hash}`;
154
+ return <Navigate to={`/sign-in?next=${encodeURIComponent(next)}`} replace />;
155
+ }
156
+ return <SignedInContext value={signedIn}>{children}</SignedInContext>;
157
+ }
158
+
159
+ /** The deployment's sign-in page, until the person is signed in; then where they were going. */
160
+ function SignInPage() {
161
+ const signedIn = useSignedIn();
162
+ const [search] = useSearchParams();
163
+ const next = safeNext(search.get("next"));
164
+ if (signedIn === undefined) return <Waiting />;
165
+ if (signedIn !== null) return <Navigate to={next} replace />;
166
+ return <SignIn next={next} />;
167
+ }
168
+
169
+ /**
170
+ * The app's front door: the workspace the person prefers, or their first, and the ask an old link
171
+ * names in it. Someone in no workspace is offered a new one, where the deployment makes them.
172
+ */
173
+ function Home() {
174
+ const navigate = useNavigate();
175
+ const location = useLocation();
176
+ const list = useList();
177
+ const preferred = usePreferred();
178
+ if (list === undefined) return <Waiting />;
179
+ const workspace = list.find((candidate) => candidate.id === preferred)?.id ?? list[0]?.id;
180
+ if (workspace !== undefined) {
181
+ return <Navigate to={workspacePath(workspace, askInOldLink(location.hash))} replace />;
182
+ }
183
+ return (
184
+ <main className="flex h-full items-center justify-center p-4">
185
+ <Empty
186
+ icon={<Tray size={32} />}
187
+ title="You're not in a workspace"
188
+ description={
189
+ create === undefined
190
+ ? "Ask someone to invite you to theirs."
191
+ : "Make one to connect your agents to, or ask someone to invite you to theirs."
192
+ }
193
+ contents={
194
+ create === undefined ? undefined : (
195
+ <Button
196
+ variant="primary"
197
+ icon={<Plus />}
198
+ onClick={() =>
199
+ void create().then(async (id) => {
200
+ if (id !== undefined) await navigate(workspacePath(id));
201
+ })
202
+ }
203
+ >
204
+ New workspace
205
+ </Button>
206
+ )
207
+ }
208
+ />
209
+ </main>
210
+ );
211
+ }
212
+
213
+ /**
214
+ * One workspace's inbox, live, through the client the deployment gives for it. Another workspace
215
+ * is another client, so switching reconnects to that one.
216
+ */
217
+ function WorkspaceInbox({
218
+ workspace,
219
+ list,
220
+ }: {
221
+ readonly workspace: string;
222
+ readonly list: readonly { readonly id: string; readonly name: string }[];
223
+ }) {
224
+ const signedIn = useSignedInHere();
225
+ const client = useClient(workspace);
226
+ const { askId } = useParams();
227
+ const navigate = useNavigate();
228
+ const onSelect = useCallback(
229
+ (id: string | null) => void navigate(workspacePath(workspace, id ?? undefined)),
230
+ [navigate, workspace],
231
+ );
232
+ const hrefFor = useCallback((id: string) => workspacePath(workspace, id), [workspace]);
233
+ return (
234
+ <InboxProvider client={client}>
235
+ <div className="flex h-full flex-col">
236
+ <Header
237
+ workspace={workspace}
238
+ signedIn={signedIn}
239
+ workspaces={list}
240
+ workspaceItems={workspaceMenu?.(workspace) ?? []}
241
+ accountItems={accountMenu}
242
+ create={create}
243
+ />
244
+ {Banner === undefined ? null : <Banner workspace={workspace} />}
245
+ <Inbox selectedId={askId ?? null} onSelect={onSelect} hrefFor={hrefFor} />
246
+ </div>
247
+ </InboxProvider>
248
+ );
249
+ }
250
+
251
+ function ConnectPage({ workspace }: { readonly workspace: string }) {
252
+ return <Connect workspace={workspace} client={useClient(workspace)} />;
253
+ }
254
+
255
+ /** A page of one of the person's workspaces, or a page saying it isn't one of theirs. */
256
+ function Theirs({ page }: { readonly page: "inbox" | "connect" }) {
257
+ const { workspace = "" } = useParams();
258
+ const list = useList();
259
+ if (list === undefined) return <Waiting />;
260
+ if (!list.some((candidate) => candidate.id === workspace)) return <NotTheirs />;
261
+ return page === "inbox" ? (
262
+ <WorkspaceInbox workspace={workspace} list={list} />
263
+ ) : (
264
+ <ConnectPage workspace={workspace} />
265
+ );
266
+ }
267
+
268
+ return function LoopbackApp() {
269
+ return (
270
+ <BrowserRouter>
271
+ <LinkProvider component={RouterLink}>
272
+ <Routes>
273
+ <Route path="/sign-in" element={<SignInPage />} />
274
+ <Route
275
+ path="/w/:workspace"
276
+ element={
277
+ <SignedIn>
278
+ <Theirs page="inbox" />
279
+ </SignedIn>
280
+ }
281
+ />
282
+ <Route
283
+ path="/w/:workspace/asks/:askId"
284
+ element={
285
+ <SignedIn>
286
+ <Theirs page="inbox" />
287
+ </SignedIn>
288
+ }
289
+ />
290
+ <Route
291
+ path="/w/:workspace/connect"
292
+ element={
293
+ <SignedIn>
294
+ <Theirs page="connect" />
295
+ </SignedIn>
296
+ }
297
+ />
298
+ {pages.map(({ path, Page: Shown, signedIn }) => (
299
+ <Route
300
+ key={path}
301
+ path={path}
302
+ element={
303
+ signedIn ? (
304
+ <SignedIn>
305
+ <Shown />
306
+ </SignedIn>
307
+ ) : (
308
+ <Shown />
309
+ )
310
+ }
311
+ />
312
+ ))}
313
+ <Route
314
+ path="*"
315
+ element={
316
+ <SignedIn>
317
+ <Home />
318
+ </SignedIn>
319
+ }
320
+ />
321
+ </Routes>
322
+ </LinkProvider>
323
+ </BrowserRouter>
324
+ );
325
+ };
326
+ };
package/src/header.tsx ADDED
@@ -0,0 +1,196 @@
1
+ import { Button, DropdownMenu, LinkButton, Text } from "@cloudflare/kumo";
2
+ import { useAttention, useInbox, useNotifications } from "@loopbacktools/inbox";
3
+ import { Bell, BellSlash, CaretDown, Check, Plugs, Plus, SignOut } from "@phosphor-icons/react";
4
+ import { useCallback } from "react";
5
+ import { Link, useNavigate } from "react-router";
6
+ import { connectPath, workspacePath } from "./paths.ts";
7
+ import type { MenuItem, SignedIn, WorkspaceSummary } from "./ports.ts";
8
+
9
+ const CONNECTION = {
10
+ connecting: { label: "Connecting", tone: "bg-kumo-line" },
11
+ live: { label: "Live", tone: "bg-kumo-success" },
12
+ reconnecting: { label: "Reconnecting", tone: "bg-kumo-warning" },
13
+ } as const;
14
+
15
+ /** Goes where a menu item goes, or does what it does. */
16
+ const useChoose = () => {
17
+ const navigate = useNavigate();
18
+ return (item: MenuItem) => {
19
+ if (item.to !== undefined) void navigate(item.to);
20
+ void item.onSelect?.();
21
+ };
22
+ };
23
+
24
+ /**
25
+ * The workspace open, and the way to the others: only where there is more than one, or a way to make
26
+ * one, or the deployment has items of its own for it.
27
+ */
28
+ function WorkspaceMenu({
29
+ workspace,
30
+ list,
31
+ items,
32
+ create,
33
+ }: {
34
+ readonly workspace: string;
35
+ readonly list: readonly WorkspaceSummary[];
36
+ readonly items: readonly MenuItem[];
37
+ readonly create: (() => Promise<string | undefined>) | undefined;
38
+ }) {
39
+ const navigate = useNavigate();
40
+ const choose = useChoose();
41
+ if (list.length < 2 && create === undefined && items.length === 0) return null;
42
+ const current = list.find((candidate) => candidate.id === workspace);
43
+ const make = async () => {
44
+ const id = await create?.();
45
+ if (id !== undefined) await navigate(workspacePath(id));
46
+ };
47
+ return (
48
+ <DropdownMenu>
49
+ <DropdownMenu.Trigger>
50
+ <Button variant="ghost" size="sm" icon={<CaretDown />}>
51
+ {current?.name ?? "Workspace"}
52
+ </Button>
53
+ </DropdownMenu.Trigger>
54
+ <DropdownMenu.Content>
55
+ {list.map((candidate) => (
56
+ <DropdownMenu.Item
57
+ key={candidate.id}
58
+ icon={candidate.id === workspace ? Check : undefined}
59
+ onClick={() => void navigate(workspacePath(candidate.id))}
60
+ >
61
+ {candidate.name}
62
+ </DropdownMenu.Item>
63
+ ))}
64
+ {items.length > 0 || create !== undefined ? <DropdownMenu.Separator /> : null}
65
+ {items.map((item) => (
66
+ <DropdownMenu.Item key={item.label} icon={item.icon} onClick={() => choose(item)}>
67
+ {item.label}
68
+ </DropdownMenu.Item>
69
+ ))}
70
+ {create === undefined ? null : (
71
+ <DropdownMenu.Item icon={Plus} onClick={() => void make()}>
72
+ New workspace
73
+ </DropdownMenu.Item>
74
+ )}
75
+ </DropdownMenu.Content>
76
+ </DropdownMenu>
77
+ );
78
+ }
79
+
80
+ /**
81
+ * The person signed in: a menu of the deployment's items and signing out. With no items and no name
82
+ * to show, signing out is a button of its own, and with nothing to sign out of either, there is none.
83
+ */
84
+ function AccountMenu({
85
+ signedIn,
86
+ items,
87
+ }: {
88
+ readonly signedIn: SignedIn;
89
+ readonly items: readonly MenuItem[];
90
+ }) {
91
+ const navigate = useNavigate();
92
+ const choose = useChoose();
93
+ const { name, signOut } = signedIn;
94
+ const leave =
95
+ signOut === undefined
96
+ ? undefined
97
+ : async () => {
98
+ await signOut();
99
+ await navigate("/");
100
+ };
101
+ if (items.length === 0 && name === undefined) {
102
+ return leave === undefined ? null : (
103
+ <Button variant="ghost" size="sm" onClick={() => void leave()}>
104
+ Sign out
105
+ </Button>
106
+ );
107
+ }
108
+ return (
109
+ <DropdownMenu>
110
+ <DropdownMenu.Trigger>
111
+ <Button variant="ghost" size="sm">
112
+ {name ?? "Account"}
113
+ </Button>
114
+ </DropdownMenu.Trigger>
115
+ <DropdownMenu.Content>
116
+ {items.map((item) => (
117
+ <DropdownMenu.Item key={item.label} icon={item.icon} onClick={() => choose(item)}>
118
+ {item.label}
119
+ </DropdownMenu.Item>
120
+ ))}
121
+ {leave === undefined ? null : (
122
+ <>
123
+ {items.length > 0 ? <DropdownMenu.Separator /> : null}
124
+ <DropdownMenu.Item icon={SignOut} onClick={() => void leave()}>
125
+ Sign out
126
+ </DropdownMenu.Item>
127
+ </>
128
+ )}
129
+ </DropdownMenu.Content>
130
+ </DropdownMenu>
131
+ );
132
+ }
133
+
134
+ /** The bar above a workspace's inbox. */
135
+ export function Header({
136
+ workspace,
137
+ signedIn,
138
+ workspaces,
139
+ workspaceItems,
140
+ accountItems,
141
+ create,
142
+ }: {
143
+ readonly workspace: string;
144
+ readonly signedIn: SignedIn;
145
+ /** Every workspace the person can open. */
146
+ readonly workspaces: readonly WorkspaceSummary[];
147
+ readonly workspaceItems: readonly MenuItem[];
148
+ readonly accountItems: readonly MenuItem[];
149
+ readonly create: (() => Promise<string | undefined>) | undefined;
150
+ }) {
151
+ const navigate = useNavigate();
152
+ const inbox = useInbox();
153
+ const notifications = useNotifications();
154
+ const open = useCallback(
155
+ (askId: string) => void navigate(workspacePath(workspace, askId)),
156
+ [navigate, workspace],
157
+ );
158
+ useAttention(inbox, notifications.enabled, open);
159
+ const connection = CONNECTION[inbox.status];
160
+ return (
161
+ <header className="flex items-center gap-2 border-b border-kumo-line bg-kumo-base px-4 py-2">
162
+ <Link to={workspacePath(workspace)} className="flex items-center gap-2">
163
+ <img src="/favicon.svg" alt="" className="size-6" />
164
+ <Text bold>Loopback</Text>
165
+ </Link>
166
+ <WorkspaceMenu
167
+ workspace={workspace}
168
+ list={workspaces}
169
+ items={workspaceItems}
170
+ create={create}
171
+ />
172
+ <span className="inline-flex items-center gap-1.5 text-xs text-kumo-subtle">
173
+ <span className={`size-2 rounded-full ${connection.tone}`} />
174
+ {connection.label}
175
+ </span>
176
+ <span className="ml-auto flex items-center gap-1">
177
+ <LinkButton href={connectPath(workspace)} variant="ghost" size="sm" icon={<Plugs />}>
178
+ Connect an agent
179
+ </LinkButton>
180
+ {notifications.supported ? (
181
+ <Button
182
+ variant="ghost"
183
+ size="sm"
184
+ shape="square"
185
+ aria-label={
186
+ notifications.enabled ? "Turn notifications off" : "Notify me when an agent asks"
187
+ }
188
+ icon={notifications.enabled ? <Bell /> : <BellSlash />}
189
+ onClick={() => void notifications.toggle()}
190
+ />
191
+ ) : null}
192
+ <AccountMenu signedIn={signedIn} items={accountItems} />
193
+ </span>
194
+ </header>
195
+ );
196
+ }
package/src/index.ts ADDED
@@ -0,0 +1,14 @@
1
+ export { createLoopbackApp } from "./app.tsx";
2
+ export { Page, Section } from "./page.tsx";
3
+ export { connectPath, workspacePath } from "./paths.ts";
4
+ export type {
5
+ ExtensionPage,
6
+ Extensions,
7
+ LoopbackAppOptions,
8
+ MenuItem,
9
+ Session,
10
+ SignedIn,
11
+ Workspaces,
12
+ WorkspaceSummary,
13
+ } from "./ports.ts";
14
+ export { followSystemTheme } from "./theme.ts";
package/src/page.tsx ADDED
@@ -0,0 +1,62 @@
1
+ import { Text } from "@cloudflare/kumo";
2
+ import type { ReactNode } from "react";
3
+ import { Link } from "react-router";
4
+
5
+ /** A page of its own, off the inbox: a way back, a heading, and its sections. */
6
+ export function Page({
7
+ back,
8
+ title,
9
+ description,
10
+ children,
11
+ }: {
12
+ readonly back: { readonly to: string; readonly label: string };
13
+ readonly title: string;
14
+ readonly description?: string;
15
+ readonly children: ReactNode;
16
+ }) {
17
+ return (
18
+ <main className="mx-auto flex max-w-2xl flex-col gap-6 px-4 py-10">
19
+ <Link to={back.to} className="text-sm text-kumo-subtle">
20
+ ← {back.label}
21
+ </Link>
22
+ <div className="flex flex-col gap-2">
23
+ <Text variant="heading" as="h1">
24
+ {title}
25
+ </Text>
26
+ {description === undefined ? null : <Text variant="secondary">{description}</Text>}
27
+ </div>
28
+ {children}
29
+ </main>
30
+ );
31
+ }
32
+
33
+ /** One part of a page: a card with a heading. A dangerous one is bordered in red. */
34
+ export function Section({
35
+ title,
36
+ description,
37
+ danger = false,
38
+ children,
39
+ }: {
40
+ readonly title: string;
41
+ readonly description?: string | undefined;
42
+ readonly danger?: boolean;
43
+ readonly children: ReactNode;
44
+ }) {
45
+ return (
46
+ <section
47
+ className={`flex flex-col gap-4 rounded-xl border bg-kumo-elevated p-5 ${
48
+ danger ? "border-kumo-danger" : "border-kumo-line"
49
+ }`}
50
+ >
51
+ <div className="flex flex-col gap-1">
52
+ <Text bold>{title}</Text>
53
+ {description === undefined ? null : (
54
+ <Text variant="secondary" size="sm">
55
+ {description}
56
+ </Text>
57
+ )}
58
+ </div>
59
+ {children}
60
+ </section>
61
+ );
62
+ }
package/src/paths.ts ADDED
@@ -0,0 +1,21 @@
1
+ /** Where a workspace's inbox, or one ask in it, is in the app. */
2
+ export const workspacePath = (workspace: string, askId?: string): string =>
3
+ `/w/${encodeURIComponent(workspace)}${askId === undefined ? "" : `/asks/${encodeURIComponent(askId)}`}`;
4
+
5
+ /** Where the page on connecting an agent to a workspace is. */
6
+ export const connectPath = (workspace: string): string => `${workspacePath(workspace)}/connect`;
7
+
8
+ /**
9
+ * Where to send someone once they have signed in: a path in this app, never another site, so a link
10
+ * to the sign-in page can't be made to send people elsewhere.
11
+ */
12
+ export const safeNext = (next: string | null): string =>
13
+ next !== null && next.startsWith("/") && !next.startsWith("//") && !next.startsWith("/\\")
14
+ ? next
15
+ : "/";
16
+
17
+ /** The ask an old link names, from before links had paths: `/#/<id>`. */
18
+ export const askInOldLink = (hash: string): string | undefined => {
19
+ const id = /^#\/(.+)$/.exec(hash)?.[1];
20
+ return id === undefined ? undefined : decodeURIComponent(id);
21
+ };
package/src/ports.ts ADDED
@@ -0,0 +1,81 @@
1
+ import type { LoopbackApi } from "@loopbacktools/sdk";
2
+ import type { Icon } from "@phosphor-icons/react";
3
+ import type { ComponentType } from "react";
4
+
5
+ // What the app needs from the deployment it runs in, and nothing more: who is signed in, which
6
+ // workspaces they can open, and anything of the deployment's own. Each deployment fills these in,
7
+ // in a short entry file of its own. See docs/design/one-app.md.
8
+
9
+ /** Someone signed in, as the app shows them. */
10
+ export interface SignedIn {
11
+ /** How to show them, such as their name or email address, when the deployment knows. */
12
+ readonly name?: string;
13
+ /** Signs them out, where there is something to sign out of. */
14
+ readonly signOut?: () => void | Promise<void>;
15
+ }
16
+
17
+ export interface Session {
18
+ /**
19
+ * Who is signed in: `undefined` while that is being found out, `null` when nobody is. A React
20
+ * hook, so the deployment can read its own session however it keeps it.
21
+ */
22
+ readonly useSignedIn: () => SignedIn | null | undefined;
23
+ /**
24
+ * The page for someone not signed in, at `/sign-in`. Once they are, the app sends them on to
25
+ * `next`, the path they were going to.
26
+ */
27
+ readonly SignIn: ComponentType<{ readonly next: string }>;
28
+ }
29
+
30
+ /** A workspace someone can open: one Loopback space. */
31
+ export interface WorkspaceSummary {
32
+ readonly id: string;
33
+ readonly name: string;
34
+ }
35
+
36
+ export interface Workspaces {
37
+ /** The workspaces the person signed in can open: `undefined` while loading. A React hook. */
38
+ readonly useList: () => readonly WorkspaceSummary[] | undefined;
39
+ /** Which of them the app opens first: the one they used last, say. A React hook. */
40
+ readonly usePreferred?: () => string | undefined;
41
+ /**
42
+ * The client for one workspace, carrying a credential for it. A React hook, giving back the same
43
+ * client for as long as the workspace stays open.
44
+ */
45
+ readonly useClient: (workspace: string) => LoopbackApi;
46
+ /** Makes a new workspace: its id, or `undefined` when none was made. */
47
+ readonly create?: () => Promise<string | undefined>;
48
+ }
49
+
50
+ /** An entry in one of the app's menus: somewhere to go, or something to do. */
51
+ export interface MenuItem {
52
+ readonly label: string;
53
+ readonly icon?: Icon;
54
+ readonly to?: string;
55
+ readonly onSelect?: () => void | Promise<void>;
56
+ }
57
+
58
+ /** A page of the deployment's own. It stands alone, without the inbox's header: see `Page`. */
59
+ export interface ExtensionPage {
60
+ /** Where it is, as a route path: `/account`, or `/w/:workspace/settings`. */
61
+ readonly path: string;
62
+ readonly Page: ComponentType;
63
+ /** Whether it is for someone signed in; anyone else is sent to sign in, and back. */
64
+ readonly signedIn: boolean;
65
+ }
66
+
67
+ export interface Extensions {
68
+ readonly pages?: readonly ExtensionPage[];
69
+ /** Items for a workspace's menu, below the workspaces. */
70
+ readonly workspaceMenu?: (workspace: string) => readonly MenuItem[];
71
+ /** Items for the menu of the person signed in, above signing out. */
72
+ readonly accountMenu?: readonly MenuItem[];
73
+ /** Shown above a workspace's inbox. */
74
+ readonly Banner?: ComponentType<{ readonly workspace: string }>;
75
+ }
76
+
77
+ export interface LoopbackAppOptions {
78
+ readonly session: Session;
79
+ readonly workspaces: Workspaces;
80
+ readonly extensions?: Extensions;
81
+ }
package/src/theme.ts ADDED
@@ -0,0 +1,8 @@
1
+ /** Kumo themes by `data-mode` on the root element, so it follows the system's light or dark setting. */
2
+ export const followSystemTheme = (): void => {
3
+ const dark = window.matchMedia("(prefers-color-scheme: dark)");
4
+ const apply = () =>
5
+ document.documentElement.setAttribute("data-mode", dark.matches ? "dark" : "light");
6
+ apply();
7
+ dark.addEventListener("change", apply);
8
+ };
package/styles.css ADDED
@@ -0,0 +1,5 @@
1
+ /*
2
+ * The app's styles. Import this after Tailwind and Kumo's theme, beside the inbox's: it has Tailwind
3
+ * generate the utilities the app's own pages use.
4
+ */
5
+ @source "./src";