@loopbacktools/app 0.0.0-stage → 0.4.1
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 +21 -0
- package/README.md +89 -2
- package/dist/src/app.d.ts +8 -0
- package/dist/src/app.js +145 -0
- package/dist/src/header.d.ts +11 -0
- package/dist/src/header.js +67 -0
- package/dist/src/index.d.ts +5 -0
- package/dist/src/index.js +4 -0
- package/dist/src/location.d.ts +2 -0
- package/dist/src/location.js +2 -0
- package/dist/src/page.d.ts +18 -0
- package/dist/src/page.js +11 -0
- package/dist/src/paths.d.ts +11 -0
- package/dist/src/paths.js +16 -0
- package/dist/src/ports.d.ts +79 -0
- package/dist/src/ports.js +1 -0
- package/dist/src/theme.d.ts +2 -0
- package/dist/src/theme.js +7 -0
- package/package.json +67 -3
- package/src/app.tsx +335 -0
- package/src/header.tsx +196 -0
- package/src/index.ts +14 -0
- package/src/location.ts +2 -0
- package/src/page.tsx +62 -0
- package/src/paths.ts +21 -0
- package/src/ports.ts +87 -0
- package/src/theme.ts +8 -0
- package/styles.css +5 -0
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,90 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @loopbacktools/app
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
// Optional: a page of the server's that signing in continues to, loaded in full.
|
|
32
|
+
continueAt: (search) =>
|
|
33
|
+
search.has("client_id") ? `/api/auth/oauth2/authorize?${search}` : undefined,
|
|
34
|
+
},
|
|
35
|
+
workspaces: {
|
|
36
|
+
useList: () => useYourWorkspaces(), // [{ id, name }], or undefined while loading
|
|
37
|
+
useClient: (workspace) =>
|
|
38
|
+
useMemo(
|
|
39
|
+
() => make({ baseUrl: location.origin, token: () => tokenFor(workspace) }),
|
|
40
|
+
[workspace],
|
|
41
|
+
),
|
|
42
|
+
create: async () => createYourWorkspace(), // optional: the new one's id
|
|
43
|
+
},
|
|
44
|
+
extensions: {
|
|
45
|
+
pages: [{ path: "/account", Page: Account, signedIn: true }],
|
|
46
|
+
accountMenu: [{ label: "Your account", to: "/account" }],
|
|
47
|
+
workspaceMenu: (workspace) => [{ label: "Members", to: `/w/${workspace}/members` }],
|
|
48
|
+
Banner: ({ workspace }) => <YourBanner workspace={workspace} />,
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
followSystemTheme();
|
|
53
|
+
createRoot(document.getElementById("root")!).render(
|
|
54
|
+
<RegistryProvider>
|
|
55
|
+
<App />
|
|
56
|
+
</RegistryProvider>,
|
|
57
|
+
);
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Every port is a React hook or a component, so a deployment reads its session and workspaces however
|
|
61
|
+
it keeps them. The app imports no sign-in library; a test keeps it that way.
|
|
62
|
+
|
|
63
|
+
## What it serves
|
|
64
|
+
|
|
65
|
+
- `/w/:workspace` and `/w/:workspace/asks/:id`: a workspace's inbox, through the client
|
|
66
|
+
`useClient` gives for it. The server's links to asks are of this form.
|
|
67
|
+
- `/w/:workspace/connect`: how to connect an agent. Whether agents sign in with OAuth or send a token
|
|
68
|
+
comes from the server itself.
|
|
69
|
+
- `/sign-in`: the deployment's own sign-in page. Once someone is signed in, it sends them on to
|
|
70
|
+
`next`, a path in the app, or to the page `continueAt` names, loaded in full: an OAuth
|
|
71
|
+
authorization an agent started, say.
|
|
72
|
+
- `/`: the workspace the person prefers (`usePreferred`), or their first. An old link, `/#/<id>`, is
|
|
73
|
+
followed to that ask.
|
|
74
|
+
- The deployment's pages. They stand alone, without the inbox's header; `Page` and `Section` give
|
|
75
|
+
them the app's look.
|
|
76
|
+
|
|
77
|
+
The workspace menu appears where there is more than one workspace, a way to make one, or items of the
|
|
78
|
+
deployment's own. With no account items and no name to show, signing out is a button of its own, and
|
|
79
|
+
with nothing to sign out of, there is none.
|
|
80
|
+
|
|
81
|
+
## Styling
|
|
82
|
+
|
|
83
|
+
Beside the inbox's, in your stylesheet:
|
|
84
|
+
|
|
85
|
+
```css
|
|
86
|
+
@import "@loopbacktools/inbox/styles.css";
|
|
87
|
+
@import "@loopbacktools/app/styles.css";
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
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;
|
package/dist/src/app.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
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, useEffect, } from "react";
|
|
8
|
+
import { BrowserRouter, Link, Navigate, Route, Routes, useLocation, useNavigate, useParams, useSearchParams, } from "react-router";
|
|
9
|
+
import { Header } from "./header.js";
|
|
10
|
+
import { loadPage } from "./location.js";
|
|
11
|
+
import { Page, Section } from "./page.js";
|
|
12
|
+
import { askInOldLink, safeNext, workspacePath } from "./paths.js";
|
|
13
|
+
function Waiting() {
|
|
14
|
+
return (_jsx("main", { className: "flex h-full items-center justify-center", children: _jsx(Loader, {}) }));
|
|
15
|
+
}
|
|
16
|
+
/** Kumo's links, followed by the app's router rather than by loading the page again. */
|
|
17
|
+
const RouterLink = forwardRef(({ href, to, ...rest }, ref) => _jsx(Link, { ref: ref, to: href ?? to ?? "", ...rest }));
|
|
18
|
+
const SignedInContext = createContext(null);
|
|
19
|
+
/** Who is signed in, for a page behind `SignedIn`. */
|
|
20
|
+
const useSignedInHere = () => {
|
|
21
|
+
const signedIn = use(SignedInContext);
|
|
22
|
+
if (signedIn === null)
|
|
23
|
+
throw new Error("Only a page for someone signed in knows who they are.");
|
|
24
|
+
return signedIn;
|
|
25
|
+
};
|
|
26
|
+
/** A workspace that isn't the person's: they may have been removed, or the link is wrong. */
|
|
27
|
+
function NotTheirs() {
|
|
28
|
+
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" }) }) }));
|
|
29
|
+
}
|
|
30
|
+
/** How agents sign in to a client's deployment, as its server says. */
|
|
31
|
+
const agentSignInAtom = Atom.family((client) => Atom.make(client.agentSignIn));
|
|
32
|
+
/** How to connect an agent to a workspace, as the server says its agents sign in. */
|
|
33
|
+
function Connect({ workspace, client, }) {
|
|
34
|
+
const signIn = useAtomValue(agentSignInAtom(client));
|
|
35
|
+
if (AsyncResult.isInitial(signIn))
|
|
36
|
+
return _jsx(Waiting, {});
|
|
37
|
+
const mcp = `${window.location.origin}/mcp`;
|
|
38
|
+
const oauth = AsyncResult.isSuccess(signIn) && signIn.value === "oauth";
|
|
39
|
+
return (_jsxs(Page, { back: { to: workspacePath(workspace), label: "Back to the inbox" }, title: "Connect an agent", description: oauth
|
|
40
|
+
? "Agents connect to Loopback over MCP and sign in as you. When one connects, you pick the workspace it asks in."
|
|
41
|
+
: "Agents connect to Loopback over MCP, sending this deployment's token.", children: [_jsxs(Section, { title: "Claude Code", children: [_jsx(ClipboardText, { text: oauth
|
|
42
|
+
? `claude mcp add --transport http loopback ${mcp}`
|
|
43
|
+
: `claude mcp add --transport http loopback ${mcp} --header "Authorization: Bearer $LOOPBACK_TOKEN"` }), _jsx(Text, { variant: "secondary", size: "sm", children: oauth
|
|
44
|
+
? "Then run /mcp in Claude Code and sign in when it asks."
|
|
45
|
+
: "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
|
|
46
|
+
? "Add it as a remote server over streamable HTTP. It signs in with OAuth."
|
|
47
|
+
: "Add it as a remote server over streamable HTTP, with the header Authorization: Bearer and the token." })] })] }));
|
|
48
|
+
}
|
|
49
|
+
const noPreference = () => undefined;
|
|
50
|
+
/**
|
|
51
|
+
* The Loopback app, for a deployment to render: its inbox, a page on connecting agents, and the
|
|
52
|
+
* deployment's own pages, routed by path. Render it inside a `RegistryProvider`, as the inbox needs.
|
|
53
|
+
* The deployment's hooks are bound here, once: the app's pages call the same ones on every render.
|
|
54
|
+
*/
|
|
55
|
+
export const createLoopbackApp = (options) => {
|
|
56
|
+
const { SignIn, continueAt } = options.session;
|
|
57
|
+
const useSignedIn = options.session.useSignedIn;
|
|
58
|
+
const useList = options.workspaces.useList;
|
|
59
|
+
const usePreferred = options.workspaces.usePreferred ?? noPreference;
|
|
60
|
+
const useClient = options.workspaces.useClient;
|
|
61
|
+
const { create } = options.workspaces;
|
|
62
|
+
const { pages = [], workspaceMenu, accountMenu = [], Banner } = options.extensions ?? {};
|
|
63
|
+
/** A page for someone signed in; anyone else is sent to sign in, and brought back after. */
|
|
64
|
+
function SignedIn({ children }) {
|
|
65
|
+
const signedIn = useSignedIn();
|
|
66
|
+
const location = useLocation();
|
|
67
|
+
if (signedIn === undefined)
|
|
68
|
+
return _jsx(Waiting, {});
|
|
69
|
+
if (signedIn === null) {
|
|
70
|
+
const next = `${location.pathname}${location.search}${location.hash}`;
|
|
71
|
+
return _jsx(Navigate, { to: `/sign-in?next=${encodeURIComponent(next)}`, replace: true });
|
|
72
|
+
}
|
|
73
|
+
return _jsx(SignedInContext, { value: signedIn, children: children });
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* The deployment's sign-in page, until the person is signed in; then where they were going: a
|
|
77
|
+
* page of the server's the deployment names, loaded in full, or else `next`.
|
|
78
|
+
*/
|
|
79
|
+
function SignInPage() {
|
|
80
|
+
const signedIn = useSignedIn();
|
|
81
|
+
const [search] = useSearchParams();
|
|
82
|
+
const next = safeNext(search.get("next"));
|
|
83
|
+
const onward = signedIn == null ? undefined : continueAt?.(search);
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
if (onward !== undefined)
|
|
86
|
+
loadPage(onward);
|
|
87
|
+
}, [onward]);
|
|
88
|
+
if (signedIn === undefined || onward !== undefined)
|
|
89
|
+
return _jsx(Waiting, {});
|
|
90
|
+
if (signedIn !== null)
|
|
91
|
+
return _jsx(Navigate, { to: next, replace: true });
|
|
92
|
+
return _jsx(SignIn, { next: next });
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* The app's front door: the workspace the person prefers, or their first, and the ask an old link
|
|
96
|
+
* names in it. Someone in no workspace is offered a new one, where the deployment makes them.
|
|
97
|
+
*/
|
|
98
|
+
function Home() {
|
|
99
|
+
const navigate = useNavigate();
|
|
100
|
+
const location = useLocation();
|
|
101
|
+
const list = useList();
|
|
102
|
+
const preferred = usePreferred();
|
|
103
|
+
if (list === undefined)
|
|
104
|
+
return _jsx(Waiting, {});
|
|
105
|
+
const workspace = list.find((candidate) => candidate.id === preferred)?.id ?? list[0]?.id;
|
|
106
|
+
if (workspace !== undefined) {
|
|
107
|
+
return _jsx(Navigate, { to: workspacePath(workspace, askInOldLink(location.hash)), replace: true });
|
|
108
|
+
}
|
|
109
|
+
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
|
|
110
|
+
? "Ask someone to invite you to theirs."
|
|
111
|
+
: "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) => {
|
|
112
|
+
if (id !== undefined)
|
|
113
|
+
await navigate(workspacePath(id));
|
|
114
|
+
}), children: "New workspace" })) }) }));
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* One workspace's inbox, live, through the client the deployment gives for it. Another workspace
|
|
118
|
+
* is another client, so switching reconnects to that one.
|
|
119
|
+
*/
|
|
120
|
+
function WorkspaceInbox({ workspace, list, }) {
|
|
121
|
+
const signedIn = useSignedInHere();
|
|
122
|
+
const client = useClient(workspace);
|
|
123
|
+
const { askId } = useParams();
|
|
124
|
+
const navigate = useNavigate();
|
|
125
|
+
const onSelect = useCallback((id) => void navigate(workspacePath(workspace, id ?? undefined)), [navigate, workspace]);
|
|
126
|
+
const hrefFor = useCallback((id) => workspacePath(workspace, id), [workspace]);
|
|
127
|
+
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 })] }) }));
|
|
128
|
+
}
|
|
129
|
+
function ConnectPage({ workspace }) {
|
|
130
|
+
return _jsx(Connect, { workspace: workspace, client: useClient(workspace) });
|
|
131
|
+
}
|
|
132
|
+
/** A page of one of the person's workspaces, or a page saying it isn't one of theirs. */
|
|
133
|
+
function Theirs({ page }) {
|
|
134
|
+
const { workspace = "" } = useParams();
|
|
135
|
+
const list = useList();
|
|
136
|
+
if (list === undefined)
|
|
137
|
+
return _jsx(Waiting, {});
|
|
138
|
+
if (!list.some((candidate) => candidate.id === workspace))
|
|
139
|
+
return _jsx(NotTheirs, {});
|
|
140
|
+
return page === "inbox" ? (_jsx(WorkspaceInbox, { workspace: workspace, list: list })) : (_jsx(ConnectPage, { workspace: workspace }));
|
|
141
|
+
}
|
|
142
|
+
return function LoopbackApp() {
|
|
143
|
+
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, {}) }) })] }) }) }));
|
|
144
|
+
};
|
|
145
|
+
};
|
|
@@ -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,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;
|
package/dist/src/page.js
ADDED
|
@@ -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,79 @@
|
|
|
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
|
+
* Where someone at the sign-in page goes once signed in, when it is a page of the server's
|
|
26
|
+
* rather than the app's, as an authorization an agent started is. The app loads it in full, in
|
|
27
|
+
* place of routing to `next`. Given the sign-in page's query; `undefined` for no such page.
|
|
28
|
+
*/
|
|
29
|
+
readonly continueAt?: (search: URLSearchParams) => string | undefined;
|
|
30
|
+
}
|
|
31
|
+
/** A workspace someone can open: one Loopback space. */
|
|
32
|
+
export interface WorkspaceSummary {
|
|
33
|
+
readonly id: string;
|
|
34
|
+
readonly name: string;
|
|
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
|
+
/** An entry in one of the app's menus: somewhere to go, or something to do. */
|
|
50
|
+
export interface MenuItem {
|
|
51
|
+
readonly label: string;
|
|
52
|
+
readonly icon?: Icon;
|
|
53
|
+
readonly to?: string;
|
|
54
|
+
readonly onSelect?: () => void | Promise<void>;
|
|
55
|
+
}
|
|
56
|
+
/** A page of the deployment's own. It stands alone, without the inbox's header: see `Page`. */
|
|
57
|
+
export interface ExtensionPage {
|
|
58
|
+
/** Where it is, as a route path: `/account`, or `/w/:workspace/settings`. */
|
|
59
|
+
readonly path: string;
|
|
60
|
+
readonly Page: ComponentType;
|
|
61
|
+
/** Whether it is for someone signed in; anyone else is sent to sign in, and back. */
|
|
62
|
+
readonly signedIn: boolean;
|
|
63
|
+
}
|
|
64
|
+
export interface Extensions {
|
|
65
|
+
readonly pages?: readonly ExtensionPage[];
|
|
66
|
+
/** Items for a workspace's menu, below the workspaces. */
|
|
67
|
+
readonly workspaceMenu?: (workspace: string) => readonly MenuItem[];
|
|
68
|
+
/** Items for the menu of the person signed in, above signing out. */
|
|
69
|
+
readonly accountMenu?: readonly MenuItem[];
|
|
70
|
+
/** Shown above a workspace's inbox. */
|
|
71
|
+
readonly Banner?: ComponentType<{
|
|
72
|
+
readonly workspace: string;
|
|
73
|
+
}>;
|
|
74
|
+
}
|
|
75
|
+
export interface LoopbackAppOptions {
|
|
76
|
+
readonly session: Session;
|
|
77
|
+
readonly workspaces: Workspaces;
|
|
78
|
+
readonly extensions?: Extensions;
|
|
79
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.4.1",
|
|
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.1",
|
|
53
|
+
"@loopbacktools/sdk": "0.4.1"
|
|
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.1",
|
|
63
|
+
"@loopbacktools/sdk": "^0.4.1"
|
|
64
|
+
},
|
|
65
|
+
"scripts": {
|
|
66
|
+
"typecheck": "tsc --noEmit",
|
|
67
|
+
"test": "vitest run",
|
|
68
|
+
"build": "tsc -p tsconfig.build.json"
|
|
69
|
+
}
|
|
6
70
|
}
|