@korso/shepherd-ui 0.1.1 → 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 (55) hide show
  1. package/dist/ShepherdRoot.d.ts +9 -0
  2. package/dist/ShepherdRoot.js +46 -0
  3. package/dist/client.d.ts +107 -0
  4. package/dist/client.js +238 -0
  5. package/dist/components/ActiveList.d.ts +22 -0
  6. package/dist/components/ActiveList.js +59 -0
  7. package/dist/components/Chat.d.ts +30 -0
  8. package/dist/components/Chat.js +61 -0
  9. package/dist/components/Composer.d.ts +37 -0
  10. package/dist/components/Composer.js +146 -0
  11. package/dist/components/Crew.d.ts +23 -0
  12. package/dist/components/Crew.js +31 -0
  13. package/dist/components/Dashboard.d.ts +48 -0
  14. package/dist/components/Dashboard.js +280 -0
  15. package/dist/components/DoneList.d.ts +29 -0
  16. package/dist/components/DoneList.js +50 -0
  17. package/dist/components/RepoSelect.d.ts +38 -0
  18. package/dist/components/RepoSelect.js +56 -0
  19. package/dist/components/Territory.d.ts +21 -0
  20. package/dist/components/Territory.js +21 -0
  21. package/dist/config/ConnectAgent.d.ts +10 -0
  22. package/dist/config/ConnectAgent.js +119 -0
  23. package/dist/config/EmptyState.d.ts +12 -0
  24. package/dist/config/EmptyState.js +8 -0
  25. package/dist/config/Members.d.ts +10 -0
  26. package/dist/config/Members.js +83 -0
  27. package/dist/config/Workspaces.d.ts +12 -0
  28. package/dist/config/Workspaces.js +96 -0
  29. package/dist/config/index.d.ts +8 -0
  30. package/dist/config/index.js +6 -0
  31. package/dist/context.d.ts +28 -0
  32. package/dist/context.js +36 -0
  33. package/dist/index.d.ts +18 -0
  34. package/dist/index.js +15 -0
  35. package/dist/lib/Dashboard-B7nioe5N.js +1286 -0
  36. package/dist/lib/index.d.ts +452 -16
  37. package/dist/lib/index.js +372 -6
  38. package/dist/lib/selfhost.js +9 -9
  39. package/dist/lib/styles.css +35 -0
  40. package/dist/logic.d.ts +208 -0
  41. package/dist/logic.js +372 -0
  42. package/dist/main.d.ts +1 -0
  43. package/dist/main.js +11 -0
  44. package/dist/selfhost/assets/index-D6rTsbVM.js +40 -0
  45. package/dist/selfhost/assets/{index-tSyzirZa.css → index-DnhlP_lc.css} +1 -1
  46. package/dist/selfhost/index.html +2 -2
  47. package/dist/selfhost.d.ts +22 -0
  48. package/dist/selfhost.js +97 -0
  49. package/dist/test/mockClient.d.ts +6 -0
  50. package/dist/test/mockClient.js +45 -0
  51. package/dist/useLandscapePolling.d.ts +70 -0
  52. package/dist/useLandscapePolling.js +134 -0
  53. package/package.json +1 -1
  54. package/dist/lib/Dashboard-CB6SL-J2.js +0 -1064
  55. package/dist/selfhost/assets/index-BBx3vo2Y.js +0 -40
