@korso/shepherd-ui 0.2.2 → 0.3.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.
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useCallback, useEffect, useState } from "react";
3
3
  import { Dashboard } from "./components/Dashboard.js";
4
4
  import { Workspaces } from "./config/Workspaces.js";
5
+ import { Members } from "./config/Members.js";
5
6
  import { ConnectAgent } from "./config/ConnectAgent.js";
6
7
  import { useShepherdClient } from "./context.js";
7
8
  import { describeError } from "./client.js";
@@ -9,6 +10,9 @@ export function ShepherdRoot({ hubUrl }) {
9
10
  const client = useShepherdClient();
10
11
  const [load, setLoad] = useState({ status: "loading" });
11
12
  const [selectedId, setSelectedId] = useState(null);
13
+ // Bumped whenever a mutation may have changed the selected workspace's
14
+ // roster (an invite created, a code redeemed), so <Members> refetches.
15
+ const [membersRefreshKey, setMembersRefreshKey] = useState(0);
12
16
  // Loads the workspace list and keeps the selection valid: a still-present
13
17
  // selection is preserved (so a create/join refresh never yanks the user off
14
18
  // their current workspace), otherwise it falls back to the first workspace.
@@ -37,10 +41,12 @@ export function ShepherdRoot({ hubUrl }) {
37
41
  }
38
42
  const hasWorkspace = workspaces.length > 0;
39
43
  // 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).
44
+ // Members is a peer section (visible to every member, not just admins) so
45
+ // its refresh key lives here, bumped by Workspaces after a roster-affecting
46
+ // mutation (invite created, code redeemed). ConnectAgent only appears once a
47
+ // workspace is selected (nothing to connect an agent to otherwise).
42
48
  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
49
  void fetchWorkspaces();
44
- }, onSelect: setSelectedId }), selected && _jsx(ConnectAgent, { workspaceId: selected.id, hubUrl: hubUrl })] }));
50
+ }, onSelect: setSelectedId, onMembersChanged: () => setMembersRefreshKey((k) => k + 1) }), selected && (_jsx(Members, { workspaceId: selected.id, refreshKey: membersRefreshKey, canRemove: selected.role === "admin", onLeft: () => void fetchWorkspaces() })), selected && _jsx(ConnectAgent, { workspaceId: selected.id, hubUrl: hubUrl })] }));
45
51
  return (_jsx("div", { className: "shepherd-root", children: _jsx(Dashboard, { workspaceId: selected?.id, config: configSection, hasWorkspace: hasWorkspace }) }));
46
52
  }
package/dist/client.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ListWorkspacesResponseT, CreateWorkspaceRequestT, CreateWorkspaceResponseT, MintTokenRequestT, MintTokenResponseT, ListTokensResponseT, CreateInviteRequestT, InviteResponseT, RedeemInviteResponseT, ListMembersResponseT, WorkspaceLandscapeResponseT, WorkspaceAnnounceRequestT, WorkspaceAnnounceResponseT } from "@shepherd/shared";
1
+ import type { ListWorkspacesResponseT, CreateWorkspaceRequestT, CreateWorkspaceResponseT, MintTokenRequestT, MintTokenResponseT, ListTokensResponseT, CreateInviteRequestT, InviteResponseT, InviteByEmailResponseT, RedeemInviteResponseT, ListMembersResponseT, WorkspaceLandscapeResponseT, WorkspaceAnnounceRequestT, WorkspaceAnnounceResponseT, FeedbackRequestT, FeedbackResponseT } from "@shepherd/shared";
2
2
  /**
3
3
  * The single error type surfaced by the client. A missing `status` means a
4
4
  * transport failure (network error or request timeout); a present `status` is
@@ -51,6 +51,8 @@ export interface ShepherdClient {
51
51
  revokeToken(workspaceId: string, tokenId: string): Promise<void>;
52
52
  /** POST a new invite code for the workspace (admin only). */
53
53
  createInvite(workspaceId: string, body: CreateInviteRequestT): Promise<InviteResponseT>;
54
+ /** POST a one-time-use invite emailed directly to an address (admin only). */
55
+ inviteByEmail(workspaceId: string, email: string): Promise<InviteByEmailResponseT>;
54
56
  /** POST to revoke an invite code (admin only). */
