@korso/shepherd-ui 0.3.0 → 0.4.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.
@@ -1,9 +1,8 @@
1
1
  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
- import { Workspaces } from "./config/Workspaces.js";
5
- import { Members } from "./config/Members.js";
6
- import { ConnectAgent } from "./config/ConnectAgent.js";
4
+ import { WorkspaceSwitcher } from "./config/WorkspaceSwitcher.js";
5
+ import { ConfigPanel } from "./config/ConfigPanel.js";
7
6
  import { useShepherdClient } from "./context.js";
8
7
  import { describeError } from "./client.js";
9
8
  export function ShepherdRoot({ hubUrl }) {
@@ -40,13 +39,15 @@ export function ShepherdRoot({ hubUrl }) {
40
39
  return (_jsx("div", { className: "shepherd-root", children: _jsx("p", { role: "alert", children: load.message }) }));
41
40
  }
42
41
  const hasWorkspace = workspaces.length > 0;
43
- // The Config tab's content. Rendered by <Dashboard> in its `config` panel.
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).
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: () => {
49
- void fetchWorkspaces();
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 })] }));
51
- return (_jsx("div", { className: "shepherd-root", children: _jsx(Dashboard, { workspaceId: selected?.id, config: configSection, hasWorkspace: hasWorkspace }) }));
42
+ // The app-bar switcher: the active-workspace indicator + the one home for
43
+ // switch/create/join, shown on every tab. The membersRefreshKey is bumped
44
+ // after a join (which drops the caller onto a new roster).
45
+ const switcher = (_jsx(WorkspaceSwitcher, { workspaces: workspaces, selected: selected, onSelect: setSelectedId, onChanged: () => {
46
+ void fetchWorkspaces();
47
+ }, onMembersChanged: () => setMembersRefreshKey((k) => k + 1) }));
48
+ // The Config tab's content. With a workspace selected it's the sidebar
49
+ // <ConfigPanel>; with none it's a prompt pointing at the switcher, since
50
+ // create/join now live there (the sidebar has nothing to configure).
51
+ const configSection = (_jsxs("section", { className: "shepherd-config", "aria-labelledby": "config-heading", children: [_jsx("h2", { id: "config-heading", children: "Configuration" }), selected ? (_jsx(ConfigPanel, { workspace: selected, hubUrl: hubUrl, membersRefreshKey: membersRefreshKey, onMembersChanged: () => setMembersRefreshKey((k) => k + 1), onLeft: () => void fetchWorkspaces() })) : (_jsxs("p", { className: "config-none", children: ["You're not in a workspace yet. Use the ", _jsx("b", { children: "Get started \u25BE" }), " menu at the top to create one or join with an invite code."] }))] }));
52
+ return (_jsx("div", { className: "shepherd-root", children: _jsx(Dashboard, { workspaceId: selected?.id, config: configSection, switcher: switcher, hasWorkspace: hasWorkspace }) }));
52
53
  }
