@korso/shepherd-ui 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/dist/ShepherdProvider.d.ts +12 -0
  2. package/dist/ShepherdProvider.js +33 -0
  3. package/dist/ShepherdProvider.test.d.ts +1 -0
  4. package/dist/ShepherdProvider.test.js +47 -0
  5. package/dist/ShepherdRoot.d.ts +9 -0
  6. package/dist/ShepherdRoot.js +88 -0
  7. package/dist/ShepherdRoot.routing.test.d.ts +1 -0
  8. package/dist/ShepherdRoot.routing.test.js +61 -0
  9. package/dist/ShepherdRoot.test.d.ts +1 -0
  10. package/dist/ShepherdRoot.test.js +37 -0
  11. package/dist/app/assets/index-CDOCIg6s.js +11 -0
  12. package/dist/app/index.html +12 -0
  13. package/dist/client.d.ts +107 -0
  14. package/dist/client.js +238 -0
  15. package/dist/client.test.d.ts +1 -0
  16. package/dist/client.test.js +298 -0
  17. package/dist/components/ActiveList.d.ts +22 -0
  18. package/dist/components/ActiveList.js +59 -0
  19. package/dist/components/Chat.d.ts +30 -0
  20. package/dist/components/Chat.js +61 -0
  21. package/dist/components/Composer.d.ts +37 -0
  22. package/dist/components/Composer.js +146 -0
  23. package/dist/components/Crew.d.ts +23 -0
  24. package/dist/components/Crew.js +31 -0
  25. package/dist/components/Dashboard.d.ts +34 -0
  26. package/dist/components/Dashboard.js +209 -0
  27. package/dist/components/DoneList.d.ts +29 -0
  28. package/dist/components/DoneList.js +50 -0
  29. package/dist/components/RepoSelect.d.ts +38 -0
  30. package/dist/components/RepoSelect.js +56 -0
  31. package/dist/components/Territory.d.ts +21 -0
  32. package/dist/components/Territory.js +21 -0
  33. package/dist/config/ConnectAgent.d.ts +10 -0
  34. package/dist/config/ConnectAgent.js +119 -0
  35. package/dist/config/ConnectAgent.test.d.ts +1 -0
  36. package/dist/config/ConnectAgent.test.js +92 -0
  37. package/dist/config/EmptyState.d.ts +12 -0
  38. package/dist/config/EmptyState.js +4 -0
  39. package/dist/config/EmptyState.test.d.ts +1 -0
  40. package/dist/config/EmptyState.test.js +32 -0
  41. package/dist/config/Members.d.ts +10 -0
  42. package/dist/config/Members.js +83 -0
  43. package/dist/config/Members.test.d.ts +1 -0
  44. package/dist/config/Members.test.js +66 -0
  45. package/dist/config/Workspaces.d.ts +12 -0
  46. package/dist/config/Workspaces.js +96 -0
  47. package/dist/config/Workspaces.test.d.ts +1 -0
  48. package/dist/config/Workspaces.test.js +90 -0
  49. package/dist/config/index.d.ts +8 -0
  50. package/dist/config/index.js +6 -0
  51. package/dist/context.d.ts +28 -0
  52. package/dist/context.js +36 -0
  53. package/dist/index.cjs +5289 -0
  54. package/dist/index.cjs.map +1 -0
  55. package/dist/index.d.cts +149 -0
  56. package/dist/index.d.ts +18 -0
  57. package/dist/index.js +15 -0
  58. package/dist/index.js.map +1 -0
  59. package/dist/lib/Dashboard-CG6KeFQ3.js +1214 -0
  60. package/dist/lib/index.d.ts +438 -16
  61. package/dist/lib/index.js +455 -6
  62. package/dist/lib/selfhost.js +9 -9
  63. package/dist/lib/styles.css +27 -0
  64. package/dist/logic.d.ts +208 -0
  65. package/dist/logic.js +372 -0
  66. package/dist/main.d.ts +1 -0
  67. package/dist/main.js +11 -0
  68. package/dist/selfhost/assets/{index-tSyzirZa.css → index-BZmImfYZ.css} +1 -1
  69. package/dist/selfhost/assets/index-CS268KSw.js +40 -0
  70. package/dist/selfhost/index.html +2 -2
  71. package/dist/selfhost.d.ts +22 -0
  72. package/dist/selfhost.js +97 -0
  73. package/dist/test/mockClient.d.ts +6 -0
  74. package/dist/test/mockClient.js +45 -0
  75. package/dist/test/setup.d.ts +1 -0
  76. package/dist/test/setup.js +2 -0
  77. package/dist/useLandscapePolling.d.ts +63 -0
  78. package/dist/useLandscapePolling.js +129 -0
  79. package/dist/views/Chat.d.ts +5 -0
  80. package/dist/views/Chat.js +60 -0
  81. package/dist/views/Chat.test.d.ts +1 -0
  82. package/dist/views/Chat.test.js +92 -0
  83. package/dist/views/Config.d.ts +12 -0
  84. package/dist/views/Config.js +6 -0
  85. package/dist/views/Tasks.d.ts +5 -0
  86. package/dist/views/Tasks.js +75 -0
  87. package/dist/views/Tasks.test.d.ts +1 -0
  88. package/dist/views/Tasks.test.js +115 -0
  89. package/dist/views/useLandscape.d.ts +14 -0
  90. package/dist/views/useLandscape.js +74 -0
  91. package/dist/views/useLandscape.test.d.ts +1 -0
  92. package/dist/views/useLandscape.test.js +101 -0
  93. package/dist/views/wallboard.d.ts +46 -0
  94. package/dist/views/wallboard.js +209 -0
  95. package/dist/views/wallboard.test.d.ts +1 -0
  96. package/dist/views/wallboard.test.js +143 -0
  97. package/package.json +1 -1
  98. package/dist/lib/Dashboard-CB6SL-J2.js +0 -1064
  99. package/dist/selfhost/assets/index-BBx3vo2Y.js +0 -40
