@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,9 @@
|
|
|
1
|
+
export interface ShepherdRootProps {
|
|
2
|
+
/**
|
|
3
|
+
* The DIRECT Hub URL embedded in the agent install command (planning decision
|
|
4
|
+
* #2: the headless agent connects straight to the Hub, not the BFF). Defaults
|
|
5
|
+
* to the client's baseUrl, which is correct for self-host.
|
|
6
|
+
*/
|
|
7
|
+
hubUrl?: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function ShepherdRoot({ hubUrl }: ShepherdRootProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useCallback, useEffect, useState } from "react";
|
|
3
|
+
import { Dashboard } from "./components/Dashboard.js";
|
|
4
|
+
import { Workspaces } from "./config/Workspaces.js";
|
|
5
|
+
import { ConnectAgent } from "./config/ConnectAgent.js";
|
|
6
|
+
import { useShepherdClient } from "./context.js";
|
|
7
|
+
import { describeError } from "./client.js";
|
|
8
|
+
export function ShepherdRoot({ hubUrl }) {
|
|
9
|
+
const client = useShepherdClient();
|
|
10
|
+
const [load, setLoad] = useState({ status: "loading" });
|
|
11
|
+
const [selectedId, setSelectedId] = useState(null);
|
|
12
|
+
// Loads the workspace list and keeps the selection valid: a still-present
|
|
13
|
+
// selection is preserved (so a create/join refresh never yanks the user off
|
|
14
|
+
// their current workspace), otherwise it falls back to the first workspace.
|
|
15
|
+
const fetchWorkspaces = useCallback(async () => {
|
|
16
|
+
try {
|
|
17
|
+
const res = await client.listWorkspaces();
|
|
18
|
+
setLoad({ status: "ready", workspaces: res.workspaces });
|
|
19
|
+
setSelectedId((prev) => prev && res.workspaces.some((w) => w.id === prev)
|
|
20
|
+
? prev
|
|
21
|
+
: (res.workspaces[0]?.id ?? null));
|
|
22
|
+
}
|
|
23
|
+
catch (err) {
|
|
24
|
+
setLoad({ status: "error", message: describeError(err) });
|
|
25
|
+
}
|
|
26
|
+
}, [client]);
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
void fetchWorkspaces();
|
|
29
|
+
}, [fetchWorkspaces]);
|
|
30
|
+
const workspaces = load.status === "ready" ? load.workspaces : [];
|
|
31
|
+
const selected = workspaces.find((w) => w.id === selectedId) ?? null;
|
|
32
|
+
if (load.status === "loading") {
|
|
33
|
+
return (_jsx("div", { className: "shepherd-root", children: _jsx("p", { role: "status", children: "Loading\u2026" }) }));
|
|
34
|
+
}
|
|
35
|
+
if (load.status === "error") {
|
|
36
|
+
return (_jsx("div", { className: "shepherd-root", children: _jsx("p", { role: "alert", children: load.message }) }));
|
|
37
|
+
}
|
|
38
|
+
const hasWorkspace = workspaces.length > 0;
|
|
39
|
+
// The Config tab's content. Rendered by <Dashboard> in its `config` panel.
|
|
40
|
+
// ConnectAgent only appears once a workspace is selected (nothing to connect
|
|
41
|
+
// an agent to otherwise).
|
|
42
|
+
const configSection = (_jsxs("section", { className: "shepherd-config", "aria-labelledby": "config-heading", children: [_jsx("h2", { id: "config-heading", children: "Configuration" }), _jsx(Workspaces, { workspaces: workspaces, selected: selected, onChanged: () => {
|
|
43
|
+
void fetchWorkspaces();
|
|
44
|
+
}, onSelect: setSelectedId }), selected && _jsx(ConnectAgent, { workspaceId: selected.id, hubUrl: hubUrl })] }));
|
|
45
|
+
return (_jsx("div", { className: "shepherd-root", children: _jsx(Dashboard, { workspaceId: selected?.id, config: configSection, hasWorkspace: hasWorkspace }) }));
|
|
46
|
+
}
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { ListWorkspacesResponseT, CreateWorkspaceRequestT, CreateWorkspaceResponseT, MintTokenRequestT, MintTokenResponseT, ListTokensResponseT, CreateInviteRequestT, InviteResponseT, RedeemInviteResponseT, ListMembersResponseT, WorkspaceLandscapeResponseT, WorkspaceAnnounceRequestT, WorkspaceAnnounceResponseT } from "@shepherd/shared";
|
|
2
|
+
/**
|
|
3
|
+
* The single error type surfaced by the client. A missing `status` means a
|
|
4
|
+
* transport failure (network error or request timeout); a present `status` is
|
|
5
|
+
* the upstream HTTP status of a non-2xx response. One class — distinguished by
|
|
6
|
+
* the optional `status` — keeps callers from having to branch on two error
|
|
7
|
+
* types just to tell "couldn't reach the hub" from "the hub said no".
|
|
8
|
+
*/
|
|
9
|
+
export declare class ShepherdClientError extends Error {
|
|
10
|
+
/** Upstream HTTP status for a non-2xx response; absent for transport errors. */
|
|
11
|
+
readonly status?: number;
|
|
12
|
+
/**
|
|
13
|
+
* @param message - Human-readable failure description.
|
|
14
|
+
* @param status - Upstream HTTP status, omitted for network/abort failures.
|
|
15
|
+
*/
|
|
16
|
+
constructor(message: string, status?: number);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Maps any thrown value into a short, human-friendly message for the view layer.
|
|
20
|
+
* A {@link ShepherdClientError} (and any other `Error`) surfaces its own
|
|
21
|
+
* `message` — which already carries the hub's detail for non-2xx responses and a
|
|
22
|
+
* transport description otherwise — while a non-Error value degrades to a
|
|
23
|
+
* generic line rather than stringifying something unprintable.
|
|
24
|
+
*
|
|
25
|
+
* @param err - The caught value (typically from a client method rejection).
|
|
26
|
+
* @returns A display-ready message string.
|
|
27
|
+
*/
|
|
28
|
+
export declare function describeError(err: unknown): string;
|
|
29
|
+
/**
|
|
30
|
+
* The typed Shepherd hub surface. The plural, workspace-scoped methods use the
|
|
31
|
+
* `/workspaces/:id/...` form (which works in both deployments: a bearer token
|
|
32
|
+
* resolves its own workspace and ignores `:id`, while a browser session
|
|
33
|
+
* validates membership of `:id`). The singular `getLandscape()`/`announce()`
|
|
34
|
+
* pair are the self-host aliases against the implicit single-workspace routes.
|
|
35
|
+
* Every response with a dedicated schema is Zod-parsed at the boundary so a
|
|
36
|
+
* contract drift throws `Invalid response schema` rather than flowing a
|
|
37
|
+
* malformed object into the view layer.
|
|
38
|
+
*/
|
|
39
|
+
export interface ShepherdClient {
|
|
40
|
+
/** The normalised (trailing-slash-stripped) hub base URL the client targets. */
|
|
41
|
+
readonly baseUrl: string;
|
|
42
|
+
/** GET the caller's workspaces, each tagged with the caller's role. */
|
|
43
|
+
listWorkspaces(): Promise<ListWorkspacesResponseT>;
|
|
44
|
+
/** POST a new workspace; the caller becomes its admin. */
|
|
45
|
+
createWorkspace(body: CreateWorkspaceRequestT): Promise<CreateWorkspaceResponseT>;
|
|
46
|
+
/** POST a new agent token; the raw `shp_` value is returned exactly once. */
|
|
47
|
+
mintToken(workspaceId: string, body: MintTokenRequestT): Promise<MintTokenResponseT>;
|
|
48
|
+
/** GET the workspace's token metadata (never the raw token). */
|
|
49
|
+
listTokens(workspaceId: string): Promise<ListTokensResponseT>;
|
|
50
|
+
/** DELETE (revoke) a token by id. */
|
|
51
|
+
revokeToken(workspaceId: string, tokenId: string): Promise<void>;
|
|
52
|
+
/** POST a new invite code for the workspace (admin only). */
|
|
53
|
+
createInvite(workspaceId: string, body: CreateInviteRequestT): Promise<InviteResponseT>;
|
|
54
|
+
/** POST to revoke an invite code (admin only). */
|
|
55
|
+
revokeInvite(workspaceId: string, code: string): Promise<void>;
|
|
56
|
+
/** POST to redeem an invite code, joining its workspace as a member. */
|
|
57
|
+
redeemInvite(code: string): Promise<RedeemInviteResponseT>;
|
|
58
|
+
/** GET the workspace's member roster. */
|
|
59
|
+
listMembers(workspaceId: string): Promise<ListMembersResponseT>;
|
|
60
|
+
/** DELETE (remove) a member by account id (admin only). */
|
|
61
|
+
removeMember(workspaceId: string, accountId: string): Promise<void>;
|
|
62
|
+
/** POST to leave the workspace. */
|
|
63
|
+
leave(workspaceId: string): Promise<void>;
|
|
64
|
+
/** GET a specific workspace's landscape (agents, tasks, announcements). */
|
|
65
|
+
landscape(workspaceId: string): Promise<WorkspaceLandscapeResponseT>;
|
|
66
|
+
/** POST an operator announcement to a specific workspace. */
|
|
67
|
+
announceTo(workspaceId: string, body: WorkspaceAnnounceRequestT): Promise<WorkspaceAnnounceResponseT>;
|
|
68
|
+
/** GET the unfiltered whole-workspace view (agents, tasks, announcements). */
|
|
69
|
+
getLandscape(): Promise<WorkspaceLandscapeResponseT>;
|
|
70
|
+
/** POST an operator announcement (broadcast or @targeted) to the workspace. */
|
|
71
|
+
announce(req: WorkspaceAnnounceRequestT): Promise<WorkspaceAnnounceResponseT>;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Construction options for {@link createShepherdClient}. Auth is supplied
|
|
75
|
+
* externally via `getAuthHeader` so the core client stays auth-agnostic — it
|
|
76
|
+
* never reads tokens, localStorage, or a BFF; it only merges whatever the host
|
|
77
|
+
* injects and notifies the host on a 401 via `onUnauthorized`.
|
|
78
|
+
*/
|
|
79
|
+
export interface ShepherdClientConfig {
|
|
80
|
+
/** Hub origin; a trailing slash is tolerated and normalised away. "" = same-origin. */
|
|
81
|
+
baseUrl: string;
|
|
82
|
+
/**
|
|
83
|
+
* Returns the credential for the next request, as either:
|
|
84
|
+
* - a string → sent as the `Authorization` header value (e.g. "Bearer …"),
|
|
85
|
+
* - a header map → merged verbatim over the JSON content-type base, or
|
|
86
|
+
* - undefined → no auth header added (same-origin BFF supplies the cookie).
|
|
87
|
+
* May be sync or async (e.g. refreshing a short-lived token). Omit entirely
|
|
88
|
+
* for an unauthenticated/same-origin deployment.
|
|
89
|
+
*/
|
|
90
|
+
getAuthHeader?: () => string | Record<string, string> | undefined | Promise<string | Record<string, string> | undefined>;
|
|
91
|
+
/** Invoked once when the hub responds 401, before the error is thrown. */
|
|
92
|
+
onUnauthorized?: () => void;
|
|
93
|
+
/** Per-request abort timeout in ms; defaults to {@link DEFAULT_TIMEOUT_MS}. */
|
|
94
|
+
timeoutMs?: number;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Builds a browser fetch client for the Shepherd hub. Each call wires up an
|
|
98
|
+
* AbortController timeout (cleared in `finally`), merges the injected auth
|
|
99
|
+
* headers over a JSON content-type base, and Zod-parses ONLY the response at
|
|
100
|
+
* the boundary — the request body is forwarded verbatim because the caller owns
|
|
101
|
+
* its input shape. void-returning methods (revoke/remove/leave) await the
|
|
102
|
+
* request and parse nothing.
|
|
103
|
+
*
|
|
104
|
+
* @param config - Base URL, optional injected auth, 401 hook, and timeout.
|
|
105
|
+
* @returns A {@link ShepherdClient}.
|
|
106
|
+
*/
|
|
107
|
+
export declare function createShepherdClient(config: ShepherdClientConfig): ShepherdClient;
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { ListWorkspacesResponse, CreateWorkspaceResponse, MintTokenResponse, ListTokensResponse, InviteResponse, RedeemInviteResponse, ListMembersResponse, WorkspaceLandscapeResponse, WorkspaceAnnounceResponse, } from "@shepherd/shared";
|
|
2
|
+
/** Per-request abort fires after this many ms when no timeoutMs is configured. */
|
|
3
|
+
const DEFAULT_TIMEOUT_MS = 5000;
|
|
4
|
+
/**
|
|
5
|
+
* The single error type surfaced by the client. A missing `status` means a
|
|
6
|
+
* transport failure (network error or request timeout); a present `status` is
|
|
7
|
+
* the upstream HTTP status of a non-2xx response. One class — distinguished by
|
|
8
|
+
* the optional `status` — keeps callers from having to branch on two error
|
|
9
|
+
* types just to tell "couldn't reach the hub" from "the hub said no".
|
|
10
|
+
*/
|
|
11
|
+
export class ShepherdClientError extends Error {
|
|
12
|
+
/** Upstream HTTP status for a non-2xx response; absent for transport errors. */
|
|
13
|
+
status;
|
|
14
|
+
/**
|
|
15
|
+
* @param message - Human-readable failure description.
|
|
16
|
+
* @param status - Upstream HTTP status, omitted for network/abort failures.
|
|
17
|
+
*/
|
|
18
|
+
constructor(message, status) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = "ShepherdClientError";
|
|
21
|
+
if (status !== undefined) {
|
|
22
|
+
this.status = status;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Maps any thrown value into a short, human-friendly message for the view layer.
|
|
28
|
+
* A {@link ShepherdClientError} (and any other `Error`) surfaces its own
|
|
29
|
+
* `message` — which already carries the hub's detail for non-2xx responses and a
|
|
30
|
+
* transport description otherwise — while a non-Error value degrades to a
|
|
31
|
+
* generic line rather than stringifying something unprintable.
|
|
32
|
+
*
|
|
33
|
+
* @param err - The caught value (typically from a client method rejection).
|
|
34
|
+
* @returns A display-ready message string.
|
|
35
|
+
*/
|
|
36
|
+
export function describeError(err) {
|
|
37
|
+
return err instanceof Error ? err.message : "Something went wrong.";
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Reads a best-effort `{ error: string }` message from a non-2xx response body
|
|
41
|
+
* so the thrown error carries the hub's own explanation. A non-JSON or bodyless
|
|
42
|
+
* response degrades to an empty string — the status code is still informative.
|
|
43
|
+
*
|
|
44
|
+
* @param res - The non-2xx response to inspect.
|
|
45
|
+
* @returns The upstream error string, or "" when none could be read.
|
|
46
|
+
*/
|
|
47
|
+
async function readErrorDetail(res) {
|
|
48
|
+
try {
|
|
49
|
+
const data = await res.json();
|
|
50
|
+
if (data !== null &&
|
|
51
|
+
typeof data === "object" &&
|
|
52
|
+
"error" in data &&
|
|
53
|
+
typeof data.error === "string") {
|
|
54
|
+
return data.error;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// Body wasn't JSON; the status code alone remains informative.
|
|
59
|
+
}
|
|
60
|
+
return "";
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Builds a browser fetch client for the Shepherd hub. Each call wires up an
|
|
64
|
+
* AbortController timeout (cleared in `finally`), merges the injected auth
|
|
65
|
+
* headers over a JSON content-type base, and Zod-parses ONLY the response at
|
|
66
|
+
* the boundary — the request body is forwarded verbatim because the caller owns
|
|
67
|
+
* its input shape. void-returning methods (revoke/remove/leave) await the
|
|
68
|
+
* request and parse nothing.
|
|
69
|
+
*
|
|
70
|
+
* @param config - Base URL, optional injected auth, 401 hook, and timeout.
|
|
71
|
+
* @returns A {@link ShepherdClient}.
|
|
72
|
+
*/
|
|
73
|
+
export function createShepherdClient(config) {
|
|
74
|
+
// Strip a trailing slash so `${baseUrl}/workspace/...` never yields a double
|
|
75
|
+
// slash regardless of how the host configured the origin. An empty baseUrl
|
|
76
|
+
// stays "" so paths resolve root-relative (same-origin).
|
|
77
|
+
const baseUrl = config.baseUrl.replace(/\/$/, "");
|
|
78
|
+
const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
79
|
+
const enc = encodeURIComponent;
|
|
80
|
+
/**
|
|
81
|
+
* Performs one request, mapping transport failures to a status-less error,
|
|
82
|
+
* 401s to `onUnauthorized` + a 401 error, other non-2xx to a status error,
|
|
83
|
+
* and a 2xx body either through the supplied Zod schema or — when no schema
|
|
84
|
+
* is given (void endpoints) — to `undefined`.
|
|
85
|
+
*/
|
|
86
|
+
async function request(method, path, opts = {}) {
|
|
87
|
+
const controller = new AbortController();
|
|
88
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
89
|
+
let res;
|
|
90
|
+
try {
|
|
91
|
+
const baseHeaders = {
|
|
92
|
+
"Content-Type": "application/json",
|
|
93
|
+
};
|
|
94
|
+
// Resolve getAuthHeader (sync or async) into a header record: a string is
|
|
95
|
+
// the Authorization value, a map merges verbatim, undefined adds nothing.
|
|
96
|
+
// Injected auth wins over the base, so a host may even override the
|
|
97
|
+
// content-type if it ever needs to. Built INSIDE the try so a rejecting
|
|
98
|
+
// (async) getAuthHeader is funneled through the catch and the finally
|
|
99
|
+
// still clears the abort timer — no leaked timer on an auth failure.
|
|
100
|
+
const resolved = await config.getAuthHeader?.();
|
|
101
|
+
const authHeaders = typeof resolved === "string"
|
|
102
|
+
? { Authorization: resolved }
|
|
103
|
+
: resolved ?? {};
|
|
104
|
+
const init = {
|
|
105
|
+
method,
|
|
106
|
+
headers: { ...baseHeaders, ...authHeaders },
|
|
107
|
+
signal: controller.signal,
|
|
108
|
+
};
|
|
109
|
+
if (opts.body !== undefined) {
|
|
110
|
+
init.body = JSON.stringify(opts.body);
|
|
111
|
+
}
|
|
112
|
+
res = await fetch(`${baseUrl}${path}`, init);
|
|
113
|
+
}
|
|
114
|
+
catch (err) {
|
|
115
|
+
// Network error or abort/timeout: no HTTP status exists, so the error is
|
|
116
|
+
// thrown WITHOUT one to mark it a transport failure.
|
|
117
|
+
throw new ShepherdClientError(err instanceof Error ? err.message : String(err));
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
clearTimeout(timer);
|
|
121
|
+
}
|
|
122
|
+
if (res.status === 401) {
|
|
123
|
+
config.onUnauthorized?.();
|
|
124
|
+
throw new ShepherdClientError("Unauthorized", 401);
|
|
125
|
+
}
|
|
126
|
+
if (!res.ok) {
|
|
127
|
+
const detail = await readErrorDetail(res);
|
|
128
|
+
throw new ShepherdClientError(detail !== ""
|
|
129
|
+
? `HTTP ${res.status}: ${detail}`
|
|
130
|
+
: `HTTP ${res.status}`, res.status);
|
|
131
|
+
}
|
|
132
|
+
if (!opts.schema) {
|
|
133
|
+
// void endpoint: no dedicated response schema. Drain any body and resolve
|
|
134
|
+
// undefined — callers of these methods don't depend on the payload.
|
|
135
|
+
try {
|
|
136
|
+
await res.json();
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
// empty / non-JSON body is fine for a void endpoint.
|
|
140
|
+
}
|
|
141
|
+
// void endpoints are called as request<void>(...); the undefined resolve
|
|
142
|
+
// is the intended value, asserted to T since the no-schema branch is only
|
|
143
|
+
// reached for the void-typed callers.
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
const parsed = opts.schema.safeParse(await res.json());
|
|
147
|
+
if (!parsed.success) {
|
|
148
|
+
throw new ShepherdClientError("Invalid response schema");
|
|
149
|
+
}
|
|
150
|
+
return parsed.data;
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
baseUrl,
|
|
154
|
+
// --- multi-workspace management surface ---------------------------------
|
|
155
|
+
listWorkspaces() {
|
|
156
|
+
return request("GET", "/workspaces", {
|
|
157
|
+
schema: ListWorkspacesResponse,
|
|
158
|
+
});
|
|
159
|
+
},
|
|
160
|
+
createWorkspace(body) {
|
|
161
|
+
return request("POST", "/workspaces", {
|
|
162
|
+
body,
|
|
163
|
+
schema: CreateWorkspaceResponse,
|
|
164
|
+
});
|
|
165
|
+
},
|
|
166
|
+
mintToken(workspaceId, body) {
|
|
167
|
+
return request("POST", `/workspaces/${enc(workspaceId)}/tokens`, {
|
|
168
|
+
body,
|
|
169
|
+
schema: MintTokenResponse,
|
|
170
|
+
});
|
|
171
|
+
},
|
|
172
|
+
listTokens(workspaceId) {
|
|
173
|
+
return request("GET", `/workspaces/${enc(workspaceId)}/tokens`, {
|
|
174
|
+
schema: ListTokensResponse,
|
|
175
|
+
});
|
|
176
|
+
},
|
|
177
|
+
revokeToken(workspaceId, tokenId) {
|
|
178
|
+
return request("DELETE", `/workspaces/${enc(workspaceId)}/tokens/${enc(tokenId)}`);
|
|
179
|
+
},
|
|
180
|
+
createInvite(workspaceId, body) {
|
|
181
|
+
return request("POST", `/workspaces/${enc(workspaceId)}/invites`, {
|
|
182
|
+
body,
|
|
183
|
+
schema: InviteResponse,
|
|
184
|
+
});
|
|
185
|
+
},
|
|
186
|
+
revokeInvite(workspaceId, code) {
|
|
187
|
+
return request("POST", `/workspaces/${enc(workspaceId)}/invites/${enc(code)}/revoke`);
|
|
188
|
+
},
|
|
189
|
+
redeemInvite(code) {
|
|
190
|
+
return request("POST", `/invites/${enc(code)}/redeem`, {
|
|
191
|
+
schema: RedeemInviteResponse,
|
|
192
|
+
});
|
|
193
|
+
},
|
|
194
|
+
listMembers(workspaceId) {
|
|
195
|
+
return request("GET", `/workspaces/${enc(workspaceId)}/members`, {
|
|
196
|
+
schema: ListMembersResponse,
|
|
197
|
+
});
|
|
198
|
+
},
|
|
199
|
+
removeMember(workspaceId, accountId) {
|
|
200
|
+
return request("DELETE", `/workspaces/${enc(workspaceId)}/members/${enc(accountId)}`);
|
|
201
|
+
},
|
|
202
|
+
leave(workspaceId) {
|
|
203
|
+
return request("POST", `/workspaces/${enc(workspaceId)}/leave`);
|
|
204
|
+
},
|
|
205
|
+
landscape(workspaceId) {
|
|
206
|
+
// WorkspaceLandscapeResponse's inferred type widens the announcement
|
|
207
|
+
// admin flags to optional, so it doesn't structurally match the declared
|
|
208
|
+
// ...ResponseT alias; an explicit cast is kept for just this schema.
|
|
209
|
+
return request("GET", `/workspaces/${enc(workspaceId)}/landscape`, {
|
|
210
|
+
schema: WorkspaceLandscapeResponse,
|
|
211
|
+
});
|
|
212
|
+
},
|
|
213
|
+
announceTo(workspaceId, body) {
|
|
214
|
+
// The request body is NOT validated here — the caller owns input shape;
|
|
215
|
+
// only the RESPONSE is parsed at the boundary.
|
|
216
|
+
return request("POST", `/workspaces/${enc(workspaceId)}/announce`, {
|
|
217
|
+
body,
|
|
218
|
+
schema: WorkspaceAnnounceResponse,
|
|
219
|
+
});
|
|
220
|
+
},
|
|
221
|
+
// --- self-host singular aliases (implicit single workspace) -------------
|
|
222
|
+
getLandscape() {
|
|
223
|
+
// See landscape() above: this schema's inferred type doesn't structurally
|
|
224
|
+
// match the ...ResponseT alias, so its explicit cast is kept.
|
|
225
|
+
return request("GET", "/workspace/landscape", {
|
|
226
|
+
schema: WorkspaceLandscapeResponse,
|
|
227
|
+
});
|
|
228
|
+
},
|
|
229
|
+
announce(req) {
|
|
230
|
+
// The request body is NOT validated here — the caller owns input shape;
|
|
231
|
+
// only the RESPONSE is parsed at the boundary.
|
|
232
|
+
return request("POST", "/workspace/announce", {
|
|
233
|
+
body: req,
|
|
234
|
+
schema: WorkspaceAnnounceResponse,
|
|
235
|
+
});
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ReactElement } from "react";
|
|
2
|
+
import type { WorkspaceTaskT } from "@shepherd/shared";
|
|
3
|
+
/** Props for {@link ActiveList}. */
|
|
4
|
+
export interface ActiveListProps {
|
|
5
|
+
/** The board's tasks; only active ones in the selected repo are shown. */
|
|
6
|
+
tasks: WorkspaceTaskT[];
|
|
7
|
+
/** The server-clock "now" in epoch ms, for relative time labels. */
|
|
8
|
+
nowMs: number;
|
|
9
|
+
/** The selected repo, or `null`/`"__all__"` for all repos. */
|
|
10
|
+
selectedRepo: string | null;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* The Active column. Ported from app.js `renderActive`. Filters tasks to active
|
|
14
|
+
* + selected-repo, groups them by agent via {@link groupActiveClaims}, then
|
|
15
|
+
* renders a {@link PlainCard} for a lone claim or a {@link GroupCard} otherwise.
|
|
16
|
+
* Shows an empty-state message when nothing is active. In All-repos mode each
|
|
17
|
+
* card carries a repo tag; in single-repo mode it does not.
|
|
18
|
+
*
|
|
19
|
+
* @param props - The tasks, the server "now", and the repo filter.
|
|
20
|
+
* @returns The active list element.
|
|
21
|
+
*/
|
|
22
|
+
export declare function ActiveList({ tasks, nowMs, selectedRepo, }: ActiveListProps): ReactElement;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { formatRelative, groupActiveClaims, matchesRepo, } from "../logic.js";
|
|
3
|
+
import { Territory } from "./Territory.js";
|
|
4
|
+
/** Stable identity for a claim across renders (the payload has no claim id). */
|
|
5
|
+
const claimKey = (t) => `${t.agentName}|${t.repo}|${t.createdAt}`;
|
|
6
|
+
/** Whether the board is showing all repos (so cards carry a repo tag). */
|
|
7
|
+
const isAllRepos = (selected) => selected === null || selected === "__all__";
|
|
8
|
+
/**
|
|
9
|
+
* One active claim's body: intent + territory + a "started N ago" meta line.
|
|
10
|
+
* Shared by the plain card and each row of a grouped card (app.js `claimBody`).
|
|
11
|
+
*/
|
|
12
|
+
function ClaimBody({ task, nowMs, }) {
|
|
13
|
+
return (_jsxs(_Fragment, { children: [_jsx("div", { className: "task__intent", children: task.intent }), _jsx(Territory, { globs: task.pathGlobs }), _jsx("div", { className: "task__meta", children: `started ${formatRelative(task.createdAt, nowMs)}` })] }));
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* A lone agent/claim rendered as the classic single card (app.js `plainCard`):
|
|
17
|
+
* the agent, an optional model/program tag, a live dot, then the claim body.
|
|
18
|
+
*/
|
|
19
|
+
function PlainCard({ task, nowMs, allRepos, }) {
|
|
20
|
+
const tag = task.model || task.program;
|
|
21
|
+
return (_jsxs("div", { className: "task", children: [_jsxs("div", { className: "task__r1", children: [_jsx("span", { className: "task__who", children: task.agentName }), allRepos && _jsx("span", { className: "task__repo", children: task.repo }), tag && _jsx("span", { className: "task__tag", children: tag }), _jsx("span", { className: "livedot", title: "active" })] }), _jsx(ClaimBody, { task: task, nowMs: nowMs })] }));
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* An agent with multiple live claims rendered grouped (app.js `groupCard`): a
|
|
25
|
+
* header, one `.claim` per primary, and a collapsible `"+N narrower claim(s)"`
|
|
26
|
+
* fold for claims a broader sibling fully covers (folded, never hidden).
|
|
27
|
+
*/
|
|
28
|
+
function GroupCard({ group, nowMs, allRepos, }) {
|
|
29
|
+
const tag = group.model || group.program;
|
|
30
|
+
const total = group.primaries.length + group.narrower.length;
|
|
31
|
+
const n = group.narrower.length;
|
|
32
|
+
return (_jsxs("div", { className: "grp", children: [_jsxs("div", { className: "grp__head", children: [_jsx("span", { className: "grp__who", children: group.agentName }), allRepos && _jsx("span", { className: "task__repo", children: group.repo }), tag && _jsx("span", { className: "grp__tag", children: tag }), _jsx("span", { className: "grp__count", children: `· ${total} active` }), _jsx("span", { className: "grp__dot", title: "live" })] }), _jsx("div", { className: "claims", children: group.primaries.map((c) => (_jsx("div", { className: "claim", children: _jsx(ClaimBody, { task: c, nowMs: nowMs }) }, claimKey(c)))) }), n > 0 && (_jsxs("details", { className: "fold", children: [_jsxs("summary", { children: [_jsx("span", { className: "tw", children: "\u25B6" }), ` +${n} narrower claim${n > 1 ? "s" : ""}`] }), _jsx("div", { className: "fold__body", children: group.narrower.map((c) => (_jsxs("div", { className: "claim", children: [_jsx(ClaimBody, { task: c, nowMs: nowMs }), _jsx("div", { className: "covered", children: "\u2282 covered by a claim above" })] }, claimKey(c)))) })] }))] }));
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The Active column. Ported from app.js `renderActive`. Filters tasks to active
|
|
36
|
+
* + selected-repo, groups them by agent via {@link groupActiveClaims}, then
|
|
37
|
+
* renders a {@link PlainCard} for a lone claim or a {@link GroupCard} otherwise.
|
|
38
|
+
* Shows an empty-state message when nothing is active. In All-repos mode each
|
|
39
|
+
* card carries a repo tag; in single-repo mode it does not.
|
|
40
|
+
*
|
|
41
|
+
* @param props - The tasks, the server "now", and the repo filter.
|
|
42
|
+
* @returns The active list element.
|
|
43
|
+
*/
|
|
44
|
+
export function ActiveList({ tasks, nowMs, selectedRepo, }) {
|
|
45
|
+
const allRepos = isAllRepos(selectedRepo);
|
|
46
|
+
const active = tasks.filter((t) => t.status === "active" && matchesRepo(t, selectedRepo));
|
|
47
|
+
if (active.length === 0) {
|
|
48
|
+
const msg = allRepos
|
|
49
|
+
? "Nothing active right now."
|
|
50
|
+
: `Nothing active in ${selectedRepo}.`;
|
|
51
|
+
return (_jsx("div", { id: "active-list", children: _jsx("div", { className: "empty", children: msg }) }));
|
|
52
|
+
}
|
|
53
|
+
return (_jsx("div", { id: "active-list", children: groupActiveClaims(active).map((g) => {
|
|
54
|
+
const single = g.primaries.length === 1 && g.narrower.length === 0;
|
|
55
|
+
// Key on the group's newest claim — stable across polls per agent.
|
|
56
|
+
const key = claimKey(g.primaries.concat(g.narrower)[0]);
|
|
57
|
+
return single ? (_jsx(PlainCard, { task: g.primaries[0], nowMs: nowMs, allRepos: allRepos }, key)) : (_jsx(GroupCard, { group: g, nowMs: nowMs, allRepos: allRepos }, key));
|
|
58
|
+
}) }));
|
|
59
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type { WorkspaceAnnouncementT } from "@shepherd/shared";
|
|
3
|
+
/** Props for {@link Chat}. */
|
|
4
|
+
export interface ChatProps {
|
|
5
|
+
/** The workspace announcement feed, newest-first as the API returns it. */
|
|
6
|
+
announcements: WorkspaceAnnouncementT[];
|
|
7
|
+
/** The board's selected repo; `null`/`"__all__"` show every repo's messages. */
|
|
8
|
+
selectedRepo: string | null;
|
|
9
|
+
/** The server clock in epoch ms, so "N ago" matches the rest of the board. */
|
|
10
|
+
nowMs: number;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* The group-chat view of the workspace announcement feed (the "Chat" tab).
|
|
14
|
+
*
|
|
15
|
+
* The API returns announcements newest-first, but a chat reads oldest -> newest
|
|
16
|
+
* top -> bottom, so the list is reversed before render; messages are filtered to
|
|
17
|
+
* the selected repo. Each row mirrors app.js `renderAnnouncements`: an avatar
|
|
18
|
+
* (initials + deterministic color), the sender, an optional "→ @target" /
|
|
19
|
+
* "→ admin" header, the relative time, and the body. Operator messages
|
|
20
|
+
* (`fromAdmin`) get `msg--me` (right-aligned); directed or to-admin messages get
|
|
21
|
+
* `msg--targeted`.
|
|
22
|
+
*
|
|
23
|
+
* The viewer stays pinned to the newest message UNLESS they have scrolled up to
|
|
24
|
+
* read history — measured against the scroll container before each re-render and
|
|
25
|
+
* re-applied in a layout effect so the write happens before paint (no flicker).
|
|
26
|
+
*
|
|
27
|
+
* @param props - The feed, the repo filter, and the server clock.
|
|
28
|
+
* @returns The scrollable chat element.
|
|
29
|
+
*/
|
|
30
|
+
export declare function Chat({ announcements, selectedRepo, nowMs }: ChatProps): ReactNode;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useLayoutEffect, useRef } from "react";
|
|
3
|
+
import { colorForName, formatRelative, initialsFor, matchesRepo, } from "../logic.js";
|
|
4
|
+
/**
|
|
5
|
+
* How close (in px) to the bottom still counts as "pinned to the newest
|
|
6
|
+
* message". Ported verbatim from app.js `chatIsNearBottom` — a small slack so a
|
|
7
|
+
* reader who is essentially at the bottom keeps following new messages, while
|
|
8
|
+
* one who has scrolled up to read history is left where they are.
|
|
9
|
+
*/
|
|
10
|
+
const NEAR_BOTTOM_PX = 80;
|
|
11
|
+
/**
|
|
12
|
+
* The group-chat view of the workspace announcement feed (the "Chat" tab).
|
|
13
|
+
*
|
|
14
|
+
* The API returns announcements newest-first, but a chat reads oldest -> newest
|
|
15
|
+
* top -> bottom, so the list is reversed before render; messages are filtered to
|
|
16
|
+
* the selected repo. Each row mirrors app.js `renderAnnouncements`: an avatar
|
|
17
|
+
* (initials + deterministic color), the sender, an optional "→ @target" /
|
|
18
|
+
* "→ admin" header, the relative time, and the body. Operator messages
|
|
19
|
+
* (`fromAdmin`) get `msg--me` (right-aligned); directed or to-admin messages get
|
|
20
|
+
* `msg--targeted`.
|
|
21
|
+
*
|
|
22
|
+
* The viewer stays pinned to the newest message UNLESS they have scrolled up to
|
|
23
|
+
* read history — measured against the scroll container before each re-render and
|
|
24
|
+
* re-applied in a layout effect so the write happens before paint (no flicker).
|
|
25
|
+
*
|
|
26
|
+
* @param props - The feed, the repo filter, and the server clock.
|
|
27
|
+
* @returns The scrollable chat element.
|
|
28
|
+
*/
|
|
29
|
+
export function Chat({ announcements, selectedRepo, nowMs }) {
|
|
30
|
+
const chatRef = useRef(null);
|
|
31
|
+
// Capture "were we near the bottom?" BEFORE React commits the new DOM, so the
|
|
32
|
+
// layout effect can decide whether to re-pin. A ref (not state) because it's a
|
|
33
|
+
// pre-paint measurement, never rendered.
|
|
34
|
+
const stickRef = useRef(true);
|
|
35
|
+
const c = chatRef.current;
|
|
36
|
+
if (c) {
|
|
37
|
+
stickRef.current = c.scrollHeight - c.scrollTop - c.clientHeight < NEAR_BOTTOM_PX;
|
|
38
|
+
}
|
|
39
|
+
// The API returns newest-first; a chat reads oldest -> newest, top -> bottom.
|
|
40
|
+
// Only show messages whose repo matches the current selection.
|
|
41
|
+
const messages = [...announcements]
|
|
42
|
+
.filter((a) => matchesRepo(a, selectedRepo))
|
|
43
|
+
.reverse();
|
|
44
|
+
// Keep the viewer pinned to the newest message unless they've scrolled up to
|
|
45
|
+
// read history. useLayoutEffect so the scroll write lands before paint.
|
|
46
|
+
useLayoutEffect(() => {
|
|
47
|
+
if (stickRef.current && chatRef.current) {
|
|
48
|
+
chatRef.current.scrollTop = chatRef.current.scrollHeight;
|
|
49
|
+
}
|
|
50
|
+
}, [messages.length]);
|
|
51
|
+
return (_jsx("div", { id: "chat", className: "chat", ref: chatRef, children: messages.length === 0 ? (_jsx("div", { className: "empty", children: "No announcements yet \u2014 agents will post here as they coordinate." })) : (messages.map((a, i) => {
|
|
52
|
+
const targeted = a.targetAgentName !== null || a.toAdmin;
|
|
53
|
+
const className = "msg" +
|
|
54
|
+
(targeted ? " msg--targeted" : "") +
|
|
55
|
+
(a.fromAdmin ? " msg--me" : "");
|
|
56
|
+
return (
|
|
57
|
+
// The feed has no stable id; index is acceptable because the list is
|
|
58
|
+
// append-only oldest->newest and rows are never reordered in place.
|
|
59
|
+
_jsxs("div", { className: className, children: [_jsx("div", { className: "msg__avatar", style: { background: colorForName(a.fromAgentName) }, children: initialsFor(a.fromAgentName) }), _jsxs("div", { className: "msg__body", children: [_jsxs("div", { className: "msg__head", children: [_jsx("span", { className: "msg__who", style: { color: colorForName(a.fromAgentName) }, children: a.fromAgentName }), a.fromHuman ? _jsx("span", { className: "msg__human", children: a.fromHuman }) : null, a.targetAgentName !== null ? (_jsx("span", { className: "msg__to", children: `→ @${a.targetAgentName}` })) : a.toAdmin ? (_jsx("span", { className: "msg__to", children: "\u2192 admin" })) : null, _jsx("span", { className: "msg__time", children: formatRelative(a.createdAt, nowMs) })] }), _jsx("div", { className: "msg__text", children: a.body })] })] }, i));
|
|
60
|
+
})) }));
|
|
61
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type { WorkspaceAgentT } from "@shepherd/shared";
|
|
3
|
+
/** Props for {@link Composer}. */
|
|
4
|
+
export interface ComposerProps {
|
|
5
|
+
/** Workspace agents; the live ones in the selected repo drive @-autocomplete. */
|
|
6
|
+
agents: WorkspaceAgentT[];
|
|
7
|
+
/** The board's selected repo; `null`/`"__all__"` broadcast to every repo. */
|
|
8
|
+
selectedRepo: string | null;
|
|
9
|
+
/**
|
|
10
|
+
* Workspace to post into. When given, submit goes through the plural
|
|
11
|
+
* `announceTo(id, …)` route; when omitted it uses the singular self-host
|
|
12
|
+
* `announce(…)` alias, so an unscoped composer is unchanged.
|
|
13
|
+
*/
|
|
14
|
+
workspaceId?: string;
|
|
15
|
+
/** Invoked after a successful send so the host can refresh the feed. */
|
|
16
|
+
onSent: () => void | Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* The operator's message composer (the "Chat" tab footer). A controlled input
|
|
20
|
+
* with an @-mention autocomplete and a send button, ported from app.js
|
|
21
|
+
* `setupComposer` + `renderPop`.
|
|
22
|
+
*
|
|
23
|
+
* As the operator types, the token immediately left of the caret is parsed; if
|
|
24
|
+
* it is an `@mention`, a `role="listbox"` of live agents in the selected repo
|
|
25
|
+
* opens. ArrowUp/ArrowDown move the highlight, Enter/Tab (or a mousedown on a
|
|
26
|
+
* row) accept it — inserting `"@Name "` — and Escape closes it. On submit a
|
|
27
|
+
* blank body is ignored; otherwise the first mention matching a known agent
|
|
28
|
+
* directs the message (`targetAgentName`) and the selected repo scopes a
|
|
29
|
+
* broadcast (`null` in All-repos mode). The send button is disabled in-flight;
|
|
30
|
+
* on success the input clears and `onSent` is awaited, on failure a
|
|
31
|
+
* "send failed — retry" status shows and the button re-enables. The client comes
|
|
32
|
+
* from context, keeping the composer auth-agnostic.
|
|
33
|
+
*
|
|
34
|
+
* @param props - The agents, repo filter, and post-send callback.
|
|
35
|
+
* @returns The composer element (autocomplete popup + form).
|
|
36
|
+
*/
|
|
37
|
+
export declare function Composer({ agents, selectedRepo, workspaceId, onSent }: ComposerProps): ReactNode;
|