@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.
- package/dist/ShepherdProvider.d.ts +12 -0
- package/dist/ShepherdProvider.js +33 -0
- package/dist/ShepherdProvider.test.d.ts +1 -0
- package/dist/ShepherdProvider.test.js +47 -0
- package/dist/ShepherdRoot.d.ts +9 -0
- package/dist/ShepherdRoot.js +88 -0
- package/dist/ShepherdRoot.routing.test.d.ts +1 -0
- package/dist/ShepherdRoot.routing.test.js +61 -0
- package/dist/ShepherdRoot.test.d.ts +1 -0
- package/dist/ShepherdRoot.test.js +37 -0
- package/dist/app/assets/index-CDOCIg6s.js +11 -0
- package/dist/app/index.html +12 -0
- package/dist/client.d.ts +107 -0
- package/dist/client.js +238 -0
- package/dist/client.test.d.ts +1 -0
- package/dist/client.test.js +298 -0
- package/dist/components/ActiveList.d.ts +22 -0
- package/dist/components/ActiveList.js +59 -0
- package/dist/components/Chat.d.ts +30 -0
- package/dist/components/Chat.js +61 -0
- package/dist/components/Composer.d.ts +37 -0
- package/dist/components/Composer.js +146 -0
- package/dist/components/Crew.d.ts +23 -0
- package/dist/components/Crew.js +31 -0
- package/dist/components/Dashboard.d.ts +34 -0
- package/dist/components/Dashboard.js +209 -0
- package/dist/components/DoneList.d.ts +29 -0
- package/dist/components/DoneList.js +50 -0
- package/dist/components/RepoSelect.d.ts +38 -0
- package/dist/components/RepoSelect.js +56 -0
- package/dist/components/Territory.d.ts +21 -0
- package/dist/components/Territory.js +21 -0
- package/dist/config/ConnectAgent.d.ts +10 -0
- package/dist/config/ConnectAgent.js +119 -0
- package/dist/config/ConnectAgent.test.d.ts +1 -0
- package/dist/config/ConnectAgent.test.js +92 -0
- package/dist/config/EmptyState.d.ts +12 -0
- package/dist/config/EmptyState.js +4 -0
- package/dist/config/EmptyState.test.d.ts +1 -0
- package/dist/config/EmptyState.test.js +32 -0
- package/dist/config/Members.d.ts +10 -0
- package/dist/config/Members.js +83 -0
- package/dist/config/Members.test.d.ts +1 -0
- package/dist/config/Members.test.js +66 -0
- package/dist/config/Workspaces.d.ts +12 -0
- package/dist/config/Workspaces.js +96 -0
- package/dist/config/Workspaces.test.d.ts +1 -0
- package/dist/config/Workspaces.test.js +90 -0
- package/dist/config/index.d.ts +8 -0
- package/dist/config/index.js +6 -0
- package/dist/context.d.ts +28 -0
- package/dist/context.js +36 -0
- package/dist/index.cjs +5289 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +149 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/Dashboard-CG6KeFQ3.js +1214 -0
- package/dist/lib/index.d.ts +438 -16
- package/dist/lib/index.js +455 -6
- package/dist/lib/selfhost.js +9 -9
- package/dist/lib/styles.css +27 -0
- package/dist/logic.d.ts +208 -0
- package/dist/logic.js +372 -0
- package/dist/main.d.ts +1 -0
- package/dist/main.js +11 -0
- package/dist/selfhost/assets/{index-tSyzirZa.css → index-BZmImfYZ.css} +1 -1
- package/dist/selfhost/assets/index-CS268KSw.js +40 -0
- package/dist/selfhost/index.html +2 -2
- package/dist/selfhost.d.ts +22 -0
- package/dist/selfhost.js +97 -0
- package/dist/test/mockClient.d.ts +6 -0
- package/dist/test/mockClient.js +45 -0
- package/dist/test/setup.d.ts +1 -0
- package/dist/test/setup.js +2 -0
- package/dist/useLandscapePolling.d.ts +63 -0
- package/dist/useLandscapePolling.js +129 -0
- package/dist/views/Chat.d.ts +5 -0
- package/dist/views/Chat.js +60 -0
- package/dist/views/Chat.test.d.ts +1 -0
- package/dist/views/Chat.test.js +92 -0
- package/dist/views/Config.d.ts +12 -0
- package/dist/views/Config.js +6 -0
- package/dist/views/Tasks.d.ts +5 -0
- package/dist/views/Tasks.js +75 -0
- package/dist/views/Tasks.test.d.ts +1 -0
- package/dist/views/Tasks.test.js +115 -0
- package/dist/views/useLandscape.d.ts +14 -0
- package/dist/views/useLandscape.js +74 -0
- package/dist/views/useLandscape.test.d.ts +1 -0
- package/dist/views/useLandscape.test.js +101 -0
- package/dist/views/wallboard.d.ts +46 -0
- package/dist/views/wallboard.js +209 -0
- package/dist/views/wallboard.test.d.ts +1 -0
- package/dist/views/wallboard.test.js +143 -0
- package/package.json +1 -1
- package/dist/lib/Dashboard-CB6SL-J2.js +0 -1064
- 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 {};
|