55
57
  revokeInvite(workspaceId: string, code: string): Promise<void>;
56
58
  /** POST to redeem an invite code, joining its workspace as a member. */
@@ -65,6 +67,13 @@ export interface ShepherdClient {
65
67
  landscape(workspaceId: string): Promise<WorkspaceLandscapeResponseT>;
66
68
  /** POST an operator announcement to a specific workspace. */
67
69
  announceTo(workspaceId: string, body: WorkspaceAnnounceRequestT): Promise<WorkspaceAnnounceResponseT>;
70
+ /**
71
+ * POST a bug/suggestion/other note from the feedback widget. Hits
72
+ * `/workspaces/:id/feedback` when `workspaceId` is given (the normal hosted
73
+ * case — a validated workspace to attach), or the flat `/feedback` otherwise
74
+ * (self-host, or a hosted caller with no workspace selected yet).
75
+ */
76
+ submitFeedback(body: FeedbackRequestT, workspaceId?: string): Promise<FeedbackResponseT>;
68
77
  /** GET the unfiltered whole-workspace view (agents, tasks, announcements). */
69
78
  getLandscape(): Promise<WorkspaceLandscapeResponseT>;
70
79
  /** POST an operator announcement (broadcast or @targeted) to the workspace. */
package/dist/client.js CHANGED
@@ -1,4 +1,4 @@
1
- import { ListWorkspacesResponse, CreateWorkspaceResponse, MintTokenResponse, ListTokensResponse, InviteResponse, RedeemInviteResponse, ListMembersResponse, WorkspaceLandscapeResponse, WorkspaceAnnounceResponse, } from "@shepherd/shared";
1
+ import { ListWorkspacesResponse, CreateWorkspaceResponse, MintTokenResponse, ListTokensResponse, InviteResponse, InviteByEmailResponse, RedeemInviteResponse, ListMembersResponse, WorkspaceLandscapeResponse, WorkspaceAnnounceResponse, FeedbackResponse, } from "@shepherd/shared";
2
2
  /** Per-request abort fires after this many ms when no timeoutMs is configured. */
3
3
  const DEFAULT_TIMEOUT_MS = 5000;
4
4
  /**
@@ -183,6 +183,12 @@ export function createShepherdClient(config) {
183
183
  schema: InviteResponse,
184
184
  });
185
185
  },
186
+ inviteByEmail(workspaceId, email) {
187
+ return request("POST", `/workspaces/${enc(workspaceId)}/invites/email`, {
188
+ body: { email },
189
+ schema: InviteByEmailResponse,
190
+ });
191
+ },
186
192
  revokeInvite(workspaceId, code) {
187
193
  return request("POST", `/workspaces/${enc(workspaceId)}/invites/${enc(code)}/revoke`);
188
194
  },
@@ -218,6 +224,12 @@ export function createShepherdClient(config) {
218
224
  schema: WorkspaceAnnounceResponse,
219
225
  });
220
226
  },
227
+ submitFeedback(body, workspaceId) {
228
+ const path = workspaceId !== undefined
229
+ ? `/workspaces/${enc(workspaceId)}/feedback`
230
+ : "/feedback";
231
+ return request("POST", path, { body, schema: FeedbackResponse });
232
+ },
221
233
  // --- self-host singular aliases (implicit single workspace) -------------
222
234
  getLandscape() {
223
235
  // See landscape() above: this schema's inferred type doesn't structurally
@@ -9,6 +9,7 @@ import { DoneList } from "./DoneList.js";
9
9
  import { Chat } from "./Chat.js";
10
10
  import { Composer } from "./Composer.js";
11
11
  import { EmptyState } from "../config/EmptyState.js";
12
+ import { FeedbackWidget } from "./FeedbackWidget.js";
12
13
  /**
13
14
  * localStorage keys + page size, ported verbatim from the state block of
14
15
  * packages/hub/public/app.js (POLL_MS lives in the hook).
@@ -276,5 +277,5 @@ export function Dashboard({ workspaceId, config, hasWorkspace, } = {}) {
276
277
  const showBoardChrome = !noWorkspace && activeTab !== "config";
277
278
  return (_jsxs("div", { id: "board", children: [_jsxs("header", { children: [_jsx("h1", { className: "brand", style: { margin: 0, font: "inherit" }, children: "Shepherd" }), showBoardChrome && (_jsxs(_Fragment, { children: [_jsx(RepoSelect, { repos: repos, counts: counts, selected: effectiveRepo, onSelect: onSelectRepo }), _jsxs("span", { className: "vitals", children: [_jsx("b", { id: "vitals-online", children: online }), " online · ", _jsx("b", { id: "vitals-active", children: active }), " active"] })] })), _jsx("span", { className: "grow" }), showBoardChrome && (_jsxs(_Fragment, { children: [_jsx("span", { id: "status", className: "status" + (statusView.kind ? ` status--${statusView.kind}` : ""), children: statusView.text }), _jsx("span", { id: "freshness", className: "freshness", children: freshness })] })), _jsx("nav", { className: "tabs", role: "tablist", "aria-label": "Shepherd views", children: tabs.map(({ id, label }, index) => (_jsx("button", { ref: (el) => {
278
279
  tabRefs.current[index] = el;
279
- }, className: "tab" + (activeTab === id ? " tab--active" : ""), "data-tab": id, type: "button", role: "tab", id: `tab-${id}`, "aria-controls": `panel-${id}`, "aria-selected": activeTab === id, tabIndex: activeTab === id ? 0 : -1, onClick: () => onTab(id), onKeyDown: (e) => onTabKeyDown(e, index), children: label }, id))) })] }), _jsx("section", { id: "panel-tasks", role: "tabpanel", "aria-labelledby": "tab-tasks", hidden: activeTab !== "tasks", children: noWorkspace ? (_jsx(EmptyState, { onGetStarted: () => onTab("config") })) : (_jsxs(_Fragment, { children: [_jsx(Crew, { agents: agents, tasks: tasks, selectedRepo: effectiveRepo }), _jsxs("div", { className: "board", children: [_jsxs("div", { className: "col", children: [_jsxs("div", { className: "colhead", children: [_jsx("h2", { children: "Active" }), _jsx("span", { className: "n", id: "active-count", children: activeCount })] }), _jsx(ActiveList, { tasks: tasks, nowMs: nowMs, selectedRepo: effectiveRepo })] }), _jsx("div", { className: "board__rule" }), _jsxs("div", { className: "col", children: [_jsxs("div", { className: "colhead", children: [_jsx("h2", { children: "Done" }), _jsx("span", { className: "n", id: "done-count", children: doneCount })] }), _jsx(DoneList, { tasks: tasks, nowMs: nowMs, selectedRepo: effectiveRepo, doneShown: doneShown, onLoadMore: onLoadMore })] })] })] })) }), _jsx("section", { id: "panel-chat", role: "tabpanel", "aria-labelledby": "tab-chat", hidden: activeTab !== "chat", children: noWorkspace ? (_jsx(EmptyState, { onGetStarted: () => onTab("config") })) : (_jsxs("div", { className: "chat-wrap", children: [_jsx(Chat, { announcements: announcements, selectedRepo: effectiveRepo, nowMs: nowMs }), _jsx(Composer, { agents: agents, selectedRepo: effectiveRepo, workspaceId: workspaceId, onSent: refresh })] })) }), hasConfig && (_jsx("section", { id: "panel-config", role: "tabpanel", "aria-labelledby": "tab-config", hidden: activeTab !== "config", children: config }))] }));
280
+ }, className: "tab" + (activeTab === id ? " tab--active" : ""), "data-tab": id, type: "button", role: "tab", id: `tab-${id}`, "aria-controls": `panel-${id}`, "aria-selected": activeTab === id, tabIndex: activeTab === id ? 0 : -1, onClick: () => onTab(id), onKeyDown: (e) => onTabKeyDown(e, index), children: label }, id))) })] }), _jsx("section", { id: "panel-tasks", role: "tabpanel", "aria-labelledby": "tab-tasks", hidden: activeTab !== "tasks", children: noWorkspace ? (_jsx(EmptyState, { onGetStarted: () => onTab("config") })) : (_jsxs(_Fragment, { children: [_jsx(Crew, { agents: agents, tasks: tasks, selectedRepo: effectiveRepo }), _jsxs("div", { className: "board", children: [_jsxs("div", { className: "col", children: [_jsxs("div", { className: "colhead", children: [_jsx("h2", { children: "Active" }), _jsx("span", { className: "n", id: "active-count", children: activeCount })] }), _jsx(ActiveList, { tasks: tasks, nowMs: nowMs, selectedRepo: effectiveRepo })] }), _jsx("div", { className: "board__rule" }), _jsxs("div", { className: "col", children: [_jsxs("div", { className: "colhead", children: [_jsx("h2", { children: "Done" }), _jsx("span", { className: "n", id: "done-count", children: doneCount })] }), _jsx(DoneList, { tasks: tasks, nowMs: nowMs, selectedRepo: effectiveRepo, doneShown: doneShown, onLoadMore: onLoadMore })] })] })] })) }), _jsx("section", { id: "panel-chat", role: "tabpanel", "aria-labelledby": "tab-chat", hidden: activeTab !== "chat", children: noWorkspace ? (_jsx(EmptyState, { onGetStarted: () => onTab("config") })) : (_jsxs("div", { className: "chat-wrap", children: [_jsx(Chat, { announcements: announcements, selectedRepo: effectiveRepo, nowMs: nowMs }), _jsx(Composer, { agents: agents, selectedRepo: effectiveRepo, workspaceId: workspaceId, onSent: refresh })] })) }), hasConfig && (_jsx("section", { id: "panel-config", role: "tabpanel", "aria-labelledby": "tab-config", hidden: activeTab !== "config", children: config })), _jsx(FeedbackWidget, { workspaceId: workspaceId })] }));
280
281
  }
@@ -0,0 +1,5 @@
1
+ export interface FeedbackWidgetProps {
2
+ /** The currently selected workspace, if any — forwarded so the hub can attach it. */
3
+ workspaceId?: string;
4
+ }
5
+ export declare function FeedbackWidget({ workspaceId }: FeedbackWidgetProps): import("react").JSX.Element;
@@ -0,0 +1,50 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useId, useState } from "react";
3
+ import { useShepherdClient } from "../context.js";
4
+ import { describeError } from "../client.js";
5
+ const TYPES = [
6
+ { id: "bug", label: "Bug" },
7
+ { id: "suggestion", label: "Suggestion" },
8
+ { id: "other", label: "Other" },
9
+ ];
10
+ /** How long the "Thanks!" confirmation shows before the popover auto-closes. */
11
+ const CONFIRMATION_MS = 1500;
12
+ export function FeedbackWidget({ workspaceId }) {
13
+ const client = useShepherdClient();
14
+ const headingId = useId();
15
+ const [open, setOpen] = useState(false);
16
+ const [type, setType] = useState("bug");
17
+ const [body, setBody] = useState("");
18
+ const [busy, setBusy] = useState(false);
19
+ const [error, setError] = useState(null);
20
+ const [sent, setSent] = useState(false);
21
+ function toggle() {
22
+ setOpen((o) => !o);
23
+ }
24
+ function reset() {
25
+ setType("bug");
26
+ setBody("");
27
+ setError(null);
28
+ setSent(false);
29
+ }
30
+ async function submit() {
31
+ setBusy(true);
32
+ setError(null);
33
+ try {
34
+ await client.submitFeedback({ type, body: body.trim() }, workspaceId);
35
+ setSent(true);
36
+ setBody("");
37
+ setTimeout(() => {
38
+ setOpen(false);
39
+ reset();
40
+ }, CONFIRMATION_MS);
41
+ }
42
+ catch (err) {
43
+ setError(describeError(err));
44
+ }
45
+ finally {
46
+ setBusy(false);
47
+ }
48
+ }
49
+ return (_jsxs("div", { className: "shepherd-feedback", children: [_jsx("button", { type: "button", className: "shepherd-feedback__trigger", onClick: toggle, "aria-expanded": open, children: "Feedback" }), open && (_jsxs("section", { className: "shepherd-feedback__panel", "aria-labelledby": headingId, children: [_jsx("h3", { id: headingId, children: "Give feedback" }), sent ? (_jsx("p", { role: "status", children: "Thanks! Your feedback was sent." })) : (_jsxs(_Fragment, { children: [_jsx("div", { className: "shepherd-feedback__types", children: TYPES.map((t) => (_jsx("button", { type: "button", "aria-pressed": type === t.id, onClick: () => setType(t.id), children: t.label }, t.id))) }), _jsx("textarea", { "aria-label": "Feedback", placeholder: "What's on your mind?", value: body, onChange: (e) => setBody(e.target.value) }), error && _jsx("p", { role: "alert", children: error }), _jsx("button", { type: "button", onClick: () => void submit(), disabled: body.trim() === "" || busy, children: "Submit" })] }))] }))] }));
50
+ }
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useId, useState } from "react";
3
3
  import { useShepherdClient } from "../context.js";