@@ -14,6 +14,12 @@ export interface DashboardProps {
14
14
  * (the self-host case) keeps the board a plain two-tab Tasks/Chat wallboard.
15
15
  */
16
16
  config?: ReactNode;
17
+ /**
18
+ * The hosted-shell workspace switcher, rendered in the header beside the brand
19
+ * on EVERY tab (not just Config) so the active workspace is always visible.
20
+ * Omitted for self-host, which has a single implicit team workspace.
21
+ */
22
+ switcher?: ReactNode;
17
23
  /**
18
24
  * Whether the account currently belongs to a workspace. Only meaningful in the
19
25
  * hosted shell (paired with {@link config}). When `false`, the Tasks/Chat
@@ -45,4 +51,4 @@ export interface DashboardProps {
45
51
  *
46
52
  * @returns The dashboard element.
47
53
  */
48
- export declare function Dashboard({ workspaceId, config, hasWorkspace, }?: DashboardProps): ReactElement;
54
+ export declare function Dashboard({ workspaceId, config, switcher, hasWorkspace, }?: DashboardProps): ReactElement;
@@ -128,7 +128,7 @@ function computeCounts(snapshot) {
128
128
  *
129
129
  * @returns The dashboard element.
130
130
  */
131
- export function Dashboard({ workspaceId, config, hasWorkspace, } = {}) {
131
+ export function Dashboard({ workspaceId, config, switcher, hasWorkspace, } = {}) {
132
132
  const hasConfig = config != null;
133
133
  // Only the hosted shell with an explicit empty account suppresses the board;
134
134
  // self-host (hasWorkspace undefined) always has its implicit team workspace.
@@ -275,7 +275,7 @@ export function Dashboard({ workspaceId, config, hasWorkspace, } = {}) {
275
275
  // makes sense for a live board — hide it on the Config tab and when there is
276
276
  // no workspace to poll. The brand and the tab strip stay in every state.
277
277
  const showBoardChrome = !noWorkspace && activeTab !== "config";
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
+ return (_jsxs("div", { id: "board", children: [_jsxs("header", { children: [_jsx("h1", { className: "brand", style: { margin: 0, font: "inherit" }, children: "Shepherd" }), switcher, 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) => {
279
279
  tabRefs.current[index] = el;
280
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 })] }));
281
281
  }
@@ -0,0 +1,14 @@
1
+ import type { WorkspaceSummaryT } from "@shepherd/shared";
2
+ export interface ConfigPanelProps {
3
+ /** The active workspace all sections configure. */
4
+ workspace: WorkspaceSummaryT;
5
+ /** The DIRECT Hub URL embedded in the agent install command. */
6
+ hubUrl?: string;
7
+ /** Bumped by the shell to force the roster to refetch (invite created/redeemed). */
8
+ membersRefreshKey?: number;
9
+ /** Called after an invite is created, so the shell can refresh the roster. */
10
+ onMembersChanged?: () => void;
11
+ /** Called after a successful leave, so the shell re-lists its workspaces. */
12
+ onLeft?: () => void;
13
+ }
14
+ export declare function ConfigPanel({ workspace, hubUrl, membersRefreshKey, onMembersChanged, onLeft, }: ConfigPanelProps): import("react").JSX.Element;
@@ -0,0 +1,16 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from "react";
3
+ import { GeneralSettings } from "./GeneralSettings.js";
4
+ import { Members } from "./Members.js";
5
+ import { Invites } from "./Invites.js";
6
+ import { ConnectAgent } from "./ConnectAgent.js";
7
+ const SECTIONS = [
8
+ { id: "general", label: "General" },
9
+ { id: "members", label: "Members" },
10
+ { id: "agent", label: "Agent" },
11
+ ];
12
+ export function ConfigPanel({ workspace, hubUrl, membersRefreshKey, onMembersChanged, onLeft, }) {
13
+ const [section, setSection] = useState("general");
14
+ const isAdmin = workspace.role === "admin";
15
+ return (_jsxs("div", { className: "config-layout", children: [_jsx("nav", { className: "config-nav", "aria-label": "Configuration sections", children: SECTIONS.map(({ id, label }) => (_jsx("button", { type: "button", className: "config-nav__item" + (section === id ? " config-nav__item--on" : ""), "aria-current": section === id ? "page" : undefined, onClick: () => setSection(id), children: label }, id))) }), _jsxs("div", { className: "config-panel", children: [section === "general" && (_jsx(GeneralSettings, { workspace: workspace, onLeft: onLeft })), section === "members" && (_jsxs(_Fragment, { children: [_jsx(Members, { workspaceId: workspace.id, refreshKey: membersRefreshKey, canRemove: isAdmin }), isAdmin && (_jsx(Invites, { workspaceId: workspace.id, onMembersChanged: onMembersChanged }))] })), section === "agent" && _jsx(ConnectAgent, { workspaceId: workspace.id, hubUrl: hubUrl })] })] }));
16
+ }
@@ -127,7 +127,7 @@ export function ConnectAgent({ workspaceId, hubUrl }) {
127
127
  setCopied(true);
128
128
  setTimeout(() => setCopied(false), 2000);
129
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) => {
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", "aria-label": copied ? "Copied" : "Copy command", title: copied ? "Copied" : "Copy", onClick: () => void copyCommand(), children: copied ? (_jsx("svg", { width: "15", height: "15", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: _jsx("path", { d: "M20 6 9 17l-5-5" }) })) : (_jsxs("svg", { width: "15", height: "15", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: [_jsx("rect", { x: "9", y: "9", width: "13", height: "13", rx: "2" }), _jsx("path", { d: "M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" })] })) })] }), _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) => {
131
131
  const name = t.name ?? t.id;
132
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));
133
133
  }) }))] })] }));
