@korso/shepherd-ui 0.10.0 → 0.11.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.
package/README.md CHANGED
@@ -1,47 +1,50 @@
1
- # @korso/shepherd-ui
2
-
3
- The Shepherd dashboard: an **auth-agnostic** React app (Vite) that renders the
4
- workspace landscape (crew, territory, active/done work, chat) and issues operator
5
- actions against the hub. "Auth-agnostic" means the core holds no notion of who
6
- the user is or how they authenticated — it renders coordination state and issues
7
- operator actions, leaving authentication to the layer in front of it.
8
-
9
- ## Two build outputs
10
-
11
- One source tree, two Vite builds (`npm run build` runs both):
12
-
13
- - **`dist/lib`** (`build:lib`) — a component library for **Korso** to embed in
14
- the hosted product. Exposes `<Dashboard/>`, `createShepherdClient`, the React
15
- context (`ShepherdClientProvider` / `useShepherdClient`), and types via the `.`
16
- export. It is **token-blind**: auth lives in the BFF in front of it. The
17
- stylesheet is opt-in (`@korso/shepherd-ui/styles.css`), not auto-imported.
18
- - **`dist/selfhost`** (`build:app`) — a self-contained SPA the **hub serves**
19
- verbatim via `@fastify/static` (the hub resolves `../../ui/dist/selfhost/`).
20
- It bundles its own CSS and mounts the token-gated root.
21
-
22
- Both `dist/` outputs are gitignored; `npm run build` (root) and the hub Docker
23
- image regenerate them.
24
-
25
- ## Two mounts
26
-
27
- - **Self-host (the Hub).** The Hub serves `<SelfHostApp/>` (the `./selfhost`
28
- export, mounted by `src/main.tsx`). This is the **one** place a team token is
29
- handled — the named exception to the auth-agnostic core: it prompts for the
30
- `TEAM_TOKEN`, persists it, builds a same-origin client that sends
31
- `Authorization: Bearer <token>`, and re-gates on a 401. The token gate lives
32
- **only** here, never in the `.` export or `<Dashboard/>`.
33
- - **Hosted (Korso).** Korso imports `<Dashboard/>` + `createShepherdClient` from
34
- the `.` export and supplies its own auth layer (the BFF). No token gate.
35
-
36
- ## Development
37
-
38
- ```
39
- npm -w @korso/shepherd-ui run dev # Vite dev server for the self-host SPA
40
- ```
41
-
42
- Other scripts: `build` (both outputs), `build:lib`, `build:app`, `type-check`
43
- (`tsc --noEmit`), `test` (Vitest, jsdom).
44
-
45
- ## Phase note
46
-
47
- Korso-side consumption of the `dist/lib` library is **finalized in Phase 5**.
1
+ # @korso/shepherd-ui
2
+
3
+ The Shepherd dashboard: an **auth-agnostic** React app (Vite) that renders the
4
+ workspace landscape (crew, territory, active/done work, chat) and issues operator
5
+ actions against the hub. "Auth-agnostic" means the core holds no notion of who
6
+ the user is or how they authenticated — it renders coordination state and issues
7
+ operator actions, leaving authentication to the layer in front of it.
8
+
9
+ ## Two build outputs
10
+
11
+ One source tree, two Vite builds (`npm run build` runs both):
12
+
13
+ - **`dist/lib`** (`build:lib`) — a component library for **Korso** to embed in
14
+ the hosted product. Exposes `<Dashboard/>`, `createShepherdClient`, the React
15
+ context (`ShepherdClientProvider` / `useShepherdClient`), and types via the `.`
16
+ export. It is **token-blind**: auth lives in the BFF in front of it. The
17
+ stylesheet is opt-in (`@korso/shepherd-ui/styles.css`), not auto-imported.
18
+ - **`dist/selfhost`** (`build:app`) — a self-contained SPA the **hub serves**
19
+ verbatim via `@fastify/static` (the hub resolves `../../ui/dist/selfhost/`).
20
+ It bundles its own CSS and mounts the token-gated root.
21
+
22
+ Both `dist/` outputs are gitignored; `npm run build` (root) and the hub Docker
23
+ image regenerate them.
24
+
25
+ ## Two mounts
26
+
27
+ - **Self-host (the Hub).** The Hub serves `<SelfHostApp/>` (the `./selfhost`
28
+ export, mounted by `src/main.tsx`). This is the **one** place a team token is
29
+ handled — the named exception to the auth-agnostic core: it prompts for the
30
+ `TEAM_TOKEN`, persists it, builds a same-origin client that sends
31
+ `Authorization: Bearer <token>`, and re-gates on a 401. The token gate lives
32
+ **only** here, never in the `.` export or `<Dashboard/>`.
33
+ - **Hosted (Korso).** Korso imports `<Dashboard/>` + `createShepherdClient` from
34
+ the `.` export and supplies its own auth layer (the BFF). No token gate.
35
+ Hosted shells that embed `<ShepherdRoot/>` may pass `onLogout`; Shepherd renders
36
+ the Sign out action at the bottom of Config, while the host callback performs
37
+ the actual session cleanup and navigation (cookies, OIDC, BFF state, etc.).
38
+
39
+ ## Development
40
+
41
+ ```
42
+ npm -w @korso/shepherd-ui run dev # Vite dev server for the self-host SPA
43
+ ```
44
+
45
+ Other scripts: `build` (both outputs), `build:lib`, `build:app`, `type-check`
46
+ (`tsc --noEmit`), `test` (Vitest, jsdom).
47
+
48
+ ## Phase note
49
+
50
+ Korso-side consumption of the `dist/lib` library is **finalized in Phase 5**.
@@ -5,5 +5,10 @@ export interface ShepherdRootProps {
5
5
  * to the client's baseUrl, which is correct for self-host.
6
6
  */
7
7
  hubUrl?: string;
8
+ /**
9
+ * Hosted session logout hook. The embedding frontend/BFF owns cookies,
10
+ * session state, and OIDC cleanup; Shepherd only renders the Config action.
11
+ */
12
+ onLogout?: () => void;
8
13
  }