@@ -0,0 +1,146 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useCallback, useRef, useState } from "react";
3
+ import { useShepherdClient } from "../context.js";
4
+ import { colorForName, extractTarget, initialsFor, mentionableAgents, parseMention, } from "../logic.js";
5
+ /** How many autocomplete suggestions to show — ported from app.js `.slice(0, 8)`. */
6
+ const MAX_SUGGESTIONS = 8;
7
+ /**
8
+ * The operator's message composer (the "Chat" tab footer). A controlled input
9
+ * with an @-mention autocomplete and a send button, ported from app.js
10
+ * `setupComposer` + `renderPop`.
11
+ *
12
+ * As the operator types, the token immediately left of the caret is parsed; if
13
+ * it is an `@mention`, a `role="listbox"` of live agents in the selected repo
14
+ * opens. ArrowUp/ArrowDown move the highlight, Enter/Tab (or a mousedown on a
15
+ * row) accept it — inserting `"@Name "` — and Escape closes it. On submit a
16
+ * blank body is ignored; otherwise the first mention matching a known agent
17
+ * directs the message (`targetAgentName`) and the selected repo scopes a
18
+ * broadcast (`null` in All-repos mode). The send button is disabled in-flight;
19
+ * on success the input clears and `onSent` is awaited, on failure a
20
+ * "send failed — retry" status shows and the button re-enables. The client comes
21
+ * from context, keeping the composer auth-agnostic.
22
+ *
23
+ * @param props - The agents, repo filter, and post-send callback.
24
+ * @returns The composer element (autocomplete popup + form).
25
+ */
26
+ export function Composer({ agents, selectedRepo, workspaceId, onSent }) {
27
+ const client = useShepherdClient();
28
+ const inputRef = useRef(null);
29
+ const [value, setValue] = useState("");
30
+ const [items, setItems] = useState([]);
31
+ const [active, setActive] = useState(0);
32
+ const [range, setRange] = useState(null);
33
+ const [sending, setSending] = useState(false);
34
+ const [failed, setFailed] = useState(false);
35
+ // The names addressable under the current filter — the same crew the board
36
+ // shows for the selected repo. Recomputed each render; cheap for board sizes.
37
+ // An agent with no session repo (repo === null) coalesces to "" so it never
38
+ // equals a real repo (mentionable only in All-repos, via matchesRepo's
39
+ // short-circuit) — exactly the original app.js behavior.
40
+ const names = mentionableAgents(agents.map((a) => ({ name: a.name, presence: a.presence, repo: a.repo ?? "" })), selectedRepo);
41
+ const popOpen = items.length > 0;
42
+ const closePop = useCallback(() => {
43
+ setItems([]);
44
+ setRange(null);
45
+ }, []);
46
+ /**
47
+ * Recompute the autocomplete from the caret position: open it on an active
48
+ * @mention (filtered by the live crew), else close it. Mirrors `refreshPop`.
49
+ */
50
+ const refreshPop = useCallback((nextValue, caret) => {
51
+ const mention = parseMention(nextValue, caret);
52
+ if (!mention) {
53
+ closePop();
54
+ return;
55
+ }
56
+ const q = mention.query.toLowerCase();
57
+ const next = names
58
+ .filter((n) => n.toLowerCase().startsWith(q))
59
+ .slice(0, MAX_SUGGESTIONS);
60
+ setItems(next);
61
+ setRange({ start: mention.start, end: mention.end });
62
+ setActive(0);
63
+ }, [names, closePop]);
64
+ /** Replace the @-token with `"@Name "` and place the caret after it. */
65
+ const accept = useCallback((name) => {
66
+ if (!range)
67
+ return;
68
+ const insert = "@" + name + " ";
69
+ const next = value.slice(0, range.start) + insert + value.slice(range.end);
70
+ const caret = range.start + insert.length;
71
+ setValue(next);
72
+ closePop();
73
+ // Restore focus + caret after React commits the new value.
74
+ requestAnimationFrame(() => {
75
+ const el = inputRef.current;
76
+ if (el) {
77
+ el.focus();
78
+ el.setSelectionRange(caret, caret);
79
+ }
80
+ });
81
+ }, [range, value, closePop]);
82
+ const onInput = useCallback((next) => {
83
+ setValue(next);
84
+ const caret = inputRef.current?.selectionStart ?? next.length;
85
+ refreshPop(next, caret);
86
+ }, [refreshPop]);
87
+ const onKeyDown = useCallback((e) => {
88
+ if (!popOpen)
89
+ return;
90
+ if (e.key === "ArrowDown") {
91
+ e.preventDefault();
92
+ setActive((a) => (a + 1) % items.length);
93
+ }
94
+ else if (e.key === "ArrowUp") {
95
+ e.preventDefault();
96
+ setActive((a) => (a - 1 + items.length) % items.length);
97
+ }
98
+ else if (e.key === "Enter" || e.key === "Tab") {
99
+ e.preventDefault();
100
+ accept(items[active]);
101
+ }
102
+ else if (e.key === "Escape") {
103
+ e.preventDefault();
104
+ closePop();
105
+ }
106
+ }, [popOpen, items, active, accept, closePop]);
107
+ const onSubmit = useCallback(async (e) => {
108
+ e.preventDefault();
109
+ const body = value.trim();
110
+ if (!body)
111
+ return;
112
+ // The first @mention matching a live agent directs the message; otherwise
113
+ // it broadcasts to the selected repo (null repo => fan out to all repos).
114
+ const targetAgentName = extractTarget(body, names);
115
+ const repo = selectedRepo === null || selectedRepo === "__all__" ? null : selectedRepo;
116
+ setSending(true);
117
+ setFailed(false);
118
+ try {
119
+ // A scoped composer posts to the workspace-scoped route; otherwise the
120
+ // singular self-host alias. Same request body either way.
121
+ const req = { body, targetAgentName, repo };
122
+ await (workspaceId !== undefined
123
+ ? client.announceTo(workspaceId, req)
124
+ : client.announce(req));
125
+ setValue("");
126
+ closePop();
127
+ await onSent();
128
+ }
129
+ catch {
130
+ // A 401 is handled by the client's onUnauthorized (self-host); the
131
+ // composer just shows the rejected state and keeps the body for retry.
132
+ setFailed(true);
133
+ }
134
+ finally {
135
+ setSending(false);
136
+ }
137
+ }, [value, names, selectedRepo, workspaceId, client, closePop, onSent]);
138
+ return (_jsxs("div", { className: "composer", children: [_jsx("div", { id: "mention-pop", className: "mention-pop", role: "listbox", "aria-label": "Agents to mention", hidden: !popOpen, children: items.map((name, i) => (_jsxs("button", { id: `mention-opt-${i}`, type: "button", role: "option", "aria-selected": i === active, className: "mention-mi" + (i === active ? " on" : ""),
139
+ // mousedown fires before the input blurs, so the click still lands.
140
+ onMouseDown: (e) => {
141
+ e.preventDefault();
142
+ accept(name);
143
+ }, children: [_jsx("div", { className: "ma", style: { background: colorForName(name) }, children: initialsFor(name) }), _jsx("span", { children: name })] }, name))) }), _jsxs("form", { id: "chat-form", className: "chat-form", onSubmit: (e) => {
144
+ void onSubmit(e);
145
+ }, children: [_jsx("input", { id: "chat-input", className: "chat-input", type: "text", autoComplete: "off", "aria-label": "Message the team", placeholder: "Message the team\u2026 use @name to direct it", role: "combobox", "aria-expanded": popOpen, "aria-controls": "mention-pop", "aria-autocomplete": "list", "aria-activedescendant": popOpen ? `mention-opt-${active}` : undefined, ref: inputRef, value: value, onChange: (e) => onInput(e.target.value), onKeyDown: onKeyDown }), _jsx("button", { id: "chat-send", className: "chat-send", type: "submit", disabled: sending, children: "Send" })] }), failed ? (_jsx("div", { className: "chat__note", role: "status", children: "send failed \u2014 retry" })) : null] }));
146
+ }
@@ -0,0 +1,23 @@
1
+ import type { ReactElement } from "react";
2
+ import type { WorkspaceAgentT, WorkspaceTaskT } from "@shepherd/shared";
3
+ /** Props for {@link Crew}. */
4
+ export interface CrewProps {
5
+ /** All known agents; only live ones in the selected repo are shown. */
6
+ agents: WorkspaceAgentT[];
7
+ /** Active/history tasks, used to mark which agents are currently working. */
8
+ tasks: WorkspaceTaskT[];
9
+ /** The selected repo, or `null`/`"__all__"` for all repos. */
10
+ selectedRepo: string | null;
11
+ }
12
+ /**
13
+ * The crew rail: live agents in the current repo view. Ported from app.js
14
+ * `renderCrew`. An agent is "active" when they own an active task in view;
15
+ * active agents sort first, then alphabetical, and idle (non-active) agents are
16
+ * greyed via `.person--idle`. Each avatar shows {@link initialsFor} on a
17
+ * {@link colorForName} background.
18
+ *
19
+ * @param props - The agents, the tasks that determine activity, and the repo
20
+ * filter.
21
+ * @returns The crew rail element.
22
+ */
23
+ export declare function Crew({ agents, tasks, selectedRepo }: CrewProps): ReactElement;
@@ -0,0 +1,31 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { colorForName, initialsFor, matchesRepo } from "../logic.js";
3
+ /**
4
+ * The crew rail: live agents in the current repo view. Ported from app.js
5
+ * `renderCrew`. An agent is "active" when they own an active task in view;
6
+ * active agents sort first, then alphabetical, and idle (non-active) agents are
7
+ * greyed via `.person--idle`. Each avatar shows {@link initialsFor} on a
8
+ * {@link colorForName} background.
9
+ *
10
+ * @param props - The agents, the tasks that determine activity, and the repo
11
+ * filter.
12
+ * @returns The crew rail element.
13
+ */
14
+ export function Crew({ agents, tasks, selectedRepo }) {
15
+ const live = agents.filter((a) => a.presence === "live" && matchesRepo({ repo: a.repo ?? "" }, selectedRepo));
16
+ // An agent is "active" iff they own an active task — mirrors app.js, which
17
+ // builds an intent-by-agent map from the active tasks.
18
+ const activeAgents = new Set();
19
+ for (const t of tasks)
20
+ if (t.status === "active")
21
+ activeAgents.add(t.agentName);
22
+ const isActive = (a) => activeAgents.has(a.name);
23
+ const ordered = [...live].sort((x, y) => {
24
+ const ax = isActive(x);
25
+ const ay = isActive(y);
26
+ if (ax !== ay)
27
+ return ax ? -1 : 1; // active agents first
28
+ return x.name.localeCompare(y.name);
29
+ });
30
+ return (_jsx("div", { className: "crew", id: "crew", children: ordered.map((a) => (_jsxs("div", { className: "person" + (isActive(a) ? "" : " person--idle"), children: [_jsx("div", { className: "avatar", style: { background: colorForName(a.name) }, children: initialsFor(a.name) }), _jsx("span", { className: "person__name", children: a.name })] }, a.name))) }));
31
+ }
@@ -0,0 +1,48 @@
1
+ import type { ReactElement, ReactNode } from "react";
2
+ /** Props for {@link Dashboard}. The client comes from context. */
3
+ export interface DashboardProps {
4
+ /**
5
+ * Workspace to scope the board to. When given, the polling hook hits the
6
+ * workspace-scoped `landscape(id)` route and the composer posts via
7
+ * `announceTo(id, …)`; when omitted, both fall back to the self-host singular
8
+ * aliases, so a no-id render is unchanged.
9
+ */
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;
25
+ }
26
+ /**
27
+ * The Shepherd wallboard shell. Composes {@link useLandscapePolling} (which reads
28
+ * the client from context, keeping this auth-agnostic) with the six leaf
29
+ * components, owning only the UI state app.js kept in module variables:
30
+ *
31
+ * - `activeTab` — persisted to `"shepherd.tab"` (default `"tasks"`).
32
+ * - `selectedRepo` — persisted to `"shepherd.repo"`; `null` derives a default on
33
+ * the first render with >=2 repos (see {@link resolveSelectedRepo}).
34
+ * - `doneShown` — the history page size, grown a page at a time by "Load older".
35
+ *
36
+ * Header chrome mirrors app.js: brand, the repo filter, vitals (online/active
37
+ * counts under the current filter), the poll status indicator, a freshness
38
+ * string, and the tab strip. The body shows the Tasks panel (crew + active +
39
+ * done) or the Chat panel (feed + composer); the composer's post-send hook is the
40
+ * polling hook's `refresh`, so a sent message shows up immediately.
41
+ *
42
+ * Renders the header even before the first snapshot arrives so the board never
43
+ * blanks — the panels simply render their own empty states off an empty
44
+ * landscape.
45
+ *
46
+ * @returns The dashboard element.
47
+ */
48
+ export declare function Dashboard({ workspaceId, config, hasWorkspace, }?: DashboardProps): ReactElement;
@@ -0,0 +1,280 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } 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
+ import { EmptyState } from "../config/EmptyState.js";
12
+ /**
13
+ * localStorage keys + page size, ported verbatim from the state block of
14
+ * packages/hub/public/app.js (POLL_MS lives in the hook).
15
+ */
16
+ const TAB_KEY = "shepherd.tab";
17
+ const REPO_KEY = "shepherd.repo";
18
+ const DONE_PAGE = 10;
19
+ /**
20
+ * Maps the polling hook's {@link LandscapeStatus} onto the chrome text + modifier
21
+ * class the original board's `setStatus` produced. Kept as data so the header
22
+ * stays a thin render of the hook's state.
23
+ */
24
+ const STATUS_VIEW = {
25
+ live: { text: "live", kind: "ok" },
26
+ reconnecting: { text: "reconnecting…", kind: "warn" },
27
+ // app.js cleared the token and re-prompted on 401; in the auth-agnostic port
28
+ // the injected client's onUnauthorized owns any token handling, so the board
29
+ // only surfaces the rejected state.
30
+ unauthorized: { text: "token rejected", kind: "error" },
31
+ };
32
+ /** localStorage read that never throws (private/quota modes return null). */
33
+ function readStored(key) {
34
+ try {
35
+ return localStorage.getItem(key);
36
+ }
37
+ catch {
38
+ return null;
39
+ }
40
+ }
41
+ /** localStorage write that never throws — persistence is best-effort chrome. */
42
+ function writeStored(key, value) {
43
+ try {
44
+ localStorage.setItem(key, value);
45
+ }
46
+ catch {
47
+ // Ignore: a board that can't persist its filter still works this session.
48
+ }
49
+ }
50
+ /**
51
+ * Resolves the repo to actually filter by for this render, reproducing the
52
+ * three side-effecting rules inside app.js's `render()` in order:
53
+ *
54
+ * 1. `null` (never chosen) + >=2 repos -> derive the newest-active default.
55
+ * 2. a persisted specific repo that has VANISHED from the data -> "__all__"
56
+ * (so a workspace that narrowed to one repo can't strand the board on an
57
+ * absent repo — the selector itself hides under 2 repos).
58
+ * 3. ONLY on first render, a persisted specific repo with no active work while
59
+ * another repo has some -> prefer the newest-active default, so a returning
60
+ * viewer never lands on an empty Active column. In-session picks are kept.
61
+ *
62
+ * Pure given its inputs; the caller commits the result back to state.
63
+ *
64
+ * @param selectedRepo - The current selection (`null` = not yet chosen).
65
+ * @param snapshot - The landscape to resolve against.
66
+ * @param firstRender - Whether this is the first render with a snapshot.
67
+ * @returns The repo to use this render (`"__all__"` or a specific repo, never
68
+ * `null` once >=2 repos exist with no prior choice).
69
+ */
70
+ function resolveSelectedRepo(selectedRepo, snapshot, firstRender) {
71
+ const repos = distinctRepos(snapshot.tasks);
72
+ let next = selectedRepo;
73
+ if (next === null && repos.length >= 2) {
74
+ next = defaultRepo(snapshot.tasks);
75
+ }
76
+ if (next !== null && next !== "__all__" && !repos.includes(next)) {
77
+ next = "__all__";
78
+ }
79
+ if (firstRender && next !== null && next !== "__all__") {
80
+ const pinnedHasActive = snapshot.tasks.some((t) => t.status === "active" && t.repo === next);
81
+ const anyActive = snapshot.tasks.some((t) => t.status === "active");
82
+ if (!pinnedHasActive && anyActive)
83
+ next = defaultRepo(snapshot.tasks);
84
+ }
85
+ return next;
86
+ }
87
+ /**
88
+ * Per-repo `{active,done}` tallies plus the board-wide `"__all__"` aggregate,
89
+ * mirroring app.js's `counts` map (each task bumps its own repo AND the
90
+ * aggregate). The shape RepoSelect's `counts` prop expects.
91
+ *
92
+ * @param snapshot - The landscape whose tasks are tallied.
93
+ * @returns A record keyed by repo, with an `"__all__"` aggregate row.
94
+ */
95
+ function computeCounts(snapshot) {
96
+ const counts = {};
97
+ const bump = (repo, key) => {
98
+ const c = (counts[repo] ??= { active: 0, done: 0 });
99
+ c[key]++;
100
+ };
101
+ for (const t of snapshot.tasks) {
102
+ const key = t.status === "active" ? "active" : "done";
103
+ bump(t.repo, key);
104
+ bump("__all__", key);
105
+ }
106
+ return counts;
107
+ }
108
+ /**
109
+ * The Shepherd wallboard shell. Composes {@link useLandscapePolling} (which reads
110
+ * the client from context, keeping this auth-agnostic) with the six leaf
111
+ * components, owning only the UI state app.js kept in module variables:
112
+ *
113
+ * - `activeTab` — persisted to `"shepherd.tab"` (default `"tasks"`).
114
+ * - `selectedRepo` — persisted to `"shepherd.repo"`; `null` derives a default on
115
+ * the first render with >=2 repos (see {@link resolveSelectedRepo}).
116
+ * - `doneShown` — the history page size, grown a page at a time by "Load older".
117
+ *
118
+ * Header chrome mirrors app.js: brand, the repo filter, vitals (online/active
119
+ * counts under the current filter), the poll status indicator, a freshness
120
+ * string, and the tab strip. The body shows the Tasks panel (crew + active +
121
+ * done) or the Chat panel (feed + composer); the composer's post-send hook is the
122
+ * polling hook's `refresh`, so a sent message shows up immediately.
123
+ *
124
+ * Renders the header even before the first snapshot arrives so the board never
125
+ * blanks — the panels simply render their own empty states off an empty
126
+ * landscape.
127
+ *
128
+ * @returns The dashboard element.
129
+ */
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]);
197
+ // `null` = not yet chosen (derive a default on first render); "__all__" = All
198
+ // repos; else a specific repo. Seeded from storage exactly like app.js.
199
+ const [selectedRepo, setSelectedRepo] = useState(() => readStored(REPO_KEY));
200
+ const [doneShown, setDoneShown] = useState(DONE_PAGE);
201
+ // First-load guard for resolveSelectedRepo's rule 3 — a persisted repo is only
202
+ // second-guessed once, then in-session selections are respected (a ref because
203
+ // flipping it must not itself trigger a render).
204
+ const firstRenderRef = useRef(true);
205
+ // No local 1s "freshness" timer: useLandscapePolling already runs its own 1s
206
+ // tick that re-renders this component, so `freshness` (computed from Date.now()
207
+ // below) refreshes every second on that re-render. A second interval here would
208
+ // only duplicate the work.
209
+ const onTab = useCallback((tab) => {
210
+ setActiveTab(tab);
211
+ writeStored(TAB_KEY, tab);
212
+ }, []);
213
+ const onSelectRepo = useCallback((repo) => {
214
+ // app.js stores the "__all__" sentinel for All repos (RepoSelect reports null).
215
+ const next = repo === null ? "__all__" : repo;
216
+ setSelectedRepo(next);
217
+ writeStored(REPO_KEY, next);
218
+ setDoneShown(DONE_PAGE); // a new filter resets the history page
219
+ }, []);
220
+ const onLoadMore = useCallback(() => {
221
+ setDoneShown((n) => n + DONE_PAGE);
222
+ }, []);
223
+ // Resolve the effective repo for this render. When the resolver derives a
224
+ // value different from state (first-load default / vanished repo), commit it so
225
+ // the selection is sticky and persisted — done in an effect to avoid a setState
226
+ // during render. `firstRenderRef` is consumed (flipped) once a snapshot exists.
227
+ const effectiveRepo = snapshot
228
+ ? resolveSelectedRepo(selectedRepo, snapshot, firstRenderRef.current)
229
+ : selectedRepo;
230
+ useEffect(() => {
231
+ if (!snapshot)
232
+ return;
233
+ firstRenderRef.current = false;
234
+ if (effectiveRepo !== selectedRepo) {
235
+ setSelectedRepo(effectiveRepo);
236
+ // Persist a derived *concrete* default so the next visit is stable (a
237
+ // documented superset of app.js, which wrote REPO_KEY only on an explicit
238
+ // pick). Do NOT persist the "__all__" vanished-repo fallback: it is a
239
+ // transient reaction to a repo briefly dropping out of one poll, and
240
+ // writing it would permanently clobber the viewer's saved specific filter
241
+ // even after that repo reappears. Keeping it in-state only restores
242
+ // app.js's recover-the-saved-repo-on-reload behavior.
243
+ if (effectiveRepo !== null && effectiveRepo !== "__all__") {
244
+ writeStored(REPO_KEY, effectiveRepo);
245
+ }
246
+ }
247
+ }, [snapshot, effectiveRepo, selectedRepo]);
248
+ const nowMs = snapshot ? Date.parse(snapshot.serverTime) : Date.now();
249
+ const repos = snapshot ? distinctRepos(snapshot.tasks) : [];
250
+ const counts = snapshot
251
+ ? computeCounts(snapshot)
252
+ : {};
253
+ // Vitals: live agents + active tasks under the current filter (matchesRepo
254
+ // treats null/"__all__" as all), mirroring app.js's `online`/`active`.
255
+ const online = snapshot
256
+ ? snapshot.agents.filter((a) => a.presence === "live" && matchesRepo({ repo: a.repo ?? "" }, effectiveRepo)).length
257
+ : 0;
258
+ const active = snapshot
259
+ ? snapshot.tasks.filter((t) => t.status === "active" && matchesRepo(t, effectiveRepo)).length
260
+ : 0;
261
+ const statusView = STATUS_VIEW[status];
262
+ const freshness = lastUpdatedMs === null
263
+ ? ""
264
+ : `updated ${Math.floor((Date.now() - lastUpdatedMs) / 1000)}s ago`;
265
+ const agents = snapshot?.agents ?? [];
266
+ const tasks = snapshot?.tasks ?? [];
267
+ const announcements = snapshot?.announcements ?? [];
268
+ // Column-head badges, ported from `renderActive`/`renderDone` which set
269
+ // `#active-count`/`#done-count` to the filtered LIST lengths. activeCount
270
+ // equals the `active` vital here (same filter), but doneCount is distinct.
271
+ const activeCount = tasks.filter((t) => t.status === "active" && matchesRepo(t, effectiveRepo)).length;
272
+ const doneCount = tasks.filter((t) => t.status !== "active" && matchesRepo(t, effectiveRepo)).length;
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 }))] }));
280
+ }
@@ -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;