@@ -0,0 +1,8 @@
1
+ import type { WorkspaceSummaryT } from "@shepherd/shared";
2
+ export interface GeneralSettingsProps {
3
+ /** The active workspace whose identity is shown. */
4
+ workspace: WorkspaceSummaryT;
5
+ /** Called after a successful leave, so the shell refreshes its workspace list. */
6
+ onLeft?: () => void;
7
+ }
8
+ export declare function GeneralSettings({ workspace, onLeft }: GeneralSettingsProps): import("react").JSX.Element;
@@ -0,0 +1,27 @@
1
+ import { jsx as _jsx, 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
+ export function GeneralSettings({ workspace, onLeft }) {
6
+ const client = useShepherdClient();
7
+ const headingId = useId();
8
+ const [error, setError] = useState(null);
9
+ const [leaving, setLeaving] = useState(false);
10
+ async function leave() {
11
+ if (leaving)
12
+ return;
13
+ setLeaving(true);
14
+ setError(null);
15
+ try {
16
+ await client.leave(workspace.id);
17
+ onLeft?.();
18
+ }
19
+ catch (err) {
20
+ setError(describeError(err));
21
+ }
22
+ finally {
23
+ setLeaving(false);
24
+ }
25
+ }
26
+ return (_jsxs("section", { className: "shepherd-general", "aria-labelledby": headingId, children: [_jsxs("div", { className: "card-head", children: [_jsx("h3", { id: headingId, children: "General" }), _jsx("p", { className: "card-sub", children: "Your active workspace and its settings." })] }), _jsxs("div", { className: "card-body", children: [error && _jsx("p", { role: "alert", children: error }), _jsxs("div", { className: "field", children: [_jsx("label", { children: "Workspace name" }), _jsx("p", { className: "readonly-value", children: workspace.name })] }), _jsxs("div", { className: "field", children: [_jsx("label", { children: "Your role" }), _jsx("p", { className: "readonly-value", children: workspace.role })] }), _jsxs("div", { className: "field leave", children: [_jsx("label", { children: "Leave workspace" }), _jsx("p", { className: "helper", children: "Remove yourself from this workspace. You'll need a new invite to rejoin." }), _jsx("button", { type: "button", className: "danger", onClick: () => void leave(), disabled: leaving, children: "Leave workspace" })] })] })] }));
27
+ }
@@ -0,0 +1,7 @@
1
+ export interface InvitesProps {
2
+ /** The workspace invites are minted against. */
3
+ workspaceId: string;
4
+ /** Called after an invite is created (may change the pending-invite roster). */
5
+ onMembersChanged?: () => void;
6
+ }
7
+ export declare function Invites({ workspaceId, onMembersChanged }: InvitesProps): import("react").JSX.Element;
@@ -0,0 +1,84 @@
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 Invites({ workspaceId, onMembersChanged }) {
6
+ const client = useShepherdClient();
7
+ const headingId = useId();
8
+ const [invite, setInvite] = useState(null);
9
+ const [error, setError] = useState(null);
10
+ const [busy, setBusy] = useState(false);
11
+ const [emailAddress, setEmailAddress] = useState("");
12
+ const [emailStatus, setEmailStatus] = useState(null);
13
+ const [emailBusy, setEmailBusy] = useState(false);
14
+ // Which of the two copyables flashed "Copied" most recently (cleared after 2s).
15
+ const [copied, setCopied] = useState(null);
16
+ // Clear any displayed invite/email status when the workspace changes, so a
17
+ // code minted for one workspace never lingers on another's screen.
18
+ useEffect(() => {
19
+ setInvite(null);
20
+ setEmailStatus(null);
21
+ }, [workspaceId]);
22
+ async function createInvite() {
23
+ if (busy)
24
+ return;
25
+ setBusy(true);
26
+ setError(null);
27
+ try {
28
+ const res = await client.createInvite(workspaceId, {});
29
+ setInvite(res);
30
+ onMembersChanged?.();
31
+ }
32
+ catch (err) {
33
+ setError(describeError(err));
34
+ }
35
+ finally {
36
+ setBusy(false);
37
+ }
38
+ }
39
+ async function revokeInvite() {
40
+ if (!invite || busy)
41
+ return;
42
+ setBusy(true);
43
+ setError(null);
44
+ try {
45
+ await client.revokeInvite(workspaceId, invite.code);
46
+ setInvite(null);
47
+ }
48
+ catch (err) {
49
+ setError(describeError(err));
50
+ }
51
+ finally {
52
+ setBusy(false);
53
+ }
54
+ }
55
+ async function sendEmailInvite() {
56
+ if (!emailAddress.trim() || emailBusy)
57
+ return;
58
+ setEmailBusy(true);
59
+ setEmailStatus(null);
60
+ try {
61
+ const res = await client.inviteByEmail(workspaceId, emailAddress.trim());
62
+ setEmailStatus(`Invite sent to ${res.email}.`);
63
+ setEmailAddress("");
64
+ onMembersChanged?.();
65
+ }
66
+ catch (err) {
67
+ setEmailStatus(describeError(err));
68
+ }
69
+ finally {
70
+ setEmailBusy(false);
71
+ }
72
+ }
73
+ const joinLink = invite
74
+ ? `${window.location.origin}/shepherd/join/${encodeURIComponent(invite.code)}`
75
+ : null;
76
+ async function copy(what, value) {
77
+ await navigator.clipboard.writeText(value);
78
+ setCopied(what);
79
+ setTimeout(() => setCopied((c) => (c === what ? null : c)), 2000);
80
+ }
81
+ return (_jsxs("section", { className: "shepherd-invites", "aria-labelledby": headingId, children: [_jsxs("div", { className: "card-head", children: [_jsx("h3", { id: headingId, children: "Invite people" }), _jsx("p", { className: "card-sub", children: "Share a code, or send a one-time join link by email." })] }), _jsxs("div", { className: "card-body", children: [error && _jsx("p", { role: "alert", children: error }), _jsxs("div", { className: "field invites", children: [_jsx("label", { children: "Invite by code" }), _jsx("p", { className: "helper", children: "Anyone with the code can join this workspace." }), invite && (_jsxs("div", { className: "invite-result", children: [_jsxs("div", { className: "invite-result__head", children: [_jsx("span", { className: "invite-result__eyebrow", children: "Invite code" }), _jsx("span", { className: "invite-uses", children: invite.maxUses === null
82
+ ? `${invite.useCount} uses`
83
+ : `${invite.useCount} / ${invite.maxUses} uses` }), _jsx("button", { type: "button", className: "link-btn", "aria-label": "Revoke invite", onClick: () => void revokeInvite(), disabled: busy, children: "Revoke" })] }), _jsxs("div", { className: "invite-result__copyrow", children: [_jsx("code", { children: invite.code }), _jsx("button", { type: "button", className: "copy-btn", onClick: () => void copy("code", invite.code), children: copied === "code" ? "Copied" : "Copy" })] }), joinLink && (_jsxs("div", { className: "invite-result__copyrow invite-result__copyrow--link", children: [_jsx("a", { href: joinLink, children: joinLink }), _jsx("button", { type: "button", className: "copy-btn", onClick: () => void copy("link", joinLink), children: copied === "link" ? "Copied" : "Copy link" })] }))] })), _jsx("button", { type: "button", onClick: () => void createInvite(), disabled: busy, children: "Create invite" })] }), _jsxs("div", { className: "field email-invite", children: [_jsx("label", { htmlFor: "invite-email", children: "Invite by email" }), _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 })] })] })] }));
84
+ }
@@ -4,7 +4,5 @@ export interface MembersProps {
4
4
  refreshKey?: number;
5
5
  /** When true, render the per-member remove control (the caller gates on admin). */
6
6
  canRemove?: boolean;
7
- /** Called after a successful self-service leave, so the shell can refresh. */
8
- onLeft?: () => void;
9
7
  }
