@korso/shepherd-ui 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) 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/EmptyState.js +5 -1
  6. package/dist/lib/{Dashboard-CG6KeFQ3.js → Dashboard-B7nioe5N.js} +529 -457
  7. package/dist/lib/index.d.ts +15 -1
  8. package/dist/lib/index.js +196 -279
  9. package/dist/lib/selfhost.js +1 -1
  10. package/dist/lib/styles.css +217 -209
  11. package/dist/selfhost/assets/index-D6rTsbVM.js +40 -0
  12. package/dist/selfhost/assets/{index-BZmImfYZ.css → index-DnhlP_lc.css} +1 -1
  13. package/dist/selfhost/index.html +13 -13
  14. package/dist/useLandscapePolling.d.ts +7 -0
  15. package/dist/useLandscapePolling.js +6 -1
  16. package/package.json +67 -67
  17. package/dist/ShepherdProvider.d.ts +0 -12
  18. package/dist/ShepherdProvider.js +0 -33
  19. package/dist/ShepherdProvider.test.d.ts +0 -1
  20. package/dist/ShepherdProvider.test.js +0 -47
  21. package/dist/ShepherdRoot.routing.test.d.ts +0 -1
  22. package/dist/ShepherdRoot.routing.test.js +0 -61
  23. package/dist/ShepherdRoot.test.d.ts +0 -1
  24. package/dist/ShepherdRoot.test.js +0 -37
  25. package/dist/app/assets/index-CDOCIg6s.js +0 -11
  26. package/dist/app/index.html +0 -12
  27. package/dist/client.test.d.ts +0 -1
  28. package/dist/client.test.js +0 -298
  29. package/dist/config/ConnectAgent.test.d.ts +0 -1
  30. package/dist/config/ConnectAgent.test.js +0 -92
  31. package/dist/config/EmptyState.test.d.ts +0 -1
  32. package/dist/config/EmptyState.test.js +0 -32
  33. package/dist/config/Members.test.d.ts +0 -1
  34. package/dist/config/Members.test.js +0 -66
  35. package/dist/config/Workspaces.test.d.ts +0 -1
  36. package/dist/config/Workspaces.test.js +0 -90
  37. package/dist/index.cjs +0 -5289
  38. package/dist/index.cjs.map +0 -1
  39. package/dist/index.d.cts +0 -149
  40. package/dist/index.js.map +0 -1
  41. package/dist/selfhost/assets/index-CS268KSw.js +0 -40
  42. package/dist/test/setup.d.ts +0 -1
  43. package/dist/test/setup.js +0 -2
  44. package/dist/views/Chat.d.ts +0 -5
  45. package/dist/views/Chat.js +0 -60
  46. package/dist/views/Chat.test.d.ts +0 -1
  47. package/dist/views/Chat.test.js +0 -92
  48. package/dist/views/Config.d.ts +0 -12
  49. package/dist/views/Config.js +0 -6
  50. package/dist/views/Tasks.d.ts +0 -5
  51. package/dist/views/Tasks.js +0 -75
  52. package/dist/views/Tasks.test.d.ts +0 -1
  53. package/dist/views/Tasks.test.js +0 -115
  54. package/dist/views/useLandscape.d.ts +0 -14
  55. package/dist/views/useLandscape.js +0 -74
  56. package/dist/views/useLandscape.test.d.ts +0 -1
  57. package/dist/views/useLandscape.test.js +0 -101
  58. package/dist/views/wallboard.d.ts +0 -46
  59. package/dist/views/wallboard.js +0 -209
  60. package/dist/views/wallboard.test.d.ts +0 -1
  61. 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
  }
@@ -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
  }