@korso/shepherd-ui 0.1.1 → 0.2.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/dist/ShepherdRoot.d.ts +9 -0
- package/dist/ShepherdRoot.js +46 -0
- package/dist/client.d.ts +107 -0
- package/dist/client.js +238 -0
- package/dist/components/ActiveList.d.ts +22 -0
- package/dist/components/ActiveList.js +59 -0
- package/dist/components/Chat.d.ts +30 -0
- package/dist/components/Chat.js +61 -0
- package/dist/components/Composer.d.ts +37 -0
- package/dist/components/Composer.js +146 -0
- package/dist/components/Crew.d.ts +23 -0
- package/dist/components/Crew.js +31 -0
- package/dist/components/Dashboard.d.ts +48 -0
- package/dist/components/Dashboard.js +280 -0
- package/dist/components/DoneList.d.ts +29 -0
- package/dist/components/DoneList.js +50 -0
- package/dist/components/RepoSelect.d.ts +38 -0
- package/dist/components/RepoSelect.js +56 -0
- package/dist/components/Territory.d.ts +21 -0
- package/dist/components/Territory.js +21 -0
- package/dist/config/ConnectAgent.d.ts +10 -0
- package/dist/config/ConnectAgent.js +119 -0
- package/dist/config/EmptyState.d.ts +12 -0
- package/dist/config/EmptyState.js +8 -0
- package/dist/config/Members.d.ts +10 -0
- package/dist/config/Members.js +83 -0
- package/dist/config/Workspaces.d.ts +12 -0
- package/dist/config/Workspaces.js +96 -0
- package/dist/config/index.d.ts +8 -0
- package/dist/config/index.js +6 -0
- package/dist/context.d.ts +28 -0
- package/dist/context.js +36 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +15 -0
- package/dist/lib/Dashboard-B7nioe5N.js +1286 -0
- package/dist/lib/index.d.ts +452 -16
- package/dist/lib/index.js +372 -6
- package/dist/lib/selfhost.js +9 -9
- package/dist/lib/styles.css +35 -0
- package/dist/logic.d.ts +208 -0
- package/dist/logic.js +372 -0
- package/dist/main.d.ts +1 -0
- package/dist/main.js +11 -0
- package/dist/selfhost/assets/index-D6rTsbVM.js +40 -0
- package/dist/selfhost/assets/{index-tSyzirZa.css → index-DnhlP_lc.css} +1 -1
- package/dist/selfhost/index.html +2 -2
- package/dist/selfhost.d.ts +22 -0
- package/dist/selfhost.js +97 -0
- package/dist/test/mockClient.d.ts +6 -0
- package/dist/test/mockClient.js +45 -0
- package/dist/useLandscapePolling.d.ts +70 -0
- package/dist/useLandscapePolling.js +134 -0
- package/package.json +1 -1
- package/dist/lib/Dashboard-CB6SL-J2.js +0 -1064
- package/dist/selfhost/assets/index-BBx3vo2Y.js +0 -40
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useRef, useState } from "react";
|
|
3
|
+
const EMPTY = { active: 0, done: 0 };
|
|
4
|
+
/**
|
|
5
|
+
* The repo filter: a click-to-open menu of repos plus an "All repos" option,
|
|
6
|
+
* each showing its `"N active · N done"` tally. Ported from app.js
|
|
7
|
+
* `renderRepoSelect` — same markup, classes, and listbox/option roles so it
|
|
8
|
+
* stays keyboard- and screen-reader-operable.
|
|
9
|
+
*
|
|
10
|
+
* Hidden entirely when fewer than two repos exist (nothing to filter). Owns its
|
|
11
|
+
* own open/close state; the trigger toggles it and Escape closes it. Choosing a
|
|
12
|
+
* repo calls `onSelect(repo)`; choosing "All repos" calls `onSelect(null)` —
|
|
13
|
+
* matching app.js, which then stores the sentinel `"__all__"` for All repos.
|
|
14
|
+
*
|
|
15
|
+
* @param props - Repos, per-repo counts, the current selection, and the
|
|
16
|
+
* selection callback.
|
|
17
|
+
* @returns The repo selector, or `null` when there are fewer than two repos.
|
|
18
|
+
*/
|
|
19
|
+
export function RepoSelect({ repos, counts, selected, onSelect, }) {
|
|
20
|
+
const [open, setOpen] = useState(false);
|
|
21
|
+
const hostRef = useRef(null);
|
|
22
|
+
// Move focus into the freshly-opened menu (the selected option, else the
|
|
23
|
+
// first) so keyboard users can Tab the options and Esc out — only on open.
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (!open)
|
|
26
|
+
return;
|
|
27
|
+
const menu = hostRef.current?.querySelector(".repo-menu");
|
|
28
|
+
if (!menu)
|
|
29
|
+
return;
|
|
30
|
+
const target = menu.querySelector('[aria-selected="true"]') ??
|
|
31
|
+
menu.querySelector("button");
|
|
32
|
+
target?.focus();
|
|
33
|
+
}, [open]);
|
|
34
|
+
if (repos.length < 2)
|
|
35
|
+
return null;
|
|
36
|
+
const isAll = selected === null || selected === "__all__";
|
|
37
|
+
const label = isAll ? "All repos" : selected;
|
|
38
|
+
/** A single menu option; `repo === null` is the "All repos" row. */
|
|
39
|
+
const renderItem = (repo, text) => {
|
|
40
|
+
const c = repo === null ? counts.__all__ ?? EMPTY : counts[repo] ?? EMPTY;
|
|
41
|
+
const isOn = repo === null ? isAll : selected === repo;
|
|
42
|
+
const cls = "repo-mi" + (isOn ? " on" : "") + (repo === null ? " all" : "");
|
|
43
|
+
return (_jsxs("button", { type: "button", className: cls, role: "option", "aria-selected": isOn, onClick: (e) => {
|
|
44
|
+
e.stopPropagation();
|
|
45
|
+
setOpen(false);
|
|
46
|
+
onSelect(repo);
|
|
47
|
+
}, children: [_jsx("span", { children: text }), _jsx("span", { className: "ct", children: `${c.active} active · ${c.done} done` })] }, repo ?? "__all__"));
|
|
48
|
+
};
|
|
49
|
+
return (_jsxs("span", { ref: hostRef, className: "repo", onKeyDown: (e) => {
|
|
50
|
+
if (e.key === "Escape")
|
|
51
|
+
setOpen(false);
|
|
52
|
+
}, children: [_jsxs("button", { type: "button", className: "repo-trig", "aria-haspopup": "listbox", "aria-expanded": open, "aria-label": `Filter by repo (current: ${label})`, onClick: (e) => {
|
|
53
|
+
e.stopPropagation();
|
|
54
|
+
setOpen((o) => !o);
|
|
55
|
+
}, children: [_jsx("span", { className: "slash", children: "/" }), " " + label + " ", _jsx("span", { children: "\u25BC" })] }), open && (_jsxs("div", { className: "repo-menu", role: "listbox", children: [repos.map((r) => renderItem(r, r)), renderItem(null, "All repos")] }))] }));
|
|
56
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ReactElement } from "react";
|
|
2
|
+
/** Props for {@link Territory}. */
|
|
3
|
+
export interface TerritoryProps {
|
|
4
|
+
/** The claim's path globs. */
|
|
5
|
+
globs: string[];
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* A claim's territory pills. Ported from app.js `taskTerritory`. A single path
|
|
9
|
+
* (or none) renders inline under a "territory" label; two or more collapse into
|
|
10
|
+
* a native `<details>` whose summary reads `"N paths"` and expands to the full
|
|
11
|
+
* glob list.
|
|
12
|
+
*
|
|
13
|
+
* Unlike app.js the open/closed state is left to the browser's native
|
|
14
|
+
* `<details>` rather than persisted across polls: React reconciles the same
|
|
15
|
+
* element across re-renders (no `replaceChildren` rebuild), so the fold state
|
|
16
|
+
* survives without manual bookkeeping.
|
|
17
|
+
*
|
|
18
|
+
* @param props - The globs to display.
|
|
19
|
+
* @returns The territory element (inline or a collapsible `<details>`).
|
|
20
|
+
*/
|
|
21
|
+
export declare function Territory({ globs }: TerritoryProps): ReactElement;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* A claim's territory pills. Ported from app.js `taskTerritory`. A single path
|
|
4
|
+
* (or none) renders inline under a "territory" label; two or more collapse into
|
|
5
|
+
* a native `<details>` whose summary reads `"N paths"` and expands to the full
|
|
6
|
+
* glob list.
|
|
7
|
+
*
|
|
8
|
+
* Unlike app.js the open/closed state is left to the browser's native
|
|
9
|
+
* `<details>` rather than persisted across polls: React reconciles the same
|
|
10
|
+
* element across re-renders (no `replaceChildren` rebuild), so the fold state
|
|
11
|
+
* survives without manual bookkeeping.
|
|
12
|
+
*
|
|
13
|
+
* @param props - The globs to display.
|
|
14
|
+
* @returns The territory element (inline or a collapsible `<details>`).
|
|
15
|
+
*/
|
|
16
|
+
export function Territory({ globs }) {
|
|
17
|
+
if (globs.length <= 1) {
|
|
18
|
+
return (_jsxs("div", { className: "terr", children: [_jsx("span", { className: "terr__lbl", children: "territory" }), globs.map((g) => (_jsx("span", { className: "glob", children: g }, g)))] }));
|
|
19
|
+
}
|
|
20
|
+
return (_jsxs("details", { className: "terrx", children: [_jsxs("summary", { children: [_jsx("span", { className: "terr__lbl", children: "territory" }), _jsxs("span", { className: "glob glob--more", children: [_jsx("span", { className: "tw", children: "\u25B6" }), ` ${globs.length} paths`] })] }), _jsx("div", { className: "terr terr--full", children: globs.map((g) => (_jsx("span", { className: "glob", children: g }, g))) })] }));
|
|
21
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface ConnectAgentProps {
|
|
2
|
+
workspaceId: string;
|
|
3
|
+
/**
|
|
4
|
+
* The DIRECT Hub URL the agent connects to (public Cloud Run URL when hosted).
|
|
5
|
+
* Defaults to the dashboard client's baseUrl, which is correct for self-host
|
|
6
|
+
* where the agent and the dashboard share the Hub origin.
|
|
7
|
+
*/
|
|
8
|
+
hubUrl?: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function ConnectAgent({ workspaceId, hubUrl }: ConnectAgentProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useId, useState } from "react";
|
|
3
|
+
import { useShepherdClient } from "../context.js";
|
|
4
|
+
import { describeError } from "../client.js";
|
|
5
|
+
const TOOLS = [
|
|
6
|
+
{ id: "claude", label: "Claude Code" },
|
|
7
|
+
{ id: "codex", label: "Codex" },
|
|
8
|
+
{ id: "pi", label: "Pi" },
|
|
9
|
+
{ id: "generic", label: "Generic (JSON)" },
|
|
10
|
+
];
|
|
11
|
+
// The token placeholder shown before a real token is minted. Switching tools or
|
|
12
|
+
// reading the command pre-mint shows this, never a real secret.
|
|
13
|
+
const TOKEN_PLACEHOLDER = "shp_<paste-after-generating>";
|
|
14
|
+
// The CLI tools differ only in the first line of the `mcp add` invocation; the
|
|
15
|
+
// rest of the command (the env flags and the npx tail) is shared.
|
|
16
|
+
const CLI_PREFIX = {
|
|
17
|
+
claude: "claude mcp add shepherd -s user \\",
|
|
18
|
+
codex: "codex mcp add shepherd \\",
|
|
19
|
+
pi: "pi mcp add shepherd \\",
|
|
20
|
+
};
|
|
21
|
+
function installCommand(tool, hubUrl, token) {
|
|
22
|
+
if (tool === "generic") {
|
|
23
|
+
return JSON.stringify({
|
|
24
|
+
mcpServers: {
|
|
25
|
+
shepherd: {
|
|
26
|
+
command: "npx",
|
|
27
|
+
args: ["-y", "@korso/shepherd"],
|
|
28
|
+
env: { HUB_URL: hubUrl, SHEPHERD_TOKEN: token },
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
}, null, 2);
|
|
32
|
+
}
|
|
33
|
+
return [
|
|
34
|
+
CLI_PREFIX[tool],
|
|
35
|
+
` -e HUB_URL=${hubUrl} \\`,
|
|
36
|
+
` -e SHEPHERD_TOKEN=${token} \\`,
|
|
37
|
+
" -- npx -y @korso/shepherd",
|
|
38
|
+
].join("\n");
|
|
39
|
+
}
|
|
40
|
+
export function ConnectAgent({ workspaceId, hubUrl }) {
|
|
41
|
+
const client = useShepherdClient();
|
|
42
|
+
const directHubUrl = hubUrl ?? client.baseUrl;
|
|
43
|
+
const headingId = useId();
|
|
44
|
+
const [tool, setTool] = useState("claude");
|
|
45
|
+
const [name, setName] = useState("");
|
|
46
|
+
// The raw token from the most recent mint — shown once, then only as command.
|
|
47
|
+
const [rawToken, setRawToken] = useState(null);
|
|
48
|
+
const [tokens, setTokens] = useState([]);
|
|
49
|
+
const [error, setError] = useState(null);
|
|
50
|
+
const [status, setStatus] = useState(null);
|
|
51
|
+
const [busy, setBusy] = useState(false);
|
|
52
|
+
// True until the first loadTokens() resolves, so the management list shows a
|
|
53
|
+
// loading placeholder rather than the "No tokens yet." empty state.
|
|
54
|
+
const [loading, setLoading] = useState(true);
|
|
55
|
+
// Per-row in-flight revoke guard (by token id) to block double-submit.
|
|
56
|
+
const [revokingId, setRevokingId] = useState(null);
|
|
57
|
+
async function loadTokens() {
|
|
58
|
+
try {
|
|
59
|
+
const res = await client.listTokens(workspaceId);
|
|
60
|
+
setTokens(res.tokens);
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
setError(describeError(err));
|
|
64
|
+
}
|
|
65
|
+
finally {
|
|
66
|
+
setLoading(false);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
setLoading(true);
|
|
71
|
+
void loadTokens();
|
|
72
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
73
|
+
}, [client, workspaceId]);
|
|
74
|
+
// The component is re-rendered (not remounted) across a workspace switch, so
|
|
75
|
+
// clear the once-shown raw token so it never leaks into another workspace's
|
|
76
|
+
// install command (mirrors Workspaces' setInvite(null) reset).
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
setRawToken(null);
|
|
79
|
+
}, [workspaceId]);
|
|
80
|
+
async function generate() {
|
|
81
|
+
setBusy(true);
|
|
82
|
+
setError(null);
|
|
83
|
+
try {
|
|
84
|
+
const res = await client.mintToken(workspaceId, name.trim() ? { name: name.trim() } : {});
|
|
85
|
+
setRawToken(res.token);
|
|
86
|
+
setName("");
|
|
87
|
+
await loadTokens();
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
setError(describeError(err));
|
|
91
|
+
}
|
|
92
|
+
finally {
|
|
93
|
+
setBusy(false);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
async function revoke(tokenId) {
|
|
97
|
+
if (revokingId)
|
|
98
|
+
return;
|
|
99
|
+
setRevokingId(tokenId);
|
|
100
|
+
setError(null);
|
|
101
|
+
setStatus(null);
|
|
102
|
+
try {
|
|
103
|
+
await client.revokeToken(workspaceId, tokenId);
|
|
104
|
+
setTokens((prev) => prev.filter((t) => t.id !== tokenId));
|
|
105
|
+
setStatus("Token revoked");
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
setError(describeError(err));
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
setRevokingId(null);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const command = installCommand(tool, directHubUrl, rawToken ?? TOKEN_PLACEHOLDER);
|
|
115
|
+
return (_jsxs("section", { className: "shepherd-connect-agent", "aria-labelledby": headingId, children: [_jsx("h3", { id: headingId, children: "Connect your agent" }), _jsx("label", { htmlFor: "connect-tool", children: "Tool" }), _jsx("select", { id: "connect-tool", value: tool, onChange: (e) => setTool(e.target.value), children: TOOLS.map((t) => (_jsx("option", { value: t.id, children: t.label }, t.id))) }), _jsxs("div", { className: "generate", children: [_jsx("label", { htmlFor: "token-name", children: "Token name (optional)" }), _jsx("input", { id: "token-name", type: "text", value: name, onChange: (e) => setName(e.target.value), placeholder: "e.g. laptop" }), _jsx("button", { type: "button", onClick: () => void generate(), disabled: busy, children: "Generate token" })] }), error && _jsx("p", { role: "alert", children: error }), status && _jsx("p", { role: "status", children: status }), rawToken && (_jsx("p", { className: "token-once", role: "status", children: "Copy this token now \u2014 it won't be shown again." })), _jsx("pre", { "data-testid": "install-command", children: command }), _jsx("h4", { children: "Existing tokens" }), loading ? (_jsx("p", { role: "status", children: "Loading\u2026" })) : tokens.length === 0 ? (_jsx("p", { children: "No tokens yet." })) : (_jsx("ul", { children: tokens.map((t) => {
|
|
116
|
+
const name = t.name ?? t.id;
|
|
117
|
+
return (_jsxs("li", { children: [_jsx("span", { children: name }), t.revokedAt ? (_jsx("span", { className: "revoked", children: "revoked" })) : (_jsx("button", { type: "button", "aria-label": `Revoke token ${name}`, onClick: () => void revoke(t.id), disabled: revokingId === t.id, children: "Revoke" }))] }, t.id));
|
|
118
|
+
}) }))] }));
|
|
119
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
export interface EmptyStateProps {
|
|
3
|
+
/** Heading text. Defaults to a generic "no workspace yet" prompt. */
|
|
4
|
+
title?: string;
|
|
5
|
+
/** Supporting copy. */
|
|
6
|
+
children?: ReactNode;
|
|
7
|
+
/** Invoked when the user clicks the call-to-action (e.g. switch to Config). */
|
|
8
|
+
onGetStarted?: () => void;
|
|
9
|
+
/** CTA label. */
|
|
10
|
+
ctaLabel?: string;
|
|
11
|
+
}
|
|
12
|
+
export declare function EmptyState({ title, children, onGetStarted, ctaLabel, }: EmptyStateProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useId } from "react";
|
|
3
|
+
export function EmptyState({ title = "No workspace yet", children, onGetStarted, ctaLabel = "Go to Config", }) {
|
|
4
|
+
// A per-instance id so two EmptyStates (e.g. the Tasks and Chat panels of a
|
|
5
|
+
// no-workspace board) never collide on a duplicate `empty-state-heading` id.
|
|
6
|
+
const headingId = useId();
|
|
7
|
+
return (_jsxs("section", { className: "shepherd-empty-state", "aria-labelledby": headingId, children: [_jsx("h2", { id: headingId, children: title }), _jsx("p", { children: children ?? "Create a workspace or join one with an invite code to get started." }), onGetStarted && (_jsx("button", { type: "button", onClick: onGetStarted, children: ctaLabel }))] }));
|
|
8
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface MembersProps {
|
|
2
|
+
workspaceId: string;
|
|
3
|
+
/** Bumped by the parent to force a refetch (e.g. after an invite is redeemed). */
|
|
4
|
+
refreshKey?: number;
|
|
5
|
+
/** When true, render the per-member remove control (the caller gates on admin). */
|
|
6
|
+
canRemove?: boolean;
|
|
7
|
+
/** Called after a successful self-service leave, so the shell can refresh. */
|
|
8
|
+
onLeft?: () => void;
|
|
9
|
+
}
|
|
10
|
+
export declare function Members({ workspaceId, refreshKey, canRemove, onLeft }: MembersProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useId, useState } from "react";
|
|
3
|
+
import { useShepherdClient } from "../context.js";
|
|
4
|
+
import { describeError } from "../client.js";
|
|
5
|
+
export function Members({ workspaceId, refreshKey = 0, canRemove = false, onLeft }) {
|
|
6
|
+
const client = useShepherdClient();
|
|
7
|
+
const headingId = useId();
|
|
8
|
+
const [members, setMembers] = useState([]);
|
|
9
|
+
const [error, setError] = useState(null);
|
|
10
|
+
const [status, setStatus] = useState(null);
|
|
11
|
+
// True until the first load() resolves, so the initial fetch shows "Loading…"
|
|
12
|
+
// rather than the genuine "No members." empty state.
|
|
13
|
+
const [loading, setLoading] = useState(true);
|
|
14
|
+
// Per-row in-flight remove guard (by accountId) and a leave busy flag, both
|
|
15
|
+
// used to disable their buttons and block double-submit.
|
|
16
|
+
const [removingId, setRemovingId] = useState(null);
|
|
17
|
+
const [leaving, setLeaving] = useState(false);
|
|
18
|
+
// `keepError` lets the remove-failure path re-sync the roster without wiping
|
|
19
|
+
// the message it just surfaced (a fresh fetch normally clears stale errors).
|
|
20
|
+
async function load({ keepError = false } = {}) {
|
|
21
|
+
if (!keepError)
|
|
22
|
+
setError(null);
|
|
23
|
+
try {
|
|
24
|
+
const res = await client.listMembers(workspaceId);
|
|
25
|
+
setMembers(res.members);
|
|
26
|
+
}
|
|
27
|
+
catch (err) {
|
|
28
|
+
setError(describeError(err));
|
|
29
|
+
}
|
|
30
|
+
finally {
|
|
31
|
+
setLoading(false);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
setLoading(true);
|
|
36
|
+
void load();
|
|
37
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
38
|
+
}, [client, workspaceId, refreshKey]);
|
|
39
|
+
async function remove(accountId, display) {
|
|
40
|
+
if (removingId)
|
|
41
|
+
return;
|
|
42
|
+
setRemovingId(accountId);
|
|
43
|
+
setError(null);
|
|
44
|
+
setStatus(null);
|
|
45
|
+
// Optimistically drop the row; re-sync from the server if it rejects.
|
|
46
|
+
setMembers((current) => current.filter((m) => m.accountId !== accountId));
|
|
47
|
+
try {
|
|
48
|
+
await client.removeMember(workspaceId, accountId);
|
|
49
|
+
setStatus(`Removed ${display}`);
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
setError(describeError(err));
|
|
53
|
+
// The captured roster may be stale; recover from the server instead,
|
|
54
|
+
// preserving the failure message we just set.
|
|
55
|
+
void load({ keepError: true });
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
setRemovingId(null);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
async function leave() {
|
|
62
|
+
if (leaving)
|
|
63
|
+
return;
|
|
64
|
+
setLeaving(true);
|
|
65
|
+
setError(null);
|
|
66
|
+
setStatus(null);
|
|
67
|
+
try {
|
|
68
|
+
await client.leave(workspaceId);
|
|
69
|
+
setStatus("Left workspace");
|
|
70
|
+
onLeft?.();
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
setError(describeError(err));
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
setLeaving(false);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return (_jsxs("section", { className: "shepherd-members", "aria-labelledby": headingId, children: [_jsx("h3", { id: headingId, children: "Members" }), error && _jsx("p", { role: "alert", children: error }), status && _jsx("p", { role: "status", children: status }), loading ? (_jsx("p", { role: "status", children: "Loading\u2026" })) : members.length === 0 ? (_jsx("p", { children: "No members." })) : (_jsx("ul", { children: members.map((m) => {
|
|
80
|
+
const display = m.displayName ?? m.githubLogin ?? m.accountId;
|
|
81
|
+
return (_jsxs("li", { children: [_jsx("span", { children: display }), _jsx("span", { className: "role", children: m.role }), canRemove && (_jsx("button", { type: "button", "aria-label": `Remove ${display}`, onClick: () => void remove(m.accountId, display), disabled: removingId === m.accountId, children: "Remove" }))] }, m.accountId));
|
|
82
|
+
}) })), _jsx("button", { type: "button", onClick: () => void leave(), disabled: leaving, children: "Leave workspace" })] }));
|
|
83
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { WorkspaceSummaryT } from "@shepherd/shared";
|
|
2
|
+
export interface WorkspacesProps {
|
|
3
|
+
/** All workspaces the account belongs to (for the switcher / context). */
|
|
4
|
+
workspaces: WorkspaceSummaryT[];
|
|
5
|
+
/** The currently-selected workspace, or null when the account has none. */
|
|
6
|
+
selected: WorkspaceSummaryT | null;
|
|
7
|
+
/** Called after a mutation that changes membership (create / join / leave). */
|
|
8
|
+
onChanged: () => void;
|
|
9
|
+
/** Switch the active workspace (optional simple switcher). */
|
|
10
|
+
onSelect?: (workspaceId: string) => void;
|
|
11
|
+
}
|
|
12
|
+
export declare function Workspaces({ workspaces, selected, onChanged, onSelect }: WorkspacesProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useId, useState } from "react";
|
|
3
|
+
import { useShepherdClient } from "../context.js";
|
|
4
|
+
import { describeError } from "../client.js";
|
|
5
|
+
import { Members } from "./Members.js";
|
|
6
|
+
export function Workspaces({ workspaces, selected, onChanged, onSelect }) {
|
|
7
|
+
const client = useShepherdClient();
|
|
8
|
+
const headingId = useId();
|
|
9
|
+
const [name, setName] = useState("");
|
|
10
|
+
const [code, setCode] = useState("");
|
|
11
|
+
const [invite, setInvite] = useState(null);
|
|
12
|
+
const [error, setError] = useState(null);
|
|
13
|
+
const [busy, setBusy] = useState(false);
|
|
14
|
+
// Bumped after a mutation that affects the current workspace's roster, so the
|
|
15
|
+
// <Members> child refetches (e.g. after an invite is created or a join lands).
|
|
16
|
+
const [membersRefreshKey, setMembersRefreshKey] = useState(0);
|
|
17
|
+
const isAdmin = selected?.role === "admin";
|
|
18
|
+
async function create() {
|
|
19
|
+
if (!name.trim() || busy)
|
|
20
|
+
return;
|
|
21
|
+
setBusy(true);
|
|
22
|
+
setError(null);
|
|
23
|
+
try {
|
|
24
|
+
await client.createWorkspace({ name: name.trim() });
|
|
25
|
+
setName("");
|
|
26
|
+
onChanged();
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
setError(describeError(err));
|
|
30
|
+
}
|
|
31
|
+
finally {
|
|
32
|
+
setBusy(false);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
async function join() {
|
|
36
|
+
if (!code.trim() || busy)
|
|
37
|
+
return;
|
|
38
|
+
setBusy(true);
|
|
39
|
+
setError(null);
|
|
40
|
+
try {
|
|
41
|
+
await client.redeemInvite(code.trim());
|
|
42
|
+
setCode("");
|
|
43
|
+
onChanged();
|
|
44
|
+
setMembersRefreshKey((k) => k + 1);
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
setError(describeError(err));
|
|
48
|
+
}
|
|
49
|
+
finally {
|
|
50
|
+
setBusy(false);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
async function createInvite() {
|
|
54
|
+
if (!selected || busy)
|
|
55
|
+
return;
|
|
56
|
+
setBusy(true);
|
|
57
|
+
setError(null);
|
|
58
|
+
try {
|
|
59
|
+
const res = await client.createInvite(selected.id, {});
|
|
60
|
+
setInvite(res);
|
|
61
|
+
setMembersRefreshKey((k) => k + 1);
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
setError(describeError(err));
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
setBusy(false);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
async function revokeInvite() {
|
|
71
|
+
if (!selected || !invite || busy)
|
|
72
|
+
return;
|
|
73
|
+
setBusy(true);
|
|
74
|
+
setError(null);
|
|
75
|
+
try {
|
|
76
|
+
await client.revokeInvite(selected.id, invite.code);
|
|
77
|
+
setInvite(null);
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
setError(describeError(err));
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
setBusy(false);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// Clear any displayed invite when the selected workspace changes.
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
setInvite(null);
|
|
89
|
+
}, [selected?.id]);
|
|
90
|
+
// InviteResponse has no `link`; build the shareable join URL from the code.
|
|
91
|
+
// Encode the code for path-safety (parity with client.ts path encoding).
|
|
92
|
+
const joinLink = invite
|
|
93
|
+
? `${window.location.origin}/join/${encodeURIComponent(invite.code)}`
|
|
94
|
+
: null;
|
|
95
|
+
return (_jsxs("section", { className: "shepherd-workspaces", "aria-labelledby": headingId, children: [_jsx("h3", { id: headingId, children: "Workspace management" }), error && _jsx("p", { role: "alert", children: error }), workspaces.length > 1 && onSelect && (_jsxs("div", { className: "switcher", children: [_jsx("label", { htmlFor: "ws-switcher", children: "Active workspace" }), _jsx("select", { id: "ws-switcher", value: selected?.id ?? "", onChange: (e) => onSelect(e.target.value), children: workspaces.map((w) => (_jsx("option", { value: w.id, children: w.name }, w.id))) })] })), _jsxs("div", { className: "create", children: [_jsx("label", { htmlFor: "ws-name", children: "Workspace name" }), _jsx("input", { id: "ws-name", type: "text", value: name, onChange: (e) => setName(e.target.value) }), _jsx("button", { type: "button", onClick: () => void create(), disabled: busy, children: "Create workspace" })] }), _jsxs("div", { className: "join", children: [_jsx("label", { htmlFor: "invite-code", children: "Invite code" }), _jsx("input", { id: "invite-code", type: "text", value: code, onChange: (e) => setCode(e.target.value) }), _jsx("button", { type: "button", onClick: () => void join(), disabled: busy, children: "Join" })] }), isAdmin && selected && (_jsxs("div", { className: "admin", children: [_jsxs("div", { className: "invites", children: [_jsx("button", { type: "button", onClick: () => void createInvite(), disabled: busy, children: "Create invite" }), invite && (_jsxs("div", { className: "invite-result", children: [_jsx("code", { children: invite.code }), _jsx("a", { href: joinLink ?? undefined, children: joinLink }), _jsxs("span", { className: "invite-uses", children: [invite.useCount, " / ", invite.maxUses, " uses"] }), _jsx("button", { type: "button", onClick: () => void revokeInvite(), disabled: busy, children: "Revoke invite" })] }))] }), _jsx(Members, { workspaceId: selected.id, refreshKey: membersRefreshKey, onLeft: onChanged, canRemove: true })] }))] }));
|
|
96
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { ConnectAgent } from "./ConnectAgent.js";
|
|
2
|
+
export type { ConnectAgentProps } from "./ConnectAgent.js";
|
|
3
|
+
export { Workspaces } from "./Workspaces.js";
|
|
4
|
+
export type { WorkspacesProps } from "./Workspaces.js";
|
|
5
|
+
export { Members } from "./Members.js";
|
|
6
|
+
export type { MembersProps } from "./Members.js";
|
|
7
|
+
export { EmptyState } from "./EmptyState.js";
|
|
8
|
+
export type { EmptyStateProps } from "./EmptyState.js";
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Config-surface components (Task 6.3). Re-exported so consumers can compose
|
|
2
|
+
// the pieces directly if they don't use the full ShepherdRoot shell.
|
|
3
|
+
export { ConnectAgent } from "./ConnectAgent.js";
|
|
4
|
+
export { Workspaces } from "./Workspaces.js";
|
|
5
|
+
export { Members } from "./Members.js";
|
|
6
|
+
export { EmptyState } from "./EmptyState.js";
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type { ShepherdClient } from "./client.js";
|
|
3
|
+
/** Props for {@link ShepherdClientProvider}. */
|
|
4
|
+
export interface ShepherdClientProviderProps {
|
|
5
|
+
/** The client made available to every descendant via the context. */
|
|
6
|
+
client: ShepherdClient;
|
|
7
|
+
/** The subtree that may read the client through {@link useShepherdClient}. */
|
|
8
|
+
children: ReactNode;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Supplies a {@link ShepherdClient} to the React subtree. This is the seam that
|
|
12
|
+
* keeps the dashboard auth-agnostic: the host constructs a client (wiring in
|
|
13
|
+
* whatever auth it uses) and injects it here, so components below only ever see
|
|
14
|
+
* the auth-neutral client interface — never tokens or transport details.
|
|
15
|
+
*
|
|
16
|
+
* @param props - The client to provide and the children that consume it.
|
|
17
|
+
* @returns A provider element wrapping `children`.
|
|
18
|
+
*/
|
|
19
|
+
export declare function ShepherdClientProvider({ client, children, }: ShepherdClientProviderProps): ReactNode;
|
|
20
|
+
/**
|
|
21
|
+
* Reads the {@link ShepherdClient} from context. Throws when called outside a
|
|
22
|
+
* {@link ShepherdClientProvider} so a missing provider surfaces as an immediate,
|
|
23
|
+
* named error at render time instead of a confusing null-dereference later.
|
|
24
|
+
*
|
|
25
|
+
* @returns The client supplied by the nearest provider.
|
|
26
|
+
* @throws Error When invoked with no {@link ShepherdClientProvider} ancestor.
|
|
27
|
+
*/
|
|
28
|
+
export declare function useShepherdClient(): ShepherdClient;
|
package/dist/context.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { createContext, useContext } from "react";
|
|
3
|
+
/**
|
|
4
|
+
* Holds the active {@link ShepherdClient}, or `null` when no provider is above
|
|
5
|
+
* a consumer. The nullable default lets {@link useShepherdClient} distinguish
|
|
6
|
+
* "no provider" from a legitimately-provided client and fail loudly rather than
|
|
7
|
+
* handing back a silent stand-in.
|
|
8
|
+
*/
|
|
9
|
+
const ShepherdClientContext = createContext(null);
|
|
10
|
+
/**
|
|
11
|
+
* Supplies a {@link ShepherdClient} to the React subtree. This is the seam that
|
|
12
|
+
* keeps the dashboard auth-agnostic: the host constructs a client (wiring in
|
|
13
|
+
* whatever auth it uses) and injects it here, so components below only ever see
|
|
14
|
+
* the auth-neutral client interface — never tokens or transport details.
|
|
15
|
+
*
|
|
16
|
+
* @param props - The client to provide and the children that consume it.
|
|
17
|
+
* @returns A provider element wrapping `children`.
|
|
18
|
+
*/
|
|
19
|
+
export function ShepherdClientProvider({ client, children, }) {
|
|
20
|
+
return (_jsx(ShepherdClientContext.Provider, { value: client, children: children }));
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Reads the {@link ShepherdClient} from context. Throws when called outside a
|
|
24
|
+
* {@link ShepherdClientProvider} so a missing provider surfaces as an immediate,
|
|
25
|
+
* named error at render time instead of a confusing null-dereference later.
|
|
26
|
+
*
|
|
27
|
+
* @returns The client supplied by the nearest provider.
|
|
28
|
+
* @throws Error When invoked with no {@link ShepherdClientProvider} ancestor.
|
|
29
|
+
*/
|
|
30
|
+
export function useShepherdClient() {
|
|
31
|
+
const client = useContext(ShepherdClientContext);
|
|
32
|
+
if (client === null) {
|
|
33
|
+
throw new Error("useShepherdClient must be used within <ShepherdClientProvider>");
|
|
34
|
+
}
|
|
35
|
+
return client;
|
|
36
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public `.` entry for `@korso/shepherd-ui` — the auth-agnostic surface hosted
|
|
3
|
+
* consumers import. It deliberately does NOT export {@link SelfHostApp} (the
|
|
4
|
+
* token-gated self-host root lives behind the `./selfhost` export) and does NOT
|
|
5
|
+
* import `styles.css` — hosted consumers opt into styling via
|
|
6
|
+
* `@korso/shepherd-ui/styles.css`. This keeps the token gate out of `.` so the
|
|
7
|
+
* core stays auth-neutral.
|
|
8
|
+
*/
|
|
9
|
+
export { createShepherdClient, ShepherdClientError, describeError, } from "./client.js";
|
|
10
|
+
export type { ShepherdClient, ShepherdClientConfig } from "./client.js";
|
|
11
|
+
export { ShepherdClientProvider, useShepherdClient, } from "./context.js";
|
|
12
|
+
export { ShepherdRoot } from "./ShepherdRoot.js";
|
|
13
|
+
export type { ShepherdRootProps } from "./ShepherdRoot.js";
|
|
14
|
+
export { Dashboard } from "./components/Dashboard.js";
|
|
15
|
+
export type { DashboardProps } from "./components/Dashboard.js";
|
|
16
|
+
export { Workspaces, Members, ConnectAgent, EmptyState, } from "./config/index.js";
|
|
17
|
+
export type { WorkspacesProps, MembersProps, ConnectAgentProps, EmptyStateProps, } from "./config/index.js";
|
|
18
|
+
export type { WorkspaceLandscapeResponseT, WorkspaceSummaryT, TokenSummaryT, MemberSummaryT, InviteResponseT, } from "@shepherd/shared";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public `.` entry for `@korso/shepherd-ui` — the auth-agnostic surface hosted
|
|
3
|
+
* consumers import. It deliberately does NOT export {@link SelfHostApp} (the
|
|
4
|
+
* token-gated self-host root lives behind the `./selfhost` export) and does NOT
|
|
5
|
+
* import `styles.css` — hosted consumers opt into styling via
|
|
6
|
+
* `@korso/shepherd-ui/styles.css`. This keeps the token gate out of `.` so the
|
|
7
|
+
* core stays auth-neutral.
|
|
8
|
+
*/
|
|
9
|
+
export { createShepherdClient, ShepherdClientError, describeError, } from "./client.js";
|
|
10
|
+
export { ShepherdClientProvider, useShepherdClient, } from "./context.js";
|
|
11
|
+
export { ShepherdRoot } from "./ShepherdRoot.js";
|
|
12
|
+
export { Dashboard } from "./components/Dashboard.js";
|
|
13
|
+
// Config screens — re-exported so consumers can compose the management surface
|
|
14
|
+
// directly without the full ShepherdRoot shell.
|
|
15
|
+
export { Workspaces, Members, ConnectAgent, EmptyState, } from "./config/index.js";
|