10
- export declare function Members({ workspaceId, refreshKey, canRemove, onLeft }: MembersProps): import("react").JSX.Element;
8
+ export declare function Members({ workspaceId, refreshKey, canRemove }: MembersProps): import("react").JSX.Element;
@@ -2,7 +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
- export function Members({ workspaceId, refreshKey = 0, canRemove = false, onLeft }) {
5
+ export function Members({ workspaceId, refreshKey = 0, canRemove = false }) {
6
6
  const client = useShepherdClient();
7
7
  const headingId = useId();
8
8
  const [members, setMembers] = useState([]);
@@ -11,10 +11,9 @@ export function Members({ workspaceId, refreshKey = 0, canRemove = false, onLeft
11
11
  // True until the first load() resolves, so the initial fetch shows "Loading…"
12
12
  // rather than the genuine "No members." empty state.
13
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.
14
+ // Per-row in-flight remove guard (by accountId), used to disable the button
15
+ // and block double-submit.
16
16
  const [removingId, setRemovingId] = useState(null);
17
- const [leaving, setLeaving] = useState(false);
18
17
  // `keepError` lets the remove-failure path re-sync the roster without wiping
19
18
  // the message it just surfaced (a fresh fetch normally clears stale errors).
20
19
  async function load({ keepError = false } = {}) {
@@ -58,26 +57,8 @@ export function Members({ workspaceId, refreshKey = 0, canRemove = false, onLeft
58
57
  setRemovingId(null);
59
58
  }
60
59
  }
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
60
  return (_jsxs("section", { className: "shepherd-members", "aria-labelledby": headingId, children: [_jsx("div", { className: "card-head", children: _jsx("h3", { id: headingId, children: "Members" }) }), _jsxs("div", { className: "card-body", children: [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
61
  const display = m.displayName ?? m.githubLogin ?? m.accountId;
81
62
  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" })] })] }));
63
+ }) }))] })] }));
83
64
  }