9
- export declare function ShepherdRoot({ hubUrl }: ShepherdRootProps): import("react").JSX.Element;
14
+ export declare function ShepherdRoot({ hubUrl, onLogout }: ShepherdRootProps): import("react").JSX.Element;
@@ -1,11 +1,12 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { useCallback, useEffect, useState } from "react";
3
3
  import { Dashboard } from "./components/Dashboard.js";
4
4
  import { WorkspaceSwitcher } from "./config/WorkspaceSwitcher.js";
5
5
  import { ConfigPanel } from "./config/ConfigPanel.js";
6
+ import { SignOutAction } from "./config/SignOutAction.js";
6
7
  import { useShepherdClient } from "./context.js";
7
8
  import { describeError } from "./client.js";
8
- export function ShepherdRoot({ hubUrl }) {
9
+ export function ShepherdRoot({ hubUrl, onLogout }) {
9
10
  const client = useShepherdClient();
10
11
  const [load, setLoad] = useState({ status: "loading" });
11
12
  const [selectedId, setSelectedId] = useState(null);
@@ -48,6 +49,6 @@ export function ShepherdRoot({ hubUrl }) {
48
49
  // The Config tab's content. With a workspace selected it's the sidebar
49
50
  // <ConfigPanel>; with none it's a prompt pointing at the switcher, since
50
51
  // 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(), onDeleted: () => 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
+ 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(), onDeleted: () => void fetchWorkspaces(), onLogout: onLogout })) : (_jsxs(_Fragment, { children: [_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."] }), onLogout && _jsx(SignOutAction, { onLogout: onLogout })] }))] }));
52
53
  return (_jsx("div", { className: "shepherd-root", children: _jsx(Dashboard, { workspaceId: selected?.id, config: configSection, switcher: switcher, hasWorkspace: hasWorkspace }) }));
53
54
  }
@@ -28,6 +28,11 @@ export interface DashboardProps {
28
28
  * has its implicit team workspace).
29
29
  */
30
30
  hasWorkspace?: boolean;
31
+ /**
32
+ * Optional header logout seam. The dashboard only renders the control and
33
+ * invokes this callback; authentication side effects belong to the caller.
34
+ */
35
+ onLogout?: () => void;
31
36
  }
32
37
  /**
33
38
  * The Shepherd wallboard shell. Composes {@link useLandscapePolling} (which reads
@@ -51,4 +56,4 @@ export interface DashboardProps {
51
56
  *
52
57
  * @returns The dashboard element.
53
58
  */
54
- export declare function Dashboard({ workspaceId, config, switcher, hasWorkspace, }?: DashboardProps): ReactElement;
59
+ export declare function Dashboard({ workspaceId, config, switcher, hasWorkspace, onLogout, }?: 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, switcher, hasWorkspace, } = {}) {
131
+ export function Dashboard({ workspaceId, config, switcher, hasWorkspace, onLogout, } = {}) {
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, switcher, 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" }), 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) => {
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 })] })), onLogout && (_jsx("button", { type: "button", className: "header-signout", onClick: onLogout, children: "Sign out" })), _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
  }
