@korso/shepherd-ui 0.2.0 → 0.2.2

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 (65) hide show
  1. package/README.md +47 -47
  2. package/dist/ShepherdRoot.js +15 -57
  3. package/dist/components/Dashboard.d.ts +16 -2
  4. package/dist/components/Dashboard.js +76 -5
  5. package/dist/config/ConnectAgent.js +4 -4
  6. package/dist/config/EmptyState.js +5 -1
  7. package/dist/config/Members.js +4 -4
  8. package/dist/config/Workspaces.js +1 -1
  9. package/dist/lib/{Dashboard-CG6KeFQ3.js → Dashboard-B7nioe5N.js} +529 -457
  10. package/dist/lib/index.d.ts +15 -1
  11. package/dist/lib/index.js +300 -359
  12. package/dist/lib/selfhost.js +1 -1
  13. package/dist/lib/styles.css +252 -209
  14. package/dist/selfhost/assets/index-COMbJ-a4.js +40 -0
  15. package/dist/selfhost/assets/index-CcGvL65Q.css +1 -0
  16. package/dist/selfhost/index.html +13 -13
  17. package/dist/useLandscapePolling.d.ts +7 -0
  18. package/dist/useLandscapePolling.js +6 -1
  19. package/package.json +67 -67
  20. package/dist/ShepherdProvider.d.ts +0 -12
  21. package/dist/ShepherdProvider.js +0 -33
  22. package/dist/ShepherdProvider.test.d.ts +0 -1
  23. package/dist/ShepherdProvider.test.js +0 -47
  24. package/dist/ShepherdRoot.routing.test.d.ts +0 -1
  25. package/dist/ShepherdRoot.routing.test.js +0 -61
  26. package/dist/ShepherdRoot.test.d.ts +0 -1
  27. package/dist/ShepherdRoot.test.js +0 -37
  28. package/dist/app/assets/index-CDOCIg6s.js +0 -11
  29. package/dist/app/index.html +0 -12
  30. package/dist/client.test.d.ts +0 -1
  31. package/dist/client.test.js +0 -298
  32. package/dist/config/ConnectAgent.test.d.ts +0 -1
  33. package/dist/config/ConnectAgent.test.js +0 -92
  34. package/dist/config/EmptyState.test.d.ts +0 -1
  35. package/dist/config/EmptyState.test.js +0 -32
  36. package/dist/config/Members.test.d.ts +0 -1
  37. package/dist/config/Members.test.js +0 -66
  38. package/dist/config/Workspaces.test.d.ts +0 -1
  39. package/dist/config/Workspaces.test.js +0 -90
  40. package/dist/index.cjs +0 -5289
  41. package/dist/index.cjs.map +0 -1
  42. package/dist/index.d.cts +0 -149
  43. package/dist/index.js.map +0 -1
  44. package/dist/selfhost/assets/index-BZmImfYZ.css +0 -1
  45. package/dist/selfhost/assets/index-CS268KSw.js +0 -40
  46. package/dist/test/setup.d.ts +0 -1
  47. package/dist/test/setup.js +0 -2
  48. package/dist/views/Chat.d.ts +0 -5
  49. package/dist/views/Chat.js +0 -60
  50. package/dist/views/Chat.test.d.ts +0 -1
  51. package/dist/views/Chat.test.js +0 -92
  52. package/dist/views/Config.d.ts +0 -12
  53. package/dist/views/Config.js +0 -6
  54. package/dist/views/Tasks.d.ts +0 -5
  55. package/dist/views/Tasks.js +0 -75
  56. package/dist/views/Tasks.test.d.ts +0 -1
  57. package/dist/views/Tasks.test.js +0 -115
  58. package/dist/views/useLandscape.d.ts +0 -14
  59. package/dist/views/useLandscape.js +0 -74
  60. package/dist/views/useLandscape.test.d.ts +0 -1
  61. package/dist/views/useLandscape.test.js +0 -101
  62. package/dist/views/wallboard.d.ts +0 -46
  63. package/dist/views/wallboard.js +0 -209
  64. package/dist/views/wallboard.test.d.ts +0 -1
  65. package/dist/views/wallboard.test.js +0 -143