@@ -0,0 +1,14 @@
1
+ import type { WorkspaceSummaryT } from "@shepherd/shared";
2
+ export interface WorkspaceSwitcherProps {
3
+ /** All workspaces the account belongs to. */
4
+ workspaces: WorkspaceSummaryT[];
5
+ /** The currently-active workspace, or null when the account has none. */
6
+ selected: WorkspaceSummaryT | null;
7
+ /** Switch the active workspace. */
8
+ onSelect: (workspaceId: string) => void;
9
+ /** Called after a create/join changes membership, so the shell re-lists. */
10
+ onChanged: () => void;
11
+ /** Called after a join (which adds the caller to a new roster). */
12
+ onMembersChanged?: () => void;
13
+ }
14
+ export declare function WorkspaceSwitcher({ workspaces, selected, onSelect, onChanged, onMembersChanged, }: WorkspaceSwitcherProps): import("react").JSX.Element;
@@ -0,0 +1,108 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useEffect, useId, useRef, useState } from "react";
3
+ import { useShepherdClient } from "../context.js";
4
+ import { describeError } from "../client.js";
5
+ /** First letter of a workspace name, for the square avatar chip. */
6
+ function initial(name) {
7
+ return (name.trim()[0] ?? "?").toUpperCase();
8
+ }
9
+ export function WorkspaceSwitcher({ workspaces, selected, onSelect, onChanged, onMembersChanged, }) {
10
+ const client = useShepherdClient();
11
+ const menuId = useId();
12
+ const rootRef = useRef(null);
13
+ const [open, setOpen] = useState(false);
14
+ const [mode, setMode] = useState("menu");
15
+ const [name, setName] = useState("");
16
+ const [code, setCode] = useState("");
17
+ const [error, setError] = useState(null);
18
+ const [busy, setBusy] = useState(false);
19
+ // Reset the popover to its default (menu) state whenever it closes, so it
20
+ // never re-opens mid-form with a stale input or error.
21
+ function close() {
22
+ setOpen(false);
23
+ setMode("menu");
24
+ setName("");
25
+ setCode("");
26
+ setError(null);
27
+ }
28
+ // Dismiss on outside-click and Escape — standard popover behavior.
29
+ useEffect(() => {
30
+ if (!open)
31
+ return;
32
+ function onDocClick(e) {
33
+ if (rootRef.current && !rootRef.current.contains(e.target))
34
+ close();
35
+ }
36
+ function onKey(e) {
37
+ if (e.key === "Escape")
38
+ close();
39
+ }
40
+ document.addEventListener("mousedown", onDocClick);
41
+ document.addEventListener("keydown", onKey);
42
+ return () => {
43
+ document.removeEventListener("mousedown", onDocClick);
44
+ document.removeEventListener("keydown", onKey);
45
+ };
46
+ }, [open]);
47
+ async function create() {
48
+ if (!name.trim() || busy)
49
+ return;
50
+ setBusy(true);
51
+ setError(null);
52
+ try {
53
+ const ws = await client.createWorkspace({ name: name.trim() });
54
+ // Switch to the freshly-created workspace, then re-list. ShepherdRoot
55
+ // preserves a still-present selection across the re-list, so ordering
56
+ // onSelect before onChanged lands the user on the new workspace.
57
+ onSelect(ws.id);
58
+ onChanged();
59
+ close();
60
+ }
61
+ catch (err) {
62
+ setError(describeError(err));
63
+ }
64
+ finally {
65
+ setBusy(false);
66
+ }
67
+ }
68
+ async function join() {
69
+ if (!code.trim() || busy)
70
+ return;
71
+ setBusy(true);
72
+ setError(null);
73
+ try {
74
+ const res = await client.redeemInvite(code.trim());
75
+ onSelect(res.workspace.id);
76
+ onChanged();
77
+ onMembersChanged?.();
78
+ close();
79
+ }
80
+ catch (err) {
81
+ setError(describeError(err));
82
+ }
83
+ finally {
84
+ setBusy(false);
85
+ }
86
+ }
87
+ const hasWorkspace = selected !== null;
88
+ const label = selected ? selected.name : "Get started";
89
+ return (_jsxs("div", { className: "ws-switcher", ref: rootRef, children: [_jsxs("button", { type: "button", className: "ws-trig" + (hasWorkspace ? "" : " ws-trig--empty"), "aria-haspopup": "menu", "aria-expanded": open, "aria-controls": open ? menuId : undefined, onClick: () => (open ? close() : setOpen(true)), children: [hasWorkspace ? (_jsx("span", { className: "ws-avatar", "aria-hidden": "true", children: initial(label) })) : (_jsx("span", { className: "ws-plus", "aria-hidden": "true", children: "\uFF0B" })), _jsx("span", { className: "ws-name", children: label }), _jsx("span", { className: "ws-caret", "aria-hidden": "true", children: "\u25BE" })] }), open && (_jsxs("div", { className: "ws-menu", id: menuId, role: "menu", children: [mode === "menu" && (_jsxs(_Fragment, { children: [_jsx("p", { className: "ws-menu__head", children: hasWorkspace ? "Switch workspace" : "Your workspaces" }), workspaces.length === 0 ? (_jsx("p", { className: "ws-menu__none", children: "None yet" })) : (_jsx("ul", { className: "ws-menu__list", children: workspaces.map((w) => {
90
+ const active = w.id === selected?.id;
91
+ return (_jsx("li", { children: _jsxs("button", { type: "button", role: "menuitemradio", "aria-checked": active, className: "ws-mi" + (active ? " ws-mi--on" : ""), onClick: () => {
92
+ onSelect(w.id);
93
+ close();
94
+ }, children: [_jsx("span", { className: "ws-avatar ws-avatar--sm", "aria-hidden": "true", children: initial(w.name) }), _jsx("span", { className: "ws-mi__name", children: w.name }), active && _jsx("span", { className: "ws-mi__check", "aria-hidden": "true", children: "\u2713" })] }) }, w.id));
95
+ }) })), _jsxs("div", { className: "ws-menu__actions", children: [_jsx("button", { type: "button", role: "menuitem", className: "ws-action", onClick: () => {
96
+ setError(null);
97
+ setMode("create");
98
+ }, children: "\uFF0B Create workspace" }), _jsx("button", { type: "button", role: "menuitem", className: "ws-action", onClick: () => {
99
+ setError(null);
100
+ setMode("join");
101
+ }, children: "\u2398 Join with a code" })] })] })), mode === "create" && (_jsxs("form", { className: "ws-form", onSubmit: (e) => {
102
+ e.preventDefault();
103
+ void create();
104
+ }, children: [_jsx("label", { htmlFor: `${menuId}-name`, children: "New workspace name" }), _jsx("input", { id: `${menuId}-name`, type: "text", placeholder: "e.g. Acme Engineering", value: name, autoFocus: true, onChange: (e) => setName(e.target.value) }), _jsxs("div", { className: "ws-form__row", children: [_jsx("button", { type: "button", className: "ws-form__back", onClick: () => setMode("menu"), children: "Back" }), _jsx("button", { type: "submit", disabled: busy || !name.trim(), children: "Create" })] })] })), mode === "join" && (_jsxs("form", { className: "ws-form", onSubmit: (e) => {
105
+ e.preventDefault();
106
+ void join();
107
+ }, children: [_jsx("label", { htmlFor: `${menuId}-code`, children: "Invite code" }), _jsx("input", { id: `${menuId}-code`, type: "text", placeholder: "Paste an invite code", value: code, autoFocus: true, onChange: (e) => setCode(e.target.value) }), _jsxs("div", { className: "ws-form__row", children: [_jsx("button", { type: "button", className: "ws-form__back", onClick: () => setMode("menu"), children: "Back" }), _jsx("button", { type: "submit", disabled: busy || !code.trim(), children: "Join" })] })] })), error && (_jsx("p", { className: "ws-menu__error", role: "alert", children: error }))] }))] }));
108
+ }
@@ -1,8 +1,14 @@
1
+ export { ConfigPanel } from "./ConfigPanel.js";
2
+ export type { ConfigPanelProps } from "./ConfigPanel.js";
3
+ export { WorkspaceSwitcher } from "./WorkspaceSwitcher.js";
4
+ export type { WorkspaceSwitcherProps } from "./WorkspaceSwitcher.js";
5
+ export { GeneralSettings } from "./GeneralSettings.js";
6
+ export type { GeneralSettingsProps } from "./GeneralSettings.js";
1
7
  export { ConnectAgent } from "./ConnectAgent.js";