@@ -12,5 +12,10 @@ export interface ConfigPanelProps {
12
12
  onLeft?: () => void;
13
13
  /** Called after a successful delete, so the shell re-lists its workspaces. */
14
14
  onDeleted?: () => void;
15
+ /**
16
+ * Hosted session logout hook. Shepherd renders the control while the host owns
17
+ * the authentication side effect.
18
+ */
19
+ onLogout?: () => void;
15
20
  }
16
- export declare function ConfigPanel({ workspace, hubUrl, membersRefreshKey, onMembersChanged, onLeft, onDeleted, }: ConfigPanelProps): import("react").JSX.Element;
21
+ export declare function ConfigPanel({ workspace, hubUrl, membersRefreshKey, onMembersChanged, onLeft, onDeleted, onLogout, }: ConfigPanelProps): import("react").JSX.Element;
@@ -4,13 +4,14 @@ import { GeneralSettings } from "./GeneralSettings.js";
4
4
  import { Members } from "./Members.js";
5
5
  import { Invites } from "./Invites.js";
6
6
  import { ConnectAgent } from "./ConnectAgent.js";
7
+ import { SignOutAction } from "./SignOutAction.js";
7
8
  const SECTIONS = [
8
9
  { id: "general", label: "General" },
9
10
  { id: "members", label: "Members" },
10
11
  { id: "agent", label: "Agent" },
11
12
  ];
12
- export function ConfigPanel({ workspace, hubUrl, membersRefreshKey, onMembersChanged, onLeft, onDeleted, }) {
13
+ export function ConfigPanel({ workspace, hubUrl, membersRefreshKey, onMembersChanged, onLeft, onDeleted, onLogout, }) {
13
14
  const [section, setSection] = useState("general");
14
15
  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, onDeleted: onDeleted })), 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, { hubUrl: hubUrl })] })] }));
16
+ return (_jsxs(_Fragment, { children: [_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, onDeleted: onDeleted })), 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, { hubUrl: hubUrl })] })] }), onLogout && _jsx(SignOutAction, { onLogout: onLogout })] }));
16
17
  }
@@ -0,0 +1,7 @@
1
+ /** Props for the hosted session sign out presentation control. */
2
+ export interface SignOutActionProps {
3
+ /** Called immediately when the operator chooses to end this dashboard session. */
4
+ onLogout: () => void;
5
+ }
6
+ /** Renders the session-level sign out control supplied by the host shell. */
7
+ export declare function SignOutAction({ onLogout }: SignOutActionProps): import("react").JSX.Element;
@@ -0,0 +1,7 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useId } from "react";
3
+ /** Renders the session-level sign out control supplied by the host shell. */
4
+ export function SignOutAction({ onLogout }) {
5
+ const headingId = useId();
6
+ return (_jsxs("section", { className: "config-signout", "aria-labelledby": headingId, children: [_jsx("div", { className: "card-head", children: _jsx("h3", { id: headingId, children: "Session" }) }), _jsxs("div", { className: "card-body", children: [_jsx("p", { className: "helper", children: "Sign out of this Shepherd dashboard session." }), _jsx("button", { type: "button", onClick: onLogout, children: "Sign out" })] })] }));
7
+ }