@korso/shepherd-ui 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/dist/ShepherdProvider.d.ts +12 -0
  2. package/dist/ShepherdProvider.js +33 -0
  3. package/dist/ShepherdProvider.test.d.ts +1 -0
  4. package/dist/ShepherdProvider.test.js +47 -0
  5. package/dist/ShepherdRoot.d.ts +9 -0
  6. package/dist/ShepherdRoot.js +88 -0
  7. package/dist/ShepherdRoot.routing.test.d.ts +1 -0
  8. package/dist/ShepherdRoot.routing.test.js +61 -0
  9. package/dist/ShepherdRoot.test.d.ts +1 -0
  10. package/dist/ShepherdRoot.test.js +37 -0
  11. package/dist/app/assets/index-CDOCIg6s.js +11 -0
  12. package/dist/app/index.html +12 -0
  13. package/dist/client.d.ts +107 -0
  14. package/dist/client.js +238 -0
  15. package/dist/client.test.d.ts +1 -0
  16. package/dist/client.test.js +298 -0
  17. package/dist/components/ActiveList.d.ts +22 -0
  18. package/dist/components/ActiveList.js +59 -0
  19. package/dist/components/Chat.d.ts +30 -0
  20. package/dist/components/Chat.js +61 -0
  21. package/dist/components/Composer.d.ts +37 -0
  22. package/dist/components/Composer.js +146 -0
  23. package/dist/components/Crew.d.ts +23 -0
  24. package/dist/components/Crew.js +31 -0
  25. package/dist/components/Dashboard.d.ts +34 -0
  26. package/dist/components/Dashboard.js +209 -0
  27. package/dist/components/DoneList.d.ts +29 -0
  28. package/dist/components/DoneList.js +50 -0
  29. package/dist/components/RepoSelect.d.ts +38 -0
  30. package/dist/components/RepoSelect.js +56 -0
  31. package/dist/components/Territory.d.ts +21 -0
  32. package/dist/components/Territory.js +21 -0
  33. package/dist/config/ConnectAgent.d.ts +10 -0
  34. package/dist/config/ConnectAgent.js +119 -0
  35. package/dist/config/ConnectAgent.test.d.ts +1 -0
  36. package/dist/config/ConnectAgent.test.js +92 -0
  37. package/dist/config/EmptyState.d.ts +12 -0
  38. package/dist/config/EmptyState.js +4 -0
  39. package/dist/config/EmptyState.test.d.ts +1 -0
  40. package/dist/config/EmptyState.test.js +32 -0
  41. package/dist/config/Members.d.ts +10 -0
  42. package/dist/config/Members.js +83 -0
  43. package/dist/config/Members.test.d.ts +1 -0
  44. package/dist/config/Members.test.js +66 -0
  45. package/dist/config/Workspaces.d.ts +12 -0
  46. package/dist/config/Workspaces.js +96 -0
  47. package/dist/config/Workspaces.test.d.ts +1 -0
  48. package/dist/config/Workspaces.test.js +90 -0
  49. package/dist/config/index.d.ts +8 -0
  50. package/dist/config/index.js +6 -0
  51. package/dist/context.d.ts +28 -0
  52. package/dist/context.js +36 -0
  53. package/dist/index.cjs +5289 -0
  54. package/dist/index.cjs.map +1 -0
  55. package/dist/index.d.cts +149 -0
  56. package/dist/index.d.ts +18 -0
  57. package/dist/index.js +15 -0
  58. package/dist/index.js.map +1 -0
  59. package/dist/lib/Dashboard-CG6KeFQ3.js +1214 -0
  60. package/dist/lib/index.d.ts +438 -16
  61. package/dist/lib/index.js +455 -6
  62. package/dist/lib/selfhost.js +9 -9
  63. package/dist/lib/styles.css +27 -0
  64. package/dist/logic.d.ts +208 -0
  65. package/dist/logic.js +372 -0
  66. package/dist/main.d.ts +1 -0
  67. package/dist/main.js +11 -0
  68. package/dist/selfhost/assets/{index-tSyzirZa.css → index-BZmImfYZ.css} +1 -1
  69. package/dist/selfhost/assets/index-CS268KSw.js +40 -0
  70. package/dist/selfhost/index.html +2 -2
  71. package/dist/selfhost.d.ts +22 -0
  72. package/dist/selfhost.js +97 -0
  73. package/dist/test/mockClient.d.ts +6 -0
  74. package/dist/test/mockClient.js +45 -0
  75. package/dist/test/setup.d.ts +1 -0
  76. package/dist/test/setup.js +2 -0
  77. package/dist/useLandscapePolling.d.ts +63 -0
  78. package/dist/useLandscapePolling.js +129 -0
  79. package/dist/views/Chat.d.ts +5 -0
  80. package/dist/views/Chat.js +60 -0
  81. package/dist/views/Chat.test.d.ts +1 -0
  82. package/dist/views/Chat.test.js +92 -0
  83. package/dist/views/Config.d.ts +12 -0
  84. package/dist/views/Config.js +6 -0
  85. package/dist/views/Tasks.d.ts +5 -0
  86. package/dist/views/Tasks.js +75 -0
  87. package/dist/views/Tasks.test.d.ts +1 -0
  88. package/dist/views/Tasks.test.js +115 -0
  89. package/dist/views/useLandscape.d.ts +14 -0
  90. package/dist/views/useLandscape.js +74 -0
  91. package/dist/views/useLandscape.test.d.ts +1 -0
  92. package/dist/views/useLandscape.test.js +101 -0
  93. package/dist/views/wallboard.d.ts +46 -0
  94. package/dist/views/wallboard.js +209 -0
  95. package/dist/views/wallboard.test.d.ts +1 -0
  96. package/dist/views/wallboard.test.js +143 -0
  97. package/package.json +1 -1
  98. package/dist/lib/Dashboard-CB6SL-J2.js +0 -1064
  99. package/dist/selfhost/assets/index-BBx3vo2Y.js +0 -40