4
4
  import { describeError } from "../client.js";
5
+ import { formatRelative } from "../logic.js";
5
6
  const TOOLS = [
6
7
  { id: "claude", label: "Claude Code" },
7
8
  { id: "codex", label: "Codex" },
@@ -18,6 +19,13 @@ const CLI_PREFIX = {
18
19
  codex: "codex mcp add shepherd \\",
19
20
  pi: "pi mcp add shepherd \\",
20
21
  };
22
+ // "created 3d ago · never used" / "created 3d ago · last used 2h ago" — helps
23
+ // an operator tell which tokens are still active before revoking one.
24
+ function tokenMeta(token, nowMs) {
25
+ const created = `created ${formatRelative(token.createdAt, nowMs)}`;
26
+ const used = token.lastUsedAt ? `last used ${formatRelative(token.lastUsedAt, nowMs)}` : "never used";
27
+ return `${created} · ${used}`;
28
+ }
21
29
  function installCommand(tool, hubUrl, token) {
22
30
  if (tool === "generic") {
23
31
  return JSON.stringify({
@@ -49,6 +57,8 @@ export function ConnectAgent({ workspaceId, hubUrl }) {
49
57
  const [error, setError] = useState(null);
50
58
  const [status, setStatus] = useState(null);
51
59
  const [busy, setBusy] = useState(false);
60
+ // True for a couple seconds after a successful copy, to flash the button label.
61
+ const [copied, setCopied] = useState(false);
52
62
  // True until the first loadTokens() resolves, so the management list shows a
53
63
  // loading placeholder rather than the "No tokens yet." empty state.
54
64
  const [loading, setLoading] = useState(true);
@@ -112,8 +122,13 @@ export function ConnectAgent({ workspaceId, hubUrl }) {
112
122
  }
113
123
  }
114
124
  const command = installCommand(tool, directHubUrl, rawToken ?? TOKEN_PLACEHOLDER);
115
- return (_jsxs("section", { className: "shepherd-connect-agent", "aria-labelledby": headingId, children: [_jsxs("div", { className: "card-head", children: [_jsx("h3", { id: headingId, children: "Connect your agent" }), _jsx("p", { className: "card-sub", children: "Generate a token, then paste the command into your coding tool." })] }), _jsxs("div", { className: "card-body", children: [_jsxs("div", { className: "field", children: [_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: "field generate", children: [_jsx("label", { htmlFor: "token-name", children: "Token name (optional)" }), _jsxs("div", { className: "field__row", children: [_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) => {
125
+ async function copyCommand() {
126
+ await navigator.clipboard.writeText(command);
127
+ setCopied(true);
128
+ setTimeout(() => setCopied(false), 2000);
129
+ }
130
+ return (_jsxs("section", { className: "shepherd-connect-agent", "aria-labelledby": headingId, children: [_jsxs("div", { className: "card-head", children: [_jsx("h3", { id: headingId, children: "Connect your agent" }), _jsx("p", { className: "card-sub", children: "Generate a token, then paste the command into your coding tool." })] }), _jsxs("div", { className: "card-body", children: [_jsxs("div", { className: "field", children: [_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: "field generate", children: [_jsx("label", { htmlFor: "token-name", children: "Token name (optional)" }), _jsxs("div", { className: "field__row", children: [_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." })), _jsxs("div", { className: "install-command", children: [_jsx("pre", { "data-testid": "install-command", children: command }), _jsx("button", { type: "button", className: "install-command__copy", onClick: () => void copyCommand(), children: copied ? "Copied" : "Copy" })] }), _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
131
  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));
132
+ return (_jsxs("li", { children: [_jsx("span", { children: name }), _jsx("span", { className: "token-meta", children: tokenMeta(t, Date.now()) }), 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
133
  }) }))] })] }));
119
134
  }
@@ -8,5 +8,8 @@ export interface WorkspacesProps {
8
8
  onChanged: () => void;
9
9
  /** Switch the active workspace (optional simple switcher). */
10
10
  onSelect?: (workspaceId: string) => void;
11
+ /** Called after a mutation that may change the selected workspace's roster
12
+ * (an invite created, a code redeemed), so the sibling <Members> refetches. */
13
+ onMembersChanged?: () => void;
11
14
  }
12
- export declare function Workspaces({ workspaces, selected, onChanged, onSelect }: WorkspacesProps): import("react").JSX.Element;
15
+ export declare function Workspaces({ workspaces, selected, onChanged, onSelect, onMembersChanged, }: WorkspacesProps): import("react").JSX.Element;
@@ -2,8 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useId, useState } from "react";
3
3
  import { useShepherdClient } from "../context.js";
4
4
  import { describeError } from "../client.js";
5
- import { Members } from "./Members.js";
6
- export function Workspaces({ workspaces, selected, onChanged, onSelect }) {
5
+ export function Workspaces({ workspaces, selected, onChanged, onSelect, onMembersChanged, }) {
7
6
  const client = useShepherdClient();
8
7
  const headingId = useId();
9
8
  const [name, setName] = useState("");
@@ -11,9 +10,9 @@ export function Workspaces({ workspaces, selected, onChanged, onSelect }) {
11
10
  const [invite, setInvite] = useState(null);
12
11
  const [error, setError] = useState(null);
13
12
  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);
13
+ const [emailAddress, setEmailAddress] = useState("");
14
+ const [emailStatus, setEmailStatus] = useState(null);
15
+ const [emailBusy, setEmailBusy] = useState(false);
17
16
  const isAdmin = selected?.role === "admin";
18
17
  async function create() {
19
18
  if (!name.trim() || busy)
@@ -41,7 +40,7 @@ export function Workspaces({ workspaces, selected, onChanged, onSelect }) {
41
40
  await client.redeemInvite(code.trim());
42
41
  setCode("");
43
42
  onChanged();
44
- setMembersRefreshKey((k) => k + 1);
43
+ onMembersChanged?.();
45
44
  }
46
45
  catch (err) {
47
46
  setError(describeError(err));
@@ -58,7 +57,7 @@ export function Workspaces({ workspaces, selected, onChanged, onSelect }) {
58
57
  try {
59
58
  const res = await client.createInvite(selected.id, {});
60
59
  setInvite(res);
61
- setMembersRefreshKey((k) => k + 1);
60
+ onMembersChanged?.();
62
61
  }
63
62
  catch (err) {
64
63
  setError(describeError(err));
@@ -67,6 +66,24 @@ export function Workspaces({ workspaces, selected, onChanged, onSelect }) {
67
66
  setBusy(false);
68
67
  }
69
68
  }
69
+ async function sendEmailInvite() {
70
+ if (!selected || !emailAddress.trim() || emailBusy)
71
+ return;
72
+ setEmailBusy(true);
73
+ setEmailStatus(null);
74
+ try {
75
+ const res = await client.inviteByEmail(selected.id, emailAddress.trim());
76
+ setEmailStatus(`Invite sent to ${res.email}.`);
77
+ setEmailAddress("");
78
+ onMembersChanged?.();
79
+ }
80
+ catch (err) {
81
+ setEmailStatus(describeError(err));
82
+ }
83
+ finally {
84
+ setEmailBusy(false);
85
+ }
86
+ }
70
87
  async function revokeInvite() {
71
88
  if (!selected || !invite || busy)
72
89
  return;
@@ -83,14 +100,17 @@ export function Workspaces({ workspaces, selected, onChanged, onSelect }) {
83
100
  setBusy(false);
84
101
  }
85
102
  }
86
- // Clear any displayed invite when the selected workspace changes.
103
+ // Clear any displayed invite/email status when the selected workspace changes.
87
104
  useEffect(() => {
88
105
  setInvite(null);
106
+ setEmailStatus(null);
89
107
  }, [selected?.id]);
90
108
  // InviteResponse has no `link`; build the shareable join URL from the code.
91
109
  // Encode the code for path-safety (parity with client.ts path encoding).
92
110
  const joinLink = invite
93
- ? `${window.location.origin}/join/${encodeURIComponent(invite.code)}`
111
+ ? `${window.location.origin}/shepherd/join/${encodeURIComponent(invite.code)}`
94
112
  : null;
95
- return (_jsxs("section", { className: "shepherd-workspaces", "aria-labelledby": headingId, children: [_jsxs("div", { className: "card-head", children: [_jsx("h3", { id: headingId, children: "Workspace management" }), _jsx("p", { className: "card-sub", children: "Create a workspace to get started, or join an existing one with an invite code." })] }), _jsxs("div", { className: "card-body", children: [error && _jsx("p", { role: "alert", children: error }), workspaces.length > 1 && onSelect && (_jsxs("div", { className: "field 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: "field create", children: [_jsx("label", { htmlFor: "ws-name", children: "Workspace name" }), _jsx("p", { className: "helper", children: "Start a fresh workspace \u2014 you'll be its admin." }), _jsxs("div", { className: "field__row", children: [_jsx("input", { id: "ws-name", type: "text", placeholder: "e.g. Acme Engineering", value: name, onChange: (e) => setName(e.target.value) }), _jsx("button", { type: "button", onClick: () => void create(), disabled: busy, children: "Create workspace" })] })] }), _jsxs("div", { className: "field join", children: [_jsx("label", { htmlFor: "invite-code", children: "Invite code" }), _jsx("p", { className: "helper", children: "Paste an invite code someone shared to join their workspace." }), _jsxs("div", { className: "field__row", children: [_jsx("input", { id: "invite-code", type: "text", placeholder: "Invite code", 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 })] }))] })] }));
113
+ return (_jsxs("section", { className: "shepherd-workspaces", "aria-labelledby": headingId, children: [_jsxs("div", { className: "card-head", children: [_jsx("h3", { id: headingId, children: "Workspace management" }), _jsx("p", { className: "card-sub", children: "Create a workspace to get started, or join an existing one with an invite code." })] }), _jsxs("div", { className: "card-body", children: [error && _jsx("p", { role: "alert", children: error }), workspaces.length > 1 && onSelect && (_jsxs("div", { className: "field 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: "field create", children: [_jsx("label", { htmlFor: "ws-name", children: "Workspace name" }), _jsx("p", { className: "helper", children: "Start a fresh workspace \u2014 you'll be its admin." }), _jsxs("div", { className: "field__row", children: [_jsx("input", { id: "ws-name", type: "text", placeholder: "e.g. Acme Engineering", value: name, onChange: (e) => setName(e.target.value) }), _jsx("button", { type: "button", onClick: () => void create(), disabled: busy, children: "Create workspace" })] })] }), _jsxs("div", { className: "field join", children: [_jsx("label", { htmlFor: "invite-code", children: "Invite code" }), _jsx("p", { className: "helper", children: "Paste an invite code someone shared to join their workspace." }), _jsxs("div", { className: "field__row", children: [_jsx("input", { id: "invite-code", type: "text", placeholder: "Invite code", 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: [_jsxs("div", { className: "invite-result__row", children: [_jsx("code", { children: invite.code }), _jsx("span", { className: "invite-uses", children: invite.maxUses === null
114
+ ? `${invite.useCount} uses`
115
+ : `${invite.useCount} / ${invite.maxUses} uses` }), _jsx("button", { type: "button", onClick: () => void revokeInvite(), disabled: busy, children: "Revoke invite" })] }), _jsx("a", { href: joinLink ?? undefined, children: joinLink })] }))] }), _jsxs("div", { className: "field email-invite", children: [_jsx("label", { htmlFor: "invite-email", children: "Email invite" }), _jsx("p", { className: "helper", children: "Send a one-time-use join link directly to someone's inbox." }), _jsxs("div", { className: "field__row", children: [_jsx("input", { id: "invite-email", type: "email", placeholder: "name@example.com", value: emailAddress, onChange: (e) => setEmailAddress(e.target.value) }), _jsx("button", { type: "button", onClick: () => void sendEmailInvite(), disabled: emailBusy, children: "Send invite" })] }), emailStatus && _jsx("p", { className: "email-invite__status", children: emailStatus })] })] }))] })] }));
96
116
  }