2
8
  export type { ConnectAgentProps } from "./ConnectAgent.js";
3
- export { Workspaces } from "./Workspaces.js";
4
- export type { WorkspacesProps } from "./Workspaces.js";
5
9
  export { Members } from "./Members.js";
6
10
  export type { MembersProps } from "./Members.js";
11
+ export { Invites } from "./Invites.js";
12
+ export type { InvitesProps } from "./Invites.js";
7
13
  export { EmptyState } from "./EmptyState.js";
8
14
  export type { EmptyStateProps } from "./EmptyState.js";
@@ -1,6 +1,9 @@
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.
1
+ // Config-surface components. Re-exported so consumers can compose the pieces
2
+ // directly if they don't use the full ShepherdRoot shell.
3
+ export { ConfigPanel } from "./ConfigPanel.js";
4
+ export { WorkspaceSwitcher } from "./WorkspaceSwitcher.js";
5
+ export { GeneralSettings } from "./GeneralSettings.js";
3
6
  export { ConnectAgent } from "./ConnectAgent.js";
4
- export { Workspaces } from "./Workspaces.js";
5
7
  export { Members } from "./Members.js";
8
+ export { Invites } from "./Invites.js";
6
9
  export { EmptyState } from "./EmptyState.js";
package/dist/index.d.ts CHANGED
@@ -13,6 +13,6 @@ export { ShepherdRoot } from "./ShepherdRoot.js";
13
13
  export type { ShepherdRootProps } from "./ShepherdRoot.js";