package/README.md CHANGED
@@ -1,47 +1,47 @@
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
+
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,76 +1,32 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useCallback, useEffect, useRef, useState } from "react";
2
+ import { useCallback, useEffect, useState } from "react";
3
3
  import { Dashboard } from "./components/Dashboard.js";
4
4
  import { Workspaces } from "./config/Workspaces.js";
5
5
  import { ConnectAgent } from "./config/ConnectAgent.js";
6
- import { EmptyState } from "./config/EmptyState.js";
7
6
  import { useShepherdClient } from "./context.js";
8
7
  import { describeError } from "./client.js";
9
- const VIEWS = [
10
- { id: "board", label: "Board" },
11
- { id: "config", label: "Config" },
12
- ];
13
8
  export function ShepherdRoot({ hubUrl }) {
14
9
  const client = useShepherdClient();
15
10
  const [load, setLoad] = useState({ status: "loading" });
16
11
  const [selectedId, setSelectedId] = useState(null);
17
- const [view, setView] = useState("board");
18
- // Roving-tabindex refs for the tablist: each tab button registers itself by
19
- // index so arrow/Home/End navigation can move DOM focus to the target tab.
20
- const tabRefs = useRef([]);
21
- // WAI-ARIA "tabs with automatic activation": Arrow keys (wrap-around),
22
- // Home, End change the active view AND move focus to the matching tab.
23
- const onTabKeyDown = useCallback((e, index) => {
24
- const last = VIEWS.length - 1;
25
- let next = null;
26
- switch (e.key) {
27
- case "ArrowRight":
28
- next = index === last ? 0 : index + 1;
29
- break;
30
- case "ArrowLeft":
31
- next = index === 0 ? last : index - 1;
32
- break;
33
- case "Home":
34
- next = 0;
35
- break;
36
- case "End":
37
- next = last;
38
- break;
39
- default:
40
- return;
41
- }
42
- e.preventDefault();
43
- setView(VIEWS[next].id);
44
- tabRefs.current[next]?.focus();
45
- }, []);
46
- // The landing view (Config vs Board) is decided exactly once, on the first
47
- // resolved list (isInitial), so a later refresh (after create/join) never
48
- // re-routes the user off whatever tab they're on.
49
- const fetchWorkspaces = useCallback(async (isInitial) => {
12
+ // Loads the workspace list and keeps the selection valid: a still-present
13
+ // selection is preserved (so a create/join refresh never yanks the user off
14
+ // their current workspace), otherwise it falls back to the first workspace.
15
+ const fetchWorkspaces = useCallback(async () => {
50
16
  try {
51
17
  const res = await client.listWorkspaces();
52
18
  setLoad({ status: "ready", workspaces: res.workspaces });
53
- // Keep the current selection if it still exists; else pick the first.
54
19
  setSelectedId((prev) => prev && res.workspaces.some((w) => w.id === prev)
55
20
  ? prev
56
21
  : (res.workspaces[0]?.id ?? null));
57
- if (isInitial) {
58
- setView(res.workspaces.length === 0 ? "config" : "board");
59
- }
60
22
  }
61
23
  catch (err) {
62
- setLoad({
63
- status: "error",
64
- message: describeError(err),
65
- });
24
+ setLoad({ status: "error", message: describeError(err) });
66
25
  }
67
26
  }, [client]);
68
- // Initial load + landing decision (runs once on mount).
69
27
  useEffect(() => {
70
- void fetchWorkspaces(true);
71
- // eslint-disable-next-line react-hooks/exhaustive-deps
72
- }, []);
73
- const refresh = useCallback(() => fetchWorkspaces(false), [fetchWorkspaces]);
28
+ void fetchWorkspaces();
29
+ }, [fetchWorkspaces]);
74
30
  const workspaces = load.status === "ready" ? load.workspaces : [];
75
31
  const selected = workspaces.find((w) => w.id === selectedId) ?? null;
76
32
  if (load.status === "loading") {
@@ -80,9 +36,11 @@ export function ShepherdRoot({ hubUrl }) {
80
36
  return (_jsx("div", { className: "shepherd-root", children: _jsx("p", { role: "alert", children: load.message }) }));
81
37
  }
82
38
  const hasWorkspace = workspaces.length > 0;
83
- return (_jsxs("div", { className: "shepherd-root", children: [_jsxs("header", { children: [_jsx("h1", { className: "brand", style: { margin: 0, font: "inherit" }, children: "Shepherd" }), _jsx("nav", { className: "tabs", role: "tablist", "aria-label": "Shepherd views", children: VIEWS.map(({ id, label }, index) => (_jsx("button", { ref: (el) => {
84
- tabRefs.current[index] = el;
85
- }, type: "button", role: "tab", id: `tab-${id}`, "aria-controls": `panel-${id}`, "aria-selected": view === id, tabIndex: view === id ? 0 : -1, onClick: () => setView(id), onKeyDown: (e) => onTabKeyDown(e, index), children: label }, id))) })] }), _jsxs("main", { children: [view === "board" && (_jsx("div", { role: "tabpanel", id: "panel-board", "aria-labelledby": "tab-board", tabIndex: 0, children: hasWorkspace && selected ? (_jsx(Dashboard, { workspaceId: selected.id })) : (_jsx(EmptyState, { onGetStarted: () => setView("config") })) })), view === "config" && (_jsx("div", { role: "tabpanel", id: "panel-config", "aria-labelledby": "tab-config", tabIndex: 0, children: _jsxs("section", { className: "shepherd-config", "aria-labelledby": "config-heading", children: [_jsx("h2", { id: "config-heading", children: "Configuration" }), _jsx(Workspaces, { workspaces: workspaces, selected: selected, onChanged: () => {
86
- void refresh();
87
- }, onSelect: setSelectedId }), selected && _jsx(ConnectAgent, { workspaceId: selected.id, hubUrl: hubUrl })] }) }))] })] }));
39
+ // The Config tab's content. Rendered by <Dashboard> in its `config` panel.
40
+ // ConnectAgent only appears once a workspace is selected (nothing to connect
41
+ // an agent to otherwise).
42
+ const configSection = (_jsxs("section", { className: "shepherd-config", "aria-labelledby": "config-heading", children: [_jsx("h2", { id: "config-heading", children: "Configuration" }), _jsx(Workspaces, { workspaces: workspaces, selected: selected, onChanged: () => {
43
+ void fetchWorkspaces();
44
+ }, onSelect: setSelectedId }), selected && _jsx(ConnectAgent, { workspaceId: selected.id, hubUrl: hubUrl })] }));
45
+ return (_jsx("div", { className: "shepherd-root", children: _jsx(Dashboard, { workspaceId: selected?.id, config: configSection, hasWorkspace: hasWorkspace }) }));
88
46
  }
@@ -1,4 +1,4 @@
1
- import type { ReactElement } from "react";
1
+ import type { ReactElement, ReactNode } from "react";
2
2
  /** Props for {@link Dashboard}. The client comes from context. */
3
3
  export interface DashboardProps {
4
4
  /**
@@ -8,6 +8,20 @@ export interface DashboardProps {
8
8
  * aliases, so a no-id render is unchanged.
9
9
  */
10
10
  workspaceId?: string;
11
+ /**
12
+ * The hosted-shell management view. When supplied, a third `Config` tab is
13
+ * shown beside `Tasks`/`Chat` and this node renders in its panel. Omitting it
14
+ * (the self-host case) keeps the board a plain two-tab Tasks/Chat wallboard.
15
+ */
16
+ config?: ReactNode;
17
+ /**
18
+ * Whether the account currently belongs to a workspace. Only meaningful in the
19
+ * hosted shell (paired with {@link config}). When `false`, the Tasks/Chat
20
+ * panels show an {@link EmptyState} prompt instead of a board, the board does
21
+ * not poll, and the view lands on Config. Defaults to "yes" (self-host always
22
+ * has its implicit team workspace).
23
+ */
24
+ hasWorkspace?: boolean;
11
25
  }
12
26
  /**
13
27
  * The Shepherd wallboard shell. Composes {@link useLandscapePolling} (which reads
@@ -31,4 +45,4 @@ export interface DashboardProps {
31
45
  *
32
46
  * @returns The dashboard element.
33
47
  */
34
- export declare function Dashboard({ workspaceId }?: DashboardProps): ReactElement;
48
+ export declare function Dashboard({ workspaceId, config, hasWorkspace, }?: DashboardProps): ReactElement;
@@ -1,4 +1,4 @@
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, useRef, useState } from "react";
3
3
  import { useLandscapePolling } from "../useLandscapePolling.js";