@@ -0,0 +1,92 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { describe, it, expect, vi, beforeEach } from "vitest";
3
+ import { render, screen, waitFor } from "@testing-library/react";
4
+ import { userEvent } from "@testing-library/user-event";
5
+ import { ShepherdProvider } from "../ShepherdProvider.js";
6
+ import { ConnectAgent } from "./ConnectAgent.js";
7
+ import { makeMockClient } from "../test/mockClient.js";
8
+ // ---------------------------------------------------------------------------
9
+ // ConnectAgent — install-command render + token mint/revoke. DB-free: the mock
10
+ // ShepherdClient is injected via ShepherdProvider's `client` prop.
11
+ // ---------------------------------------------------------------------------
12
+ describe("ConnectAgent", () => {
13
+ const WORKSPACE_ID = "ws_1";
14
+ const HUB_URL = "https://hub.example.run.app";
15
+ let client;
16
+ beforeEach(() => {
17
+ client = makeMockClient({
18
+ listTokens: vi.fn().mockResolvedValue({ tokens: [] }),
19
+ });
20
+ });
21
+ function renderConnect() {
22
+ return render(_jsx(ShepherdProvider, { client: client, children: _jsx(ConnectAgent, { workspaceId: WORKSPACE_ID, hubUrl: HUB_URL }) }));
23
+ }
24
+ it("does not show a raw token until one is minted", async () => {
25
+ renderConnect();
26
+ await waitFor(() => expect(client.listTokens).toHaveBeenCalled());
27
+ // The command shows a placeholder, not a real shp_ token, before minting.
28
+ const cmd = screen.getByTestId("install-command").textContent ?? "";
29
+ expect(cmd).not.toMatch(/shp_realtoken/);
30
+ });
31
+ it("renders the install command with the direct Hub URL after minting a token", async () => {
32
+ client.mintToken = vi
33
+ .fn()
34
+ .mockResolvedValue({ token: "shp_realtoken123", id: "tok_1" });
35
+ renderConnect();
36
+ await waitFor(() => expect(client.listTokens).toHaveBeenCalled());
37
+ await userEvent.click(screen.getByRole("button", { name: /generate token/i }));
38
+ await waitFor(() => {
39
+ const cmd = screen.getByTestId("install-command").textContent ?? "";
40
+ expect(cmd).toContain("shp_realtoken123");
41
+ expect(cmd).toContain(HUB_URL);
42
+ });
43
+ expect(client.mintToken).toHaveBeenCalledWith(WORKSPACE_ID, expect.any(Object));
44
+ });
45
+ it("surfaces the raw token exactly once (with a one-time notice)", async () => {
46
+ client.mintToken = vi
47
+ .fn()
48
+ .mockResolvedValue({ token: "shp_onceonly", id: "tok_1" });
49
+ renderConnect();
50
+ await userEvent.click(screen.getByRole("button", { name: /generate token/i }));
51
+ await waitFor(() => {
52
+ expect(screen.getByTestId("install-command").textContent).toContain("shp_onceonly");
53
+ });
54
+ // The one-time warning is shown so the operator knows to copy it now.
55
+ expect(screen.getByText(/won't be shown again|shown once|only.*once/i)).toBeInTheDocument();
56
+ });
57
+ it("switches the install command when a different tool is picked", async () => {
58
+ client.mintToken = vi
59
+ .fn()
60
+ .mockResolvedValue({ token: "shp_tok", id: "tok_1" });
61
+ renderConnect();
62
+ await userEvent.click(screen.getByRole("button", { name: /generate token/i }));
63
+ await waitFor(() => expect(screen.getByTestId("install-command").textContent).toContain("shp_tok"));
64
+ const claudeCmd = screen.getByTestId("install-command").textContent ?? "";
65
+ expect(claudeCmd).toMatch(/claude mcp add/);
66
+ // Pick Codex.
67
+ await userEvent.selectOptions(screen.getByLabelText(/tool/i), "codex");
68
+ await waitFor(() => {
69
+ const codexCmd = screen.getByTestId("install-command").textContent ?? "";
70
+ expect(codexCmd).toMatch(/codex mcp add/);
71
+ expect(codexCmd).toContain("shp_tok");
72
+ });
73
+ });
74
+ it("lists existing tokens and revokes one", async () => {
75
+ client.listTokens = vi.fn().mockResolvedValue({
76
+ tokens: [
77
+ {
78
+ id: "tok_old",
79
+ name: "laptop",
80
+ lastUsedAt: null,
81
+ createdAt: "2026-06-01T00:00:00.000Z",
82
+ revokedAt: null,
83
+ },
84
+ ],
85
+ });
86
+ client.revokeToken = vi.fn().mockResolvedValue(undefined);
87
+ renderConnect();
88
+ await waitFor(() => expect(screen.getByText("laptop")).toBeInTheDocument());
89
+ await userEvent.click(screen.getByRole("button", { name: /revoke/i }));
90
+ await waitFor(() => expect(client.revokeToken).toHaveBeenCalledWith(WORKSPACE_ID, "tok_old"));
91
+ });
92
+ });
@@ -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,4 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ export function EmptyState({ title = "No workspace yet", children, onGetStarted, ctaLabel = "Go to Config", }) {
3
+ return (_jsxs("section", { className: "shepherd-empty-state", "aria-labelledby": "empty-state-heading", children: [_jsx("h2", { id: "empty-state-heading", 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 }))] }));
4
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,32 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { describe, it, expect, vi } from "vitest";
3
+ import { render, screen } from "@testing-library/react";
4
+ import { userEvent } from "@testing-library/user-event";
5
+ import { EmptyState } from "./EmptyState.js";
6
+ // ---------------------------------------------------------------------------
7
+ // EmptyState — the reusable "no workspace yet" prompt with an optional CTA.
8
+ // ---------------------------------------------------------------------------
9
+ describe("EmptyState", () => {
10
+ it("renders the default title, copy, and CTA label", () => {
11
+ render(_jsx(EmptyState, { onGetStarted: () => { } }));
12
+ expect(screen.getByRole("heading", { name: /no workspace yet/i })).toBeInTheDocument();
13
+ expect(screen.getByText(/create a workspace or join one/i)).toBeInTheDocument();
14
+ expect(screen.getByRole("button", { name: /go to config/i })).toBeInTheDocument();
15
+ });
16
+ it("invokes onGetStarted when the CTA is clicked", async () => {
17
+ const onGetStarted = vi.fn();
18
+ render(_jsx(EmptyState, { onGetStarted: onGetStarted }));
19
+ await userEvent.click(screen.getByRole("button", { name: /go to config/i }));
20
+ expect(onGetStarted).toHaveBeenCalledTimes(1);
21
+ });
22
+ it("honours custom title, copy, and CTA label", () => {
23
+ render(_jsx(EmptyState, { title: "Nothing here", ctaLabel: "Get going", onGetStarted: () => { }, children: "Custom supporting copy." }));
24
+ expect(screen.getByRole("heading", { name: "Nothing here" })).toBeInTheDocument();
25
+ expect(screen.getByText("Custom supporting copy.")).toBeInTheDocument();
26
+ expect(screen.getByRole("button", { name: "Get going" })).toBeInTheDocument();
27
+ });
28
+ it("omits the CTA when no handler is supplied", () => {
29
+ render(_jsx(EmptyState, {}));
30
+ expect(screen.queryByRole("button")).not.toBeInTheDocument();
31
+ });
32
+ });
@@ -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 @@
1
+ export {};
@@ -0,0 +1,66 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
3
+ import { render, screen, waitFor } from "@testing-library/react";
4
+ import { userEvent } from "@testing-library/user-event";
5
+ import { ShepherdProvider } from "../ShepherdProvider.js";
6
+ import { Members } from "./Members.js";
7
+ import { makeMockClient } from "../test/mockClient.js";
8
+ // ---------------------------------------------------------------------------
9
+ // Members — the workspace roster with an admin-only remove control. Fetches the
10
+ // list on mount and removes a member through the client.
11
+ // ---------------------------------------------------------------------------
12
+ const WORKSPACE_ID = "ws_1";
13
+ describe("Members", () => {
14
+ let client;
15
+ beforeEach(() => {
16
+ client = makeMockClient();
17
+ });
18
+ afterEach(() => {
19
+ vi.restoreAllMocks();
20
+ });
21
+ function renderMembers() {
22
+ return render(_jsx(ShepherdProvider, { client: client, children: _jsx(Members, { workspaceId: WORKSPACE_ID }) }));
23
+ }
24
+ it("lists members fetched from the client", async () => {
25
+ client.listMembers = vi.fn().mockResolvedValue({
26
+ members: [
27
+ {
28
+ accountId: "acc_1",
29
+ displayName: "Alice",
30
+ githubLogin: "alice",
31
+ avatarUrl: null,
32
+ role: "admin",
33
+ },
34
+ ],
35
+ });
36
+ renderMembers();
37
+ await waitFor(() => expect(client.listMembers).toHaveBeenCalledWith(WORKSPACE_ID));
38
+ expect(await screen.findByText("Alice")).toBeInTheDocument();
39
+ expect(screen.getByText("admin")).toBeInTheDocument();
40
+ });
41
+ it("shows an empty state when there are no members", async () => {
42
+ client.listMembers = vi.fn().mockResolvedValue({ members: [] });
43
+ renderMembers();
44
+ expect(await screen.findByText(/no members/i)).toBeInTheDocument();
45
+ });
46
+ it("removes a member through the client", async () => {
47
+ client.listMembers = vi.fn().mockResolvedValue({
48
+ members: [
49
+ {
50
+ accountId: "acc_2",
51
+ displayName: "Bob",
52
+ githubLogin: "bob",
53
+ avatarUrl: null,
54
+ role: "member",
55
+ },
56
+ ],
57
+ });
58
+ client.removeMember = vi.fn().mockResolvedValue(undefined);
59
+ renderMembers();
60
+ expect(await screen.findByText("Bob")).toBeInTheDocument();
61
+ await userEvent.click(screen.getByRole("button", { name: /remove/i }));
62
+ await waitFor(() => expect(client.removeMember).toHaveBeenCalledWith(WORKSPACE_ID, "acc_2"));
63
+ // Optimistically dropped from the list.
64
+ await waitFor(() => expect(screen.queryByText("Bob")).not.toBeInTheDocument());
65
+ });
66
+ });
@@ -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 @@
1
+ export {};
@@ -0,0 +1,90 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { describe, it, expect, vi, beforeEach } from "vitest";
3
+ import { render, screen, waitFor } from "@testing-library/react";
4
+ import { userEvent } from "@testing-library/user-event";
5
+ import { ShepherdProvider } from "../ShepherdProvider.js";
6
+ import { Workspaces } from "./Workspaces.js";
7
+ import { makeMockClient } from "../test/mockClient.js";
8
+ // ---------------------------------------------------------------------------
9
+ // Workspaces — create / join, plus admin-only invite + member-admin controls.
10
+ // Admin-ness is read from the selected workspace's `role` (WorkspaceSummary.role).
11
+ // ---------------------------------------------------------------------------
12
+ const ADMIN_WS = { id: "ws_1", slug: "acme", name: "Acme", role: "admin" };
13
+ const MEMBER_WS = { id: "ws_2", slug: "beta", name: "Beta", role: "member" };
14
+ describe("Workspaces", () => {
15
+ let client;
16
+ beforeEach(() => {
17
+ client = makeMockClient();
18
+ });
19
+ function renderWorkspaces(props = {}) {
20
+ return render(_jsx(ShepherdProvider, { client: client, children: _jsx(Workspaces, { workspaces: props.workspace ? [props.workspace] : [], selected: props.workspace ?? null, onChanged: props.onChanged ?? (() => { }) }) }));
21
+ }
22
+ it("creates a workspace via the client", async () => {
23
+ const onChanged = vi.fn();
24
+ renderWorkspaces({ onChanged });
25
+ await userEvent.type(screen.getByLabelText(/workspace name/i), "My Team");
26
+ await userEvent.click(screen.getByRole("button", { name: /create workspace/i }));
27
+ await waitFor(() => expect(client.createWorkspace).toHaveBeenCalledWith({ name: "My Team" }));
28
+ expect(onChanged).toHaveBeenCalled();
29
+ });
30
+ it("joins a workspace by redeeming an invite code", async () => {
31
+ const onChanged = vi.fn();
32
+ renderWorkspaces({ onChanged });
33
+ await userEvent.type(screen.getByLabelText(/invite code/i), "INV-123");
34
+ await userEvent.click(screen.getByRole("button", { name: /join/i }));
35
+ await waitFor(() => expect(client.redeemInvite).toHaveBeenCalledWith("INV-123"));
36
+ expect(onChanged).toHaveBeenCalled();
37
+ });
38
+ it("shows invite + member-admin controls for an admin", async () => {
39
+ client.listMembers = vi.fn().mockResolvedValue({
40
+ members: [
41
+ {
42
+ accountId: "acc_1",
43
+ displayName: "Alice",
44
+ githubLogin: "alice",
45
+ avatarUrl: null,
46
+ role: "admin",
47
+ },
48
+ ],
49
+ });
50
+ renderWorkspaces({ workspace: ADMIN_WS });
51
+ expect(screen.getByRole("button", { name: /create invite/i })).toBeInTheDocument();
52
+ await waitFor(() => expect(screen.getByText("Alice")).toBeInTheDocument());
53
+ });
54
+ it("hides invite + member-admin controls for a non-admin member", async () => {
55
+ renderWorkspaces({ workspace: MEMBER_WS });
56
+ expect(screen.queryByRole("button", { name: /create invite/i })).not.toBeInTheDocument();
57
+ // The member list is not even fetched for a non-admin.
58
+ expect(client.listMembers).not.toHaveBeenCalled();
59
+ });
60
+ it("creates an invite (admin) and shows the code", async () => {
61
+ client.createInvite = vi.fn().mockResolvedValue({
62
+ code: "INV-NEW",
63
+ expiresAt: null,
64
+ maxUses: 5,
65
+ useCount: 0,
66
+ });
67
+ renderWorkspaces({ workspace: ADMIN_WS });
68
+ await userEvent.click(screen.getByRole("button", { name: /create invite/i }));
69
+ await waitFor(() => expect(client.createInvite).toHaveBeenCalledWith("ws_1", expect.any(Object)));
70
+ await waitFor(() => expect(screen.getByText(/INV-NEW/)).toBeInTheDocument());
71
+ });
72
+ it("removes a member (admin)", async () => {
73
+ client.listMembers = vi.fn().mockResolvedValue({
74
+ members: [
75
+ {
76
+ accountId: "acc_2",
77
+ displayName: "Bob",
78
+ githubLogin: "bob",
79
+ avatarUrl: null,
80
+ role: "member",
81
+ },
82
+ ],
83
+ });
84
+ client.removeMember = vi.fn().mockResolvedValue(undefined);
85
+ renderWorkspaces({ workspace: ADMIN_WS });
86
+ await waitFor(() => expect(screen.getByText("Bob")).toBeInTheDocument());
87
+ await userEvent.click(screen.getByRole("button", { name: /remove/i }));
88
+ await waitFor(() => expect(client.removeMember).toHaveBeenCalledWith("ws_1", "acc_2"));
89
+ });
90
+ });
@@ -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;
@@ -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
+ }