14
14
  export { Dashboard } from "./components/Dashboard.js";
15
15
  export type { DashboardProps } from "./components/Dashboard.js";
16
- export { Workspaces, Members, ConnectAgent, EmptyState, } from "./config/index.js";
17
- export type { WorkspacesProps, MembersProps, ConnectAgentProps, EmptyStateProps, } from "./config/index.js";
16
+ export { ConfigPanel, WorkspaceSwitcher, GeneralSettings, Members, Invites, ConnectAgent, EmptyState, } from "./config/index.js";
17
+ export type { ConfigPanelProps, WorkspaceSwitcherProps, GeneralSettingsProps, MembersProps, InvitesProps, ConnectAgentProps, EmptyStateProps, } from "./config/index.js";
18
18
  export type { WorkspaceLandscapeResponseT, WorkspaceSummaryT, TokenSummaryT, MemberSummaryT, InviteResponseT, } from "@shepherd/shared";
package/dist/index.js CHANGED
@@ -12,4 +12,4 @@ export { ShepherdRoot } from "./ShepherdRoot.js";
12
12
  export { Dashboard } from "./components/Dashboard.js";
13
13
  // Config screens — re-exported so consumers can compose the management surface
14
14
  // directly without the full ShepherdRoot shell.
15
- export { Workspaces, Members, ConnectAgent, EmptyState, } from "./config/index.js";
15
+ export { ConfigPanel, WorkspaceSwitcher, GeneralSettings, Members, Invites, ConnectAgent, EmptyState, } from "./config/index.js";