@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.
- package/dist/ShepherdRoot.d.ts +9 -0
- package/dist/ShepherdRoot.js +46 -0
- package/dist/client.d.ts +107 -0
- package/dist/client.js +238 -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 +48 -0
- package/dist/components/Dashboard.js +280 -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/EmptyState.d.ts +12 -0
- package/dist/config/EmptyState.js +8 -0
- package/dist/config/Members.d.ts +10 -0
- package/dist/config/Members.js +83 -0
- package/dist/config/Workspaces.d.ts +12 -0
- package/dist/config/Workspaces.js +96 -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.d.ts +18 -0
- package/dist/index.js +15 -0
- package/dist/lib/Dashboard-B7nioe5N.js +1286 -0
- package/dist/lib/index.d.ts +452 -16
- package/dist/lib/index.js +372 -6
- package/dist/lib/selfhost.js +9 -9
- package/dist/lib/styles.css +35 -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-D6rTsbVM.js +40 -0
- package/dist/selfhost/assets/{index-tSyzirZa.css → index-DnhlP_lc.css} +1 -1
- 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/useLandscapePolling.d.ts +70 -0
- package/dist/useLandscapePolling.js +134 -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,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;
|