@@ -0,0 +1,209 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useCallback, useEffect, useRef, useState } from "react";
3
+ import { useLandscapePolling } from "../useLandscapePolling.js";
4
+ import { defaultRepo, distinctRepos, matchesRepo } from "../logic.js";
5
+ import { RepoSelect } from "./RepoSelect.js";
6
+ import { Crew } from "./Crew.js";
7
+ import { ActiveList } from "./ActiveList.js";
8
+ import { DoneList } from "./DoneList.js";
9
+ import { Chat } from "./Chat.js";
10
+ import { Composer } from "./Composer.js";
11
+ /**
12
+ * localStorage keys + page size, ported verbatim from the state block of
13
+ * packages/hub/public/app.js (POLL_MS lives in the hook).
14
+ */
15
+ const TAB_KEY = "shepherd.tab";
16
+ const REPO_KEY = "shepherd.repo";
17
+ const DONE_PAGE = 10;
18
+ /**
19
+ * Maps the polling hook's {@link LandscapeStatus} onto the chrome text + modifier
20
+ * class the original board's `setStatus` produced. Kept as data so the header
21
+ * stays a thin render of the hook's state.
22
+ */
23
+ const STATUS_VIEW = {
24
+ live: { text: "live", kind: "ok" },
25
+ reconnecting: { text: "reconnecting…", kind: "warn" },
26
+ // app.js cleared the token and re-prompted on 401; in the auth-agnostic port
27
+ // the injected client's onUnauthorized owns any token handling, so the board
28
+ // only surfaces the rejected state.
29
+ unauthorized: { text: "token rejected", kind: "error" },
30
+ };
31
+ /** localStorage read that never throws (private/quota modes return null). */
32
+ function readStored(key) {
33
+ try {
34
+ return localStorage.getItem(key);
35
+ }
36
+ catch {
37
+ return null;
38
+ }
39
+ }
40
+ /** localStorage write that never throws — persistence is best-effort chrome. */
41
+ function writeStored(key, value) {
42
+ try {
43
+ localStorage.setItem(key, value);
44
+ }
45
+ catch {
46
+ // Ignore: a board that can't persist its filter still works this session.
47
+ }
48
+ }
49
+ /**
50
+ * Resolves the repo to actually filter by for this render, reproducing the
51
+ * three side-effecting rules inside app.js's `render()` in order:
52
+ *
53
+ * 1. `null` (never chosen) + >=2 repos -> derive the newest-active default.
54
+ * 2. a persisted specific repo that has VANISHED from the data -> "__all__"
55
+ * (so a workspace that narrowed to one repo can't strand the board on an
56
+ * absent repo — the selector itself hides under 2 repos).
57
+ * 3. ONLY on first render, a persisted specific repo with no active work while
58
+ * another repo has some -> prefer the newest-active default, so a returning
59
+ * viewer never lands on an empty Active column. In-session picks are kept.
60
+ *
61
+ * Pure given its inputs; the caller commits the result back to state.
62
+ *
63
+ * @param selectedRepo - The current selection (`null` = not yet chosen).
64
+ * @param snapshot - The landscape to resolve against.
65
+ * @param firstRender - Whether this is the first render with a snapshot.
66
+ * @returns The repo to use this render (`"__all__"` or a specific repo, never
67
+ * `null` once >=2 repos exist with no prior choice).
68
+ */
69
+ function resolveSelectedRepo(selectedRepo, snapshot, firstRender) {
70
+ const repos = distinctRepos(snapshot.tasks);
71
+ let next = selectedRepo;
72
+ if (next === null && repos.length >= 2) {
73
+ next = defaultRepo(snapshot.tasks);
74
+ }
75
+ if (next !== null && next !== "__all__" && !repos.includes(next)) {
76
+ next = "__all__";
77
+ }
78
+ if (firstRender && next !== null && next !== "__all__") {
79
+ const pinnedHasActive = snapshot.tasks.some((t) => t.status === "active" && t.repo === next);
80
+ const anyActive = snapshot.tasks.some((t) => t.status === "active");
81
+ if (!pinnedHasActive && anyActive)
82
+ next = defaultRepo(snapshot.tasks);
83
+ }
84
+ return next;
85
+ }
86
+ /**
87
+ * Per-repo `{active,done}` tallies plus the board-wide `"__all__"` aggregate,
88
+ * mirroring app.js's `counts` map (each task bumps its own repo AND the
89
+ * aggregate). The shape RepoSelect's `counts` prop expects.
90
+ *
91
+ * @param snapshot - The landscape whose tasks are tallied.
92
+ * @returns A record keyed by repo, with an `"__all__"` aggregate row.
93
+ */
94
+ function computeCounts(snapshot) {
95
+ const counts = {};
96
+ const bump = (repo, key) => {
97
+ const c = (counts[repo] ??= { active: 0, done: 0 });
98
+ c[key]++;
99
+ };
100
+ for (const t of snapshot.tasks) {
101
+ const key = t.status === "active" ? "active" : "done";
102
+ bump(t.repo, key);
103
+ bump("__all__", key);
104
+ }
105
+ return counts;
106
+ }
107
+ /**
108
+ * The Shepherd wallboard shell. Composes {@link useLandscapePolling} (which reads
109
+ * the client from context, keeping this auth-agnostic) with the six leaf
110
+ * components, owning only the UI state app.js kept in module variables:
111
+ *
112
+ * - `activeTab` — persisted to `"shepherd.tab"` (default `"tasks"`).
113
+ * - `selectedRepo` — persisted to `"shepherd.repo"`; `null` derives a default on
114
+ * the first render with >=2 repos (see {@link resolveSelectedRepo}).
115
+ * - `doneShown` — the history page size, grown a page at a time by "Load older".
116
+ *
117
+ * Header chrome mirrors app.js: brand, the repo filter, vitals (online/active
118
+ * counts under the current filter), the poll status indicator, a freshness
119
+ * string, and the tab strip. The body shows the Tasks panel (crew + active +
120
+ * done) or the Chat panel (feed + composer); the composer's post-send hook is the
121
+ * polling hook's `refresh`, so a sent message shows up immediately.
122
+ *
123
+ * Renders the header even before the first snapshot arrives so the board never
124
+ * blanks — the panels simply render their own empty states off an empty
125
+ * landscape.
126
+ *
127
+ * @returns The dashboard element.
128
+ */
129
+ export function Dashboard({ workspaceId } = {}) {
130
+ const { snapshot, status, lastUpdatedMs, refresh } = useLandscapePolling({ workspaceId });
131
+ const [activeTab, setActiveTab] = useState(() => (readStored(TAB_KEY) === "chat" ? "chat" : "tasks"));
132
+ // `null` = not yet chosen (derive a default on first render); "__all__" = All
133
+ // repos; else a specific repo. Seeded from storage exactly like app.js.
134
+ const [selectedRepo, setSelectedRepo] = useState(() => readStored(REPO_KEY));
135
+ const [doneShown, setDoneShown] = useState(DONE_PAGE);
136
+ // First-load guard for resolveSelectedRepo's rule 3 — a persisted repo is only
137
+ // second-guessed once, then in-session selections are respected (a ref because
138
+ // flipping it must not itself trigger a render).
139
+ const firstRenderRef = useRef(true);
140
+ // No local 1s "freshness" timer: useLandscapePolling already runs its own 1s
141
+ // tick that re-renders this component, so `freshness` (computed from Date.now()
142
+ // below) refreshes every second on that re-render. A second interval here would
143
+ // only duplicate the work.
144
+ const onTab = useCallback((tab) => {
145
+ setActiveTab(tab);
146
+ writeStored(TAB_KEY, tab);
147
+ }, []);
148
+ const onSelectRepo = useCallback((repo) => {
149
+ // app.js stores the "__all__" sentinel for All repos (RepoSelect reports null).
150
+ const next = repo === null ? "__all__" : repo;
151
+ setSelectedRepo(next);
152
+ writeStored(REPO_KEY, next);
153
+ setDoneShown(DONE_PAGE); // a new filter resets the history page
154
+ }, []);
155
+ const onLoadMore = useCallback(() => {
156
+ setDoneShown((n) => n + DONE_PAGE);
157
+ }, []);
158
+ // Resolve the effective repo for this render. When the resolver derives a
159
+ // value different from state (first-load default / vanished repo), commit it so
160
+ // the selection is sticky and persisted — done in an effect to avoid a setState
161
+ // during render. `firstRenderRef` is consumed (flipped) once a snapshot exists.
162
+ const effectiveRepo = snapshot
163
+ ? resolveSelectedRepo(selectedRepo, snapshot, firstRenderRef.current)
164
+ : selectedRepo;
165
+ useEffect(() => {
166
+ if (!snapshot)
167
+ return;
168
+ firstRenderRef.current = false;
169
+ if (effectiveRepo !== selectedRepo) {
170
+ setSelectedRepo(effectiveRepo);
171
+ // Persist a derived *concrete* default so the next visit is stable (a
172
+ // documented superset of app.js, which wrote REPO_KEY only on an explicit
173
+ // pick). Do NOT persist the "__all__" vanished-repo fallback: it is a
174
+ // transient reaction to a repo briefly dropping out of one poll, and
175
+ // writing it would permanently clobber the viewer's saved specific filter
176
+ // even after that repo reappears. Keeping it in-state only restores
177
+ // app.js's recover-the-saved-repo-on-reload behavior.
178
+ if (effectiveRepo !== null && effectiveRepo !== "__all__") {
179
+ writeStored(REPO_KEY, effectiveRepo);
180
+ }
181
+ }
182
+ }, [snapshot, effectiveRepo, selectedRepo]);
183
+ const nowMs = snapshot ? Date.parse(snapshot.serverTime) : Date.now();
184
+ const repos = snapshot ? distinctRepos(snapshot.tasks) : [];
185
+ const counts = snapshot
186
+ ? computeCounts(snapshot)
187
+ : {};
188
+ // Vitals: live agents + active tasks under the current filter (matchesRepo
189
+ // treats null/"__all__" as all), mirroring app.js's `online`/`active`.
190
+ const online = snapshot
191
+ ? snapshot.agents.filter((a) => a.presence === "live" && matchesRepo({ repo: a.repo ?? "" }, effectiveRepo)).length
192
+ : 0;
193
+ const active = snapshot
194
+ ? snapshot.tasks.filter((t) => t.status === "active" && matchesRepo(t, effectiveRepo)).length
195
+ : 0;
196
+ const statusView = STATUS_VIEW[status];
197
+ const freshness = lastUpdatedMs === null
198
+ ? ""
199
+ : `updated ${Math.floor((Date.now() - lastUpdatedMs) / 1000)}s ago`;
200
+ const agents = snapshot?.agents ?? [];
201
+ const tasks = snapshot?.tasks ?? [];
202
+ const announcements = snapshot?.announcements ?? [];
203
+ // Column-head badges, ported from `renderActive`/`renderDone` which set
204
+ // `#active-count`/`#done-count` to the filtered LIST lengths. activeCount
205
+ // equals the `active` vital here (same filter), but doneCount is distinct.
206
+ const activeCount = tasks.filter((t) => t.status === "active" && matchesRepo(t, effectiveRepo)).length;
207
+ 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 })] }) })] }));
209
+ }
@@ -0,0 +1,29 @@
1
+ import type { ReactElement } from "react";
2
+ import type { WorkspaceTaskT } from "@shepherd/shared";
3
+ /** Props for {@link DoneList}. */
4
+ export interface DoneListProps {
5
+ /** The board's tasks; only finished ones in the selected repo are shown. */
6
+ tasks: WorkspaceTaskT[];
7
+ /** The server-clock "now" in epoch ms, for relative time + day bucketing. */
8
+ nowMs: number;
9
+ /** The selected repo, or `null`/`"__all__"` for all repos. */
10
+ selectedRepo: string | null;
11
+ /** Cumulative number of finished tasks to display (initial 10). */
12
+ doneShown: number;
13
+ /** Called when "Load older" is clicked, to grow `doneShown` by a page. */
14
+ onLoadMore: () => void;
15
+ }
16
+ /**
17
+ * The Done column. Ported from app.js `renderDone`. Filters tasks to
18
+ * non-active + selected-repo, then renders the cumulative `[0, doneShown]`
19
+ * slice under day-bucket headers ({@link dayBucket} on `endedAt ?? createdAt`).
20
+ * A dropped task gets a `task__stat--drop` chip and a "went offline … — no done
21
+ * signal" meta; a done task gets a "done" chip and a "finished … · active Nm"
22
+ * meta. When more finished tasks exist than are shown, a "Load older · N of M"
23
+ * button calls {@link DoneListProps.onLoadMore}.
24
+ *
25
+ * @param props - The tasks, the server "now", the repo filter, the cumulative
26
+ * shown count, and the load-more callback.
27
+ * @returns The done list element.
28
+ */
29
+ export declare function DoneList({ tasks, nowMs, selectedRepo, doneShown, onLoadMore, }: DoneListProps): ReactElement;
@@ -0,0 +1,50 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Fragment } from "react";
3
+ import { dayBucket, formatActiveDuration, formatRelative, matchesRepo, statusLabel, } from "../logic.js";
4
+ import { Territory } from "./Territory.js";
5
+ /** Stable identity for a task across renders (the payload has no task id). */
6
+ const taskKey = (t) => `${t.agentName}|${t.repo}|${t.createdAt}`;
7
+ /** Whether the board is showing all repos (so cards carry a repo tag). */
8
+ const isAllRepos = (selected) => selected === null || selected === "__all__";
9
+ /**
10
+ * The Done column. Ported from app.js `renderDone`. Filters tasks to
11
+ * non-active + selected-repo, then renders the cumulative `[0, doneShown]`
12
+ * slice under day-bucket headers ({@link dayBucket} on `endedAt ?? createdAt`).
13
+ * A dropped task gets a `task__stat--drop` chip and a "went offline … — no done
14
+ * signal" meta; a done task gets a "done" chip and a "finished … · active Nm"
15
+ * meta. When more finished tasks exist than are shown, a "Load older · N of M"
16
+ * button calls {@link DoneListProps.onLoadMore}.
17
+ *
18
+ * @param props - The tasks, the server "now", the repo filter, the cumulative
19
+ * shown count, and the load-more callback.
20
+ * @returns The done list element.
21
+ */
22
+ export function DoneList({ tasks, nowMs, selectedRepo, doneShown, onLoadMore, }) {
23
+ const allRepos = isAllRepos(selectedRepo);
24
+ const done = tasks.filter((t) => t.status !== "active" && matchesRepo(t, selectedRepo));
25
+ if (done.length === 0) {
26
+ const msg = allRepos
27
+ ? "No finished tasks yet."
28
+ : `No finished tasks in ${selectedRepo} yet.`;
29
+ return (_jsx("div", { id: "done-list", children: _jsx("div", { className: "empty", children: msg }) }));
30
+ }
31
+ const page = done.slice(0, doneShown);
32
+ let lastDay = null;
33
+ return (_jsxs("div", { id: "done-list", children: [page.map((t) => {
34
+ const day = dayBucket(t.endedAt ?? t.createdAt, nowMs);
35
+ const header = day !== lastDay ? day : null;
36
+ lastDay = day;
37
+ const dropped = t.status === "dropped";
38
+ const meta = dropped
39
+ ? `went offline ${formatRelative(t.endedAt ?? t.createdAt, nowMs)} — no done signal`
40
+ : `finished ${formatRelative(t.endedAt ?? t.createdAt, nowMs)}${formatActiveDuration(t.createdAt, t.endedAt)
41
+ ? " · " + formatActiveDuration(t.createdAt, t.endedAt)
42
+ : ""}`;
43
+ return (_jsxs(Fragment, { children: [header !== null && _jsx("div", { className: "day", children: header }), _jsxs("div", { className: "task", children: [_jsxs("div", { className: "task__r1", children: [_jsx("span", { className: "task__who", children: t.agentName }), allRepos && _jsx("span", { className: "task__repo", children: t.repo }), _jsx("span", { className: "task__stat" + (dropped ? " task__stat--drop" : ""), children: statusLabel(t.status) })] }), _jsx("div", { className: "task__intent", children: t.intent }), _jsx(Territory, { globs: t.pathGlobs }), _jsx("div", { className: "task__meta", children: meta })] })] }, taskKey(t)));
44
+ }), done.length > doneShown && (_jsx("div", { className: "more", role: "button", tabIndex: 0, onClick: onLoadMore, onKeyDown: (e) => {
45
+ if (e.key === "Enter" || e.key === " ") {
46
+ e.preventDefault();
47
+ onLoadMore();
48
+ }
49
+ }, children: `Load older · ${doneShown} of ${done.length}` }))] }));
50
+ }
@@ -0,0 +1,38 @@
1
+ import type { ReactElement } from "react";
2
+ /**
3
+ * Per-repo task tallies surfaced in the menu rows. The `"__all__"` key carries
4
+ * the board-wide aggregate shown on the "All repos" option.
5
+ */
6
+ export interface RepoCounts {
7
+ /** Active task count for the repo (or aggregate under `"__all__"`). */
8
+ active: number;
9
+ /** Finished task count for the repo (or aggregate under `"__all__"`). */
10
+ done: number;
11
+ }
12
+ /** Props for {@link RepoSelect}. */
13
+ export interface RepoSelectProps {
14
+ /** The distinct repos present on the board (sorted by the caller). */
15
+ repos: string[];
16
+ /** Per-repo `{active,done}` tallies, plus an `"__all__"` aggregate row. */
17
+ counts: Record<string, RepoCounts>;
18
+ /** The selected repo, or `null` for All repos. */
19
+ selected: string | null;
20
+ /** Called with the chosen repo, or `null` when "All repos" is picked. */
21
+ onSelect: (repo: string | null) => void;
22
+ }
23
+ /**
24
+ * The repo filter: a click-to-open menu of repos plus an "All repos" option,
25
+ * each showing its `"N active · N done"` tally. Ported from app.js
26
+ * `renderRepoSelect` — same markup, classes, and listbox/option roles so it
27
+ * stays keyboard- and screen-reader-operable.
28
+ *
29
+ * Hidden entirely when fewer than two repos exist (nothing to filter). Owns its
30
+ * own open/close state; the trigger toggles it and Escape closes it. Choosing a
31
+ * repo calls `onSelect(repo)`; choosing "All repos" calls `onSelect(null)` —
32
+ * matching app.js, which then stores the sentinel `"__all__"` for All repos.
33
+ *
34
+ * @param props - Repos, per-repo counts, the current selection, and the
35
+ * selection callback.
36
+ * @returns The repo selector, or `null` when there are fewer than two repos.
37
+ */
38
+ export declare function RepoSelect({ repos, counts, selected, onSelect, }: RepoSelectProps): ReactElement | null;
@@ -0,0 +1,56 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useRef, useState } from "react";
3
+ const EMPTY = { active: 0, done: 0 };
4
+ /**
5
+ * The repo filter: a click-to-open menu of repos plus an "All repos" option,
6
+ * each showing its `"N active · N done"` tally. Ported from app.js
7
+ * `renderRepoSelect` — same markup, classes, and listbox/option roles so it
8
+ * stays keyboard- and screen-reader-operable.
9
+ *
10
+ * Hidden entirely when fewer than two repos exist (nothing to filter). Owns its
11
+ * own open/close state; the trigger toggles it and Escape closes it. Choosing a
12
+ * repo calls `onSelect(repo)`; choosing "All repos" calls `onSelect(null)` —
13
+ * matching app.js, which then stores the sentinel `"__all__"` for All repos.
14
+ *
15
+ * @param props - Repos, per-repo counts, the current selection, and the
16
+ * selection callback.
17
+ * @returns The repo selector, or `null` when there are fewer than two repos.
18
+ */
19
+ export function RepoSelect({ repos, counts, selected, onSelect, }) {
20
+ const [open, setOpen] = useState(false);
21
+ const hostRef = useRef(null);
22
+ // Move focus into the freshly-opened menu (the selected option, else the
23
+ // first) so keyboard users can Tab the options and Esc out — only on open.
24
+ useEffect(() => {
25
+ if (!open)
26
+ return;
27
+ const menu = hostRef.current?.querySelector(".repo-menu");
28
+ if (!menu)
29
+ return;
30
+ const target = menu.querySelector('[aria-selected="true"]') ??
31
+ menu.querySelector("button");
32
+ target?.focus();
33
+ }, [open]);
34
+ if (repos.length < 2)
35
+ return null;
36
+ const isAll = selected === null || selected === "__all__";
37
+ const label = isAll ? "All repos" : selected;
38
+ /** A single menu option; `repo === null` is the "All repos" row. */
39
+ const renderItem = (repo, text) => {
40
+ const c = repo === null ? counts.__all__ ?? EMPTY : counts[repo] ?? EMPTY;
41
+ const isOn = repo === null ? isAll : selected === repo;
42
+ const cls = "repo-mi" + (isOn ? " on" : "") + (repo === null ? " all" : "");
43
+ return (_jsxs("button", { type: "button", className: cls, role: "option", "aria-selected": isOn, onClick: (e) => {
44
+ e.stopPropagation();
45
+ setOpen(false);
46
+ onSelect(repo);
47
+ }, children: [_jsx("span", { children: text }), _jsx("span", { className: "ct", children: `${c.active} active · ${c.done} done` })] }, repo ?? "__all__"));
48
+ };
49
+ return (_jsxs("span", { ref: hostRef, className: "repo", onKeyDown: (e) => {
50
+ if (e.key === "Escape")
51
+ setOpen(false);
52
+ }, children: [_jsxs("button", { type: "button", className: "repo-trig", "aria-haspopup": "listbox", "aria-expanded": open, "aria-label": `Filter by repo (current: ${label})`, onClick: (e) => {
53
+ e.stopPropagation();
54
+ setOpen((o) => !o);
55
+ }, children: [_jsx("span", { className: "slash", children: "/" }), " " + label + " ", _jsx("span", { children: "\u25BC" })] }), open && (_jsxs("div", { className: "repo-menu", role: "listbox", children: [repos.map((r) => renderItem(r, r)), renderItem(null, "All repos")] }))] }));
56
+ }
@@ -0,0 +1,21 @@
1
+ import type { ReactElement } from "react";
2
+ /** Props for {@link Territory}. */
3
+ export interface TerritoryProps {
4
+ /** The claim's path globs. */
5
+ globs: string[];
6
+ }
7
+ /**
8
+ * A claim's territory pills. Ported from app.js `taskTerritory`. A single path
9
+ * (or none) renders inline under a "territory" label; two or more collapse into
10
+ * a native `<details>` whose summary reads `"N paths"` and expands to the full
11
+ * glob list.
12
+ *
13
+ * Unlike app.js the open/closed state is left to the browser's native
14
+ * `<details>` rather than persisted across polls: React reconciles the same
15
+ * element across re-renders (no `replaceChildren` rebuild), so the fold state
16
+ * survives without manual bookkeeping.
17
+ *
18
+ * @param props - The globs to display.
19
+ * @returns The territory element (inline or a collapsible `<details>`).
20
+ */
21
+ export declare function Territory({ globs }: TerritoryProps): ReactElement;
@@ -0,0 +1,21 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * A claim's territory pills. Ported from app.js `taskTerritory`. A single path
4
+ * (or none) renders inline under a "territory" label; two or more collapse into
5
+ * a native `<details>` whose summary reads `"N paths"` and expands to the full
6
+ * glob list.
7
+ *
8
+ * Unlike app.js the open/closed state is left to the browser's native
9
+ * `<details>` rather than persisted across polls: React reconciles the same
10
+ * element across re-renders (no `replaceChildren` rebuild), so the fold state
11
+ * survives without manual bookkeeping.
12
+ *
13
+ * @param props - The globs to display.
14
+ * @returns The territory element (inline or a collapsible `<details>`).
15
+ */
16
+ export function Territory({ globs }) {
17
+ if (globs.length <= 1) {
18
+ return (_jsxs("div", { className: "terr", children: [_jsx("span", { className: "terr__lbl", children: "territory" }), globs.map((g) => (_jsx("span", { className: "glob", children: g }, g)))] }));
19
+ }
20
+ return (_jsxs("details", { className: "terrx", children: [_jsxs("summary", { children: [_jsx("span", { className: "terr__lbl", children: "territory" }), _jsxs("span", { className: "glob glob--more", children: [_jsx("span", { className: "tw", children: "\u25B6" }), ` ${globs.length} paths`] })] }), _jsx("div", { className: "terr terr--full", children: globs.map((g) => (_jsx("span", { className: "glob", children: g }, g))) })] }));
21
+ }
@@ -0,0 +1,10 @@
1
+ export interface ConnectAgentProps {
2
+ workspaceId: string;
3
+ /**
4
+ * The DIRECT Hub URL the agent connects to (public Cloud Run URL when hosted).
5
+ * Defaults to the dashboard client's baseUrl, which is correct for self-host
6
+ * where the agent and the dashboard share the Hub origin.
7
+ */
8
+ hubUrl?: string;
9
+ }
10
+ export declare function ConnectAgent({ workspaceId, hubUrl }: ConnectAgentProps): import("react").JSX.Element;
@@ -0,0 +1,119 @@
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
+ const TOOLS = [
6
+ { id: "claude", label: "Claude Code" },
7
+ { id: "codex", label: "Codex" },
8
+ { id: "pi", label: "Pi" },
9
+ { id: "generic", label: "Generic (JSON)" },
10
+ ];
11
+ // The token placeholder shown before a real token is minted. Switching tools or
12
+ // reading the command pre-mint shows this, never a real secret.
13
+ const TOKEN_PLACEHOLDER = "shp_<paste-after-generating>";
14
+ // The CLI tools differ only in the first line of the `mcp add` invocation; the
15
+ // rest of the command (the env flags and the npx tail) is shared.
16
+ const CLI_PREFIX = {
17
+ claude: "claude mcp add shepherd -s user \\",
18
+ codex: "codex mcp add shepherd \\",
19
+ pi: "pi mcp add shepherd \\",
20
+ };
21
+ function installCommand(tool, hubUrl, token) {
22
+ if (tool === "generic") {
23
+ return JSON.stringify({
24
+ mcpServers: {
25
+ shepherd: {
26
+ command: "npx",
27
+ args: ["-y", "@korso/shepherd"],
28
+ env: { HUB_URL: hubUrl, SHEPHERD_TOKEN: token },
29
+ },
30
+ },
31
+ }, null, 2);
32
+ }
33
+ return [
34
+ CLI_PREFIX[tool],
35
+ ` -e HUB_URL=${hubUrl} \\`,
36
+ ` -e SHEPHERD_TOKEN=${token} \\`,
37
+ " -- npx -y @korso/shepherd",
38
+ ].join("\n");
39
+ }
40
+ export function ConnectAgent({ workspaceId, hubUrl }) {
41
+ const client = useShepherdClient();
42
+ const directHubUrl = hubUrl ?? client.baseUrl;
43
+ const headingId = useId();
44
+ const [tool, setTool] = useState("claude");
45
+ const [name, setName] = useState("");
46
+ // The raw token from the most recent mint — shown once, then only as command.
47
+ const [rawToken, setRawToken] = useState(null);
48
+ const [tokens, setTokens] = useState([]);
49
+ const [error, setError] = useState(null);
50
+ const [status, setStatus] = useState(null);
51
+ const [busy, setBusy] = useState(false);
52
+ // True until the first loadTokens() resolves, so the management list shows a
53
+ // loading placeholder rather than the "No tokens yet." empty state.
54
+ const [loading, setLoading] = useState(true);
55
+ // Per-row in-flight revoke guard (by token id) to block double-submit.
56
+ const [revokingId, setRevokingId] = useState(null);
57
+ async function loadTokens() {
58
+ try {
59
+ const res = await client.listTokens(workspaceId);
60
+ setTokens(res.tokens);
61
+ }
62
+ catch (err) {
63
+ setError(describeError(err));
64
+ }
65
+ finally {
66
+ setLoading(false);
67
+ }
68
+ }
69
+ useEffect(() => {
70
+ setLoading(true);
71
+ void loadTokens();
72
+ // eslint-disable-next-line react-hooks/exhaustive-deps
73
+ }, [client, workspaceId]);
74
+ // The component is re-rendered (not remounted) across a workspace switch, so
75
+ // clear the once-shown raw token so it never leaks into another workspace's
76
+ // install command (mirrors Workspaces' setInvite(null) reset).
77
+ useEffect(() => {
78
+ setRawToken(null);
79
+ }, [workspaceId]);
80
+ async function generate() {
81
+ setBusy(true);
82
+ setError(null);
83
+ try {
84
+ const res = await client.mintToken(workspaceId, name.trim() ? { name: name.trim() } : {});
85
+ setRawToken(res.token);
86
+ setName("");
87
+ await loadTokens();
88
+ }
89
+ catch (err) {
90
+ setError(describeError(err));
91
+ }
92
+ finally {
93
+ setBusy(false);
94
+ }
95
+ }
96
+ async function revoke(tokenId) {
97
+ if (revokingId)
98
+ return;
99
+ setRevokingId(tokenId);
100
+ setError(null);
101
+ setStatus(null);
102
+ try {
103
+ await client.revokeToken(workspaceId, tokenId);
104
+ setTokens((prev) => prev.filter((t) => t.id !== tokenId));
105
+ setStatus("Token revoked");
106
+ }
107
+ catch (err) {
108
+ setError(describeError(err));
109
+ }
110
+ finally {
111
+ setRevokingId(null);
112
+ }
113
+ }
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
+ }) }))] }));
119
+ }
@@ -0,0 +1 @@
1
+ export {};