4
4
  import { defaultRepo, distinctRepos, matchesRepo } from "../logic.js";
@@ -8,6 +8,7 @@ import { ActiveList } from "./ActiveList.js";
8
8
  import { DoneList } from "./DoneList.js";
9
9
  import { Chat } from "./Chat.js";
10
10
  import { Composer } from "./Composer.js";
11
+ import { EmptyState } from "../config/EmptyState.js";
11
12
  /**
12
13
  * localStorage keys + page size, ported verbatim from the state block of
13
14
  * packages/hub/public/app.js (POLL_MS lives in the hook).
@@ -126,9 +127,73 @@ function computeCounts(snapshot) {
126
127
  *
127
128
  * @returns The dashboard element.
128
129
  */
129
- export function Dashboard({ workspaceId } = {}) {
130
- const { snapshot, status, lastUpdatedMs, refresh } = useLandscapePolling({ workspaceId });
131
- const [activeTab, setActiveTab] = useState(() => (readStored(TAB_KEY) === "chat" ? "chat" : "tasks"));
130
+ export function Dashboard({ workspaceId, config, hasWorkspace, } = {}) {
131
+ const hasConfig = config != null;
132
+ // Only the hosted shell with an explicit empty account suppresses the board;
133
+ // self-host (hasWorkspace undefined) always has its implicit team workspace.
134
+ const noWorkspace = hasWorkspace === false;
135
+ const { snapshot, status, lastUpdatedMs, refresh } = useLandscapePolling({
136
+ workspaceId,
137
+ // A no-workspace board has nothing to poll; keep it off the hub.
138
+ enabled: !noWorkspace,
139
+ });
140
+ const [activeTab, setActiveTab] = useState(() => {
141
+ // No workspace yet → land on Config, where create/join lives.
142
+ if (hasConfig && noWorkspace)
143
+ return "config";
144
+ const stored = readStored(TAB_KEY);
145
+ if (stored === "chat")
146
+ return "chat";
147
+ if (stored === "config" && hasConfig)
148
+ return "config";
149
+ return "tasks";
150
+ });
151
+ // The flat tab strip: Tasks/Chat always, Config only when the host supplies it.
152
+ const tabs = hasConfig
153
+ ? [
154
+ { id: "tasks", label: "Tasks" },
155
+ { id: "chat", label: "Chat" },
156
+ { id: "config", label: "Config" },
157
+ ]
158
+ : [
159
+ { id: "tasks", label: "Tasks" },
160
+ { id: "chat", label: "Chat" },
161
+ ];
162
+ // Roving-tabindex refs for the tablist: each tab registers by index so
163
+ // arrow/Home/End navigation can move DOM focus to the target tab.
164
+ const tabRefs = useRef([]);
165
+ // WAI-ARIA "tabs with automatic activation": Arrow keys (wrap-around), Home,
166
+ // End change the active view AND move focus to the matching tab.
167
+ const onTabKeyDown = useCallback((e, index) => {
168
+ const last = tabs.length - 1;
169
+ let next = null;
170
+ switch (e.key) {
171
+ case "ArrowRight":
172
+ next = index === last ? 0 : index + 1;
173
+ break;
174
+ case "ArrowLeft":
175
+ next = index === 0 ? last : index - 1;
176
+ break;
177
+ case "Home":
178
+ next = 0;
179
+ break;
180
+ case "End":
181
+ next = last;
182
+ break;
183
+ default:
184
+ return;
185
+ }
186
+ e.preventDefault();
187
+ const target = tabs[next];
188
+ if (!target)
189
+ return;
190
+ onTab(target.id);
191
+ tabRefs.current[next]?.focus();
192
+ },
193
+ // `tabs` is rebuilt each render but its identity only matters by length,
194
+ // which is stable for a given `hasConfig`; `onTab` is stable.
195
+ // eslint-disable-next-line react-hooks/exhaustive-deps
196
+ [hasConfig]);
132
197
  // `null` = not yet chosen (derive a default on first render); "__all__" = All
133
198
  // repos; else a specific repo. Seeded from storage exactly like app.js.
134
199
  const [selectedRepo, setSelectedRepo] = useState(() => readStored(REPO_KEY));
@@ -205,5 +270,11 @@ export function Dashboard({ workspaceId } = {}) {
205
270
  // equals the `active` vital here (same filter), but doneCount is distinct.
206
271
  const activeCount = tasks.filter((t) => t.status === "active" && matchesRepo(t, effectiveRepo)).length;
207
272
  const doneCount = tasks.filter((t) => t.status !== "active" && matchesRepo(t, effectiveRepo)).length;
208
- return (_jsxs("div", { id: "board", children: [_jsxs("header", { children: [_jsx("span", { className: "brand", children: "Shepherd" }), _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" }), _jsx("span", { id: "status", className: "status" + (statusView.kind ? ` status--${statusView.kind}` : ""), children: statusView.text }), _jsx("span", { id: "freshness", className: "freshness", children: freshness }), _jsxs("nav", { className: "tabs", role: "tablist", "aria-label": "Dashboard views", children: [_jsx("button", { className: "tab" + (activeTab === "tasks" ? " tab--active" : ""), "data-tab": "tasks", type: "button", role: "tab", id: "tab-tasks", "aria-controls": "panel-tasks", "aria-selected": activeTab === "tasks", onClick: () => onTab("tasks"), children: "Tasks" }), _jsx("button", { className: "tab" + (activeTab === "chat" ? " tab--active" : ""), "data-tab": "chat", type: "button", role: "tab", id: "tab-chat", "aria-controls": "panel-chat", "aria-selected": activeTab === "chat", onClick: () => onTab("chat"), children: "Chat" })] })] }), _jsxs("section", { id: "panel-tasks", role: "tabpanel", "aria-labelledby": "tab-tasks", hidden: activeTab !== "tasks", 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: _jsxs("div", { className: "chat-wrap", children: [_jsx(Chat, { announcements: announcements, selectedRepo: effectiveRepo, nowMs: nowMs }), _jsx(Composer, { agents: agents, selectedRepo: effectiveRepo, workspaceId: workspaceId, onSent: refresh })] }) })] }));
273
+ // The poll-driven header chrome (repo filter, vitals, status, freshness) only
274
+ // makes sense for a live board — hide it on the Config tab and when there is
275
+ // no workspace to poll. The brand and the tab strip stay in every state.
276
+ const showBoardChrome = !noWorkspace && activeTab !== "config";
277
+ 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
+ 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 }))] }));
209
280
  }
@@ -112,8 +112,8 @@ export function ConnectAgent({ workspaceId, hubUrl }) {
112
112
  }
113
113
  }
114
114
  const command = installCommand(tool, directHubUrl, rawToken ?? TOKEN_PLACEHOLDER);
115
- return (_jsxs("section", { className: "shepherd-connect-agent", "aria-labelledby": headingId, children: [_jsx("h3", { id: headingId, children: "Connect your agent" }), _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: "generate", children: [_jsx("label", { htmlFor: "token-name", children: "Token name (optional)" }), _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) => {
116
- 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));
118
- }) }))] }));
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) => {
116
+ 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));
118
+ }) }))] })] }));
119
119
  }
@@ -1,4 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useId } from "react";
2
3
  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
+ // A per-instance id so two EmptyStates (e.g. the Tasks and Chat panels of a
5
+ // no-workspace board) never collide on a duplicate `empty-state-heading` id.
6
+ const headingId = useId();
7
+ return (_jsxs("section", { className: "shepherd-empty-state", "aria-labelledby": headingId, children: [_jsx("h2", { id: headingId, 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
8
  }
@@ -76,8 +76,8 @@ export function Members({ workspaceId, refreshKey = 0, canRemove = false, onLeft
76
76
  setLeaving(false);
77
77
  }
78
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" })] }));
79
+ 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
+ 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
83
  }
@@ -92,5 +92,5 @@ export function Workspaces({ workspaces, selected, onChanged, onSelect }) {
92
92
  const joinLink = invite
93
93
  ? `${window.location.origin}/join/${encodeURIComponent(invite.code)}`
94
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 })] }))] }));
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 })] }))] })] }));
96
96
  }