@korso/shepherd-ui 0.1.1 → 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/README.md +47 -47
- 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 +209 -182
- 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 +13 -13
- 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 +67 -67
- package/dist/lib/Dashboard-CB6SL-J2.js +0 -1064
- package/dist/selfhost/assets/index-BBx3vo2Y.js +0 -40
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ReactElement } from "react";
|
|
2
|
+
import type { WorkspaceTaskT } from "@shepherd/shared";
|
|
3
|
+
/** Props for {@link ActiveList}. */
|
|
4
|
+
export interface ActiveListProps {
|
|
5
|
+
/** The board's tasks; only active ones in the selected repo are shown. */
|
|
6
|
+
tasks: WorkspaceTaskT[];
|
|
7
|
+
/** The server-clock "now" in epoch ms, for relative time labels. */
|
|
8
|
+
nowMs: number;
|
|
9
|
+
/** The selected repo, or `null`/`"__all__"` for all repos. */
|
|
10
|
+
selectedRepo: string | null;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* The Active column. Ported from app.js `renderActive`. Filters tasks to active
|
|
14
|
+
* + selected-repo, groups them by agent via {@link groupActiveClaims}, then
|
|
15
|
+
* renders a {@link PlainCard} for a lone claim or a {@link GroupCard} otherwise.
|
|
16
|
+
* Shows an empty-state message when nothing is active. In All-repos mode each
|
|
17
|
+
* card carries a repo tag; in single-repo mode it does not.
|
|
18
|
+
*
|
|
19
|
+
* @param props - The tasks, the server "now", and the repo filter.
|
|
20
|
+
* @returns The active list element.
|
|
21
|
+
*/
|
|
22
|
+
export declare function ActiveList({ tasks, nowMs, selectedRepo, }: ActiveListProps): ReactElement;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { formatRelative, groupActiveClaims, matchesRepo, } from "../logic.js";
|
|
3
|
+
import { Territory } from "./Territory.js";
|
|
4
|
+
/** Stable identity for a claim across renders (the payload has no claim id). */
|
|
5
|
+
const claimKey = (t) => `${t.agentName}|${t.repo}|${t.createdAt}`;
|
|
6
|
+
/** Whether the board is showing all repos (so cards carry a repo tag). */
|
|
7
|
+
const isAllRepos = (selected) => selected === null || selected === "__all__";
|
|
8
|
+
/**
|
|
9
|
+
* One active claim's body: intent + territory + a "started N ago" meta line.
|
|
10
|
+
* Shared by the plain card and each row of a grouped card (app.js `claimBody`).
|
|
11
|
+
*/
|
|
12
|
+
function ClaimBody({ task, nowMs, }) {
|
|
13
|
+
return (_jsxs(_Fragment, { children: [_jsx("div", { className: "task__intent", children: task.intent }), _jsx(Territory, { globs: task.pathGlobs }), _jsx("div", { className: "task__meta", children: `started ${formatRelative(task.createdAt, nowMs)}` })] }));
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* A lone agent/claim rendered as the classic single card (app.js `plainCard`):
|
|
17
|
+
* the agent, an optional model/program tag, a live dot, then the claim body.
|
|
18
|
+
*/
|
|
19
|
+
function PlainCard({ task, nowMs, allRepos, }) {
|
|
20
|
+
const tag = task.model || task.program;
|
|
21
|
+
return (_jsxs("div", { className: "task", children: [_jsxs("div", { className: "task__r1", children: [_jsx("span", { className: "task__who", children: task.agentName }), allRepos && _jsx("span", { className: "task__repo", children: task.repo }), tag && _jsx("span", { className: "task__tag", children: tag }), _jsx("span", { className: "livedot", title: "active" })] }), _jsx(ClaimBody, { task: task, nowMs: nowMs })] }));
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* An agent with multiple live claims rendered grouped (app.js `groupCard`): a
|
|
25
|
+
* header, one `.claim` per primary, and a collapsible `"+N narrower claim(s)"`
|
|
26
|
+
* fold for claims a broader sibling fully covers (folded, never hidden).
|
|
27
|
+
*/
|
|
28
|
+
function GroupCard({ group, nowMs, allRepos, }) {
|
|
29
|
+
const tag = group.model || group.program;
|
|
30
|
+
const total = group.primaries.length + group.narrower.length;
|
|
31
|
+
const n = group.narrower.length;
|
|
32
|
+
return (_jsxs("div", { className: "grp", children: [_jsxs("div", { className: "grp__head", children: [_jsx("span", { className: "grp__who", children: group.agentName }), allRepos && _jsx("span", { className: "task__repo", children: group.repo }), tag && _jsx("span", { className: "grp__tag", children: tag }), _jsx("span", { className: "grp__count", children: `· ${total} active` }), _jsx("span", { className: "grp__dot", title: "live" })] }), _jsx("div", { className: "claims", children: group.primaries.map((c) => (_jsx("div", { className: "claim", children: _jsx(ClaimBody, { task: c, nowMs: nowMs }) }, claimKey(c)))) }), n > 0 && (_jsxs("details", { className: "fold", children: [_jsxs("summary", { children: [_jsx("span", { className: "tw", children: "\u25B6" }), ` +${n} narrower claim${n > 1 ? "s" : ""}`] }), _jsx("div", { className: "fold__body", children: group.narrower.map((c) => (_jsxs("div", { className: "claim", children: [_jsx(ClaimBody, { task: c, nowMs: nowMs }), _jsx("div", { className: "covered", children: "\u2282 covered by a claim above" })] }, claimKey(c)))) })] }))] }));
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The Active column. Ported from app.js `renderActive`. Filters tasks to active
|
|
36
|
+
* + selected-repo, groups them by agent via {@link groupActiveClaims}, then
|
|
37
|
+
* renders a {@link PlainCard} for a lone claim or a {@link GroupCard} otherwise.
|
|
38
|
+
* Shows an empty-state message when nothing is active. In All-repos mode each
|
|
39
|
+
* card carries a repo tag; in single-repo mode it does not.
|
|
40
|
+
*
|
|
41
|
+
* @param props - The tasks, the server "now", and the repo filter.
|
|
42
|
+
* @returns The active list element.
|
|
43
|
+
*/
|
|
44
|
+
export function ActiveList({ tasks, nowMs, selectedRepo, }) {
|
|
45
|
+
const allRepos = isAllRepos(selectedRepo);
|
|
46
|
+
const active = tasks.filter((t) => t.status === "active" && matchesRepo(t, selectedRepo));
|
|
47
|
+
if (active.length === 0) {
|
|
48
|
+
const msg = allRepos
|
|
49
|
+
? "Nothing active right now."
|
|
50
|
+
: `Nothing active in ${selectedRepo}.`;
|
|
51
|
+
return (_jsx("div", { id: "active-list", children: _jsx("div", { className: "empty", children: msg }) }));
|
|
52
|
+
}
|
|
53
|
+
return (_jsx("div", { id: "active-list", children: groupActiveClaims(active).map((g) => {
|
|
54
|
+
const single = g.primaries.length === 1 && g.narrower.length === 0;
|
|
55
|
+
// Key on the group's newest claim — stable across polls per agent.
|
|
56
|
+
const key = claimKey(g.primaries.concat(g.narrower)[0]);
|
|
57
|
+
return single ? (_jsx(PlainCard, { task: g.primaries[0], nowMs: nowMs, allRepos: allRepos }, key)) : (_jsx(GroupCard, { group: g, nowMs: nowMs, allRepos: allRepos }, key));
|
|
58
|
+
}) }));
|
|
59
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type { WorkspaceAnnouncementT } from "@shepherd/shared";
|
|
3
|
+
/** Props for {@link Chat}. */
|
|
4
|
+
export interface ChatProps {
|
|
5
|
+
/** The workspace announcement feed, newest-first as the API returns it. */
|
|
6
|
+
announcements: WorkspaceAnnouncementT[];
|
|
7
|
+
/** The board's selected repo; `null`/`"__all__"` show every repo's messages. */
|
|
8
|
+
selectedRepo: string | null;
|
|
9
|
+
/** The server clock in epoch ms, so "N ago" matches the rest of the board. */
|
|
10
|
+
nowMs: number;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* The group-chat view of the workspace announcement feed (the "Chat" tab).
|
|
14
|
+
*
|
|
15
|
+
* The API returns announcements newest-first, but a chat reads oldest -> newest
|
|
16
|
+
* top -> bottom, so the list is reversed before render; messages are filtered to
|
|
17
|
+
* the selected repo. Each row mirrors app.js `renderAnnouncements`: an avatar
|
|
18
|
+
* (initials + deterministic color), the sender, an optional "→ @target" /
|
|
19
|
+
* "→ admin" header, the relative time, and the body. Operator messages
|
|
20
|
+
* (`fromAdmin`) get `msg--me` (right-aligned); directed or to-admin messages get
|
|
21
|
+
* `msg--targeted`.
|
|
22
|
+
*
|
|
23
|
+
* The viewer stays pinned to the newest message UNLESS they have scrolled up to
|
|
24
|
+
* read history — measured against the scroll container before each re-render and
|
|
25
|
+
* re-applied in a layout effect so the write happens before paint (no flicker).
|
|
26
|
+
*
|
|
27
|
+
* @param props - The feed, the repo filter, and the server clock.
|
|
28
|
+
* @returns The scrollable chat element.
|
|
29
|
+
*/
|
|
30
|
+
export declare function Chat({ announcements, selectedRepo, nowMs }: ChatProps): ReactNode;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useLayoutEffect, useRef } from "react";
|
|
3
|
+
import { colorForName, formatRelative, initialsFor, matchesRepo, } from "../logic.js";
|
|
4
|
+
/**
|
|
5
|
+
* How close (in px) to the bottom still counts as "pinned to the newest
|
|
6
|
+
* message". Ported verbatim from app.js `chatIsNearBottom` — a small slack so a
|
|
7
|
+
* reader who is essentially at the bottom keeps following new messages, while
|
|
8
|
+
* one who has scrolled up to read history is left where they are.
|
|
9
|
+
*/
|
|
10
|
+
const NEAR_BOTTOM_PX = 80;
|
|
11
|
+
/**
|
|
12
|
+
* The group-chat view of the workspace announcement feed (the "Chat" tab).
|
|
13
|
+
*
|
|
14
|
+
* The API returns announcements newest-first, but a chat reads oldest -> newest
|
|
15
|
+
* top -> bottom, so the list is reversed before render; messages are filtered to
|
|
16
|
+
* the selected repo. Each row mirrors app.js `renderAnnouncements`: an avatar
|
|
17
|
+
* (initials + deterministic color), the sender, an optional "→ @target" /
|
|
18
|
+
* "→ admin" header, the relative time, and the body. Operator messages
|
|
19
|
+
* (`fromAdmin`) get `msg--me` (right-aligned); directed or to-admin messages get
|
|
20
|
+
* `msg--targeted`.
|
|
21
|
+
*
|
|
22
|
+
* The viewer stays pinned to the newest message UNLESS they have scrolled up to
|
|
23
|
+
* read history — measured against the scroll container before each re-render and
|
|
24
|
+
* re-applied in a layout effect so the write happens before paint (no flicker).
|
|
25
|
+
*
|
|
26
|
+
* @param props - The feed, the repo filter, and the server clock.
|
|
27
|
+
* @returns The scrollable chat element.
|
|
28
|
+
*/
|
|
29
|
+
export function Chat({ announcements, selectedRepo, nowMs }) {
|
|
30
|
+
const chatRef = useRef(null);
|
|
31
|
+
// Capture "were we near the bottom?" BEFORE React commits the new DOM, so the
|
|
32
|
+
// layout effect can decide whether to re-pin. A ref (not state) because it's a
|
|
33
|
+
// pre-paint measurement, never rendered.
|
|
34
|
+
const stickRef = useRef(true);
|
|
35
|
+
const c = chatRef.current;
|
|
36
|
+
if (c) {
|
|
37
|
+
stickRef.current = c.scrollHeight - c.scrollTop - c.clientHeight < NEAR_BOTTOM_PX;
|
|
38
|
+
}
|
|
39
|
+
// The API returns newest-first; a chat reads oldest -> newest, top -> bottom.
|
|
40
|
+
// Only show messages whose repo matches the current selection.
|
|
41
|
+
const messages = [...announcements]
|
|
42
|
+
.filter((a) => matchesRepo(a, selectedRepo))
|
|
43
|
+
.reverse();
|
|
44
|
+
// Keep the viewer pinned to the newest message unless they've scrolled up to
|
|
45
|
+
// read history. useLayoutEffect so the scroll write lands before paint.
|
|
46
|
+
useLayoutEffect(() => {
|
|
47
|
+
if (stickRef.current && chatRef.current) {
|
|
48
|
+
chatRef.current.scrollTop = chatRef.current.scrollHeight;
|
|
49
|
+
}
|
|
50
|
+
}, [messages.length]);
|
|
51
|
+
return (_jsx("div", { id: "chat", className: "chat", ref: chatRef, children: messages.length === 0 ? (_jsx("div", { className: "empty", children: "No announcements yet \u2014 agents will post here as they coordinate." })) : (messages.map((a, i) => {
|
|
52
|
+
const targeted = a.targetAgentName !== null || a.toAdmin;
|
|
53
|
+
const className = "msg" +
|
|
54
|
+
(targeted ? " msg--targeted" : "") +
|
|
55
|
+
(a.fromAdmin ? " msg--me" : "");
|
|
56
|
+
return (
|
|
57
|
+
// The feed has no stable id; index is acceptable because the list is
|
|
58
|
+
// append-only oldest->newest and rows are never reordered in place.
|
|
59
|
+
_jsxs("div", { className: className, children: [_jsx("div", { className: "msg__avatar", style: { background: colorForName(a.fromAgentName) }, children: initialsFor(a.fromAgentName) }), _jsxs("div", { className: "msg__body", children: [_jsxs("div", { className: "msg__head", children: [_jsx("span", { className: "msg__who", style: { color: colorForName(a.fromAgentName) }, children: a.fromAgentName }), a.fromHuman ? _jsx("span", { className: "msg__human", children: a.fromHuman }) : null, a.targetAgentName !== null ? (_jsx("span", { className: "msg__to", children: `→ @${a.targetAgentName}` })) : a.toAdmin ? (_jsx("span", { className: "msg__to", children: "\u2192 admin" })) : null, _jsx("span", { className: "msg__time", children: formatRelative(a.createdAt, nowMs) })] }), _jsx("div", { className: "msg__text", children: a.body })] })] }, i));
|
|
60
|
+
})) }));
|
|
61
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type { WorkspaceAgentT } from "@shepherd/shared";
|
|
3
|
+
/** Props for {@link Composer}. */
|
|
4
|
+
export interface ComposerProps {
|
|
5
|
+
/** Workspace agents; the live ones in the selected repo drive @-autocomplete. */
|
|
6
|
+
agents: WorkspaceAgentT[];
|
|
7
|
+
/** The board's selected repo; `null`/`"__all__"` broadcast to every repo. */
|
|
8
|
+
selectedRepo: string | null;
|
|
9
|
+
/**
|
|
10
|
+
* Workspace to post into. When given, submit goes through the plural
|
|
11
|
+
* `announceTo(id, …)` route; when omitted it uses the singular self-host
|
|
12
|
+
* `announce(…)` alias, so an unscoped composer is unchanged.
|
|
13
|
+
*/
|
|
14
|
+
workspaceId?: string;
|
|
15
|
+
/** Invoked after a successful send so the host can refresh the feed. */
|
|
16
|
+
onSent: () => void | Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* The operator's message composer (the "Chat" tab footer). A controlled input
|
|
20
|
+
* with an @-mention autocomplete and a send button, ported from app.js
|
|
21
|
+
* `setupComposer` + `renderPop`.
|
|
22
|
+
*
|
|
23
|
+
* As the operator types, the token immediately left of the caret is parsed; if
|
|
24
|
+
* it is an `@mention`, a `role="listbox"` of live agents in the selected repo
|
|
25
|
+
* opens. ArrowUp/ArrowDown move the highlight, Enter/Tab (or a mousedown on a
|
|
26
|
+
* row) accept it — inserting `"@Name "` — and Escape closes it. On submit a
|
|
27
|
+
* blank body is ignored; otherwise the first mention matching a known agent
|
|
28
|
+
* directs the message (`targetAgentName`) and the selected repo scopes a
|
|
29
|
+
* broadcast (`null` in All-repos mode). The send button is disabled in-flight;
|
|
30
|
+
* on success the input clears and `onSent` is awaited, on failure a
|
|
31
|
+
* "send failed — retry" status shows and the button re-enables. The client comes
|
|
32
|
+
* from context, keeping the composer auth-agnostic.
|
|
33
|
+
*
|
|
34
|
+
* @param props - The agents, repo filter, and post-send callback.
|
|
35
|
+
* @returns The composer element (autocomplete popup + form).
|
|
36
|
+
*/
|
|
37
|
+
export declare function Composer({ agents, selectedRepo, workspaceId, onSent }: ComposerProps): ReactNode;
|
|
@@ -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,34 @@
|
|
|
1
|
+
import type { ReactElement } 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
|
+
/**
|
|
13
|
+
* The Shepherd wallboard shell. Composes {@link useLandscapePolling} (which reads
|
|
14
|
+
* the client from context, keeping this auth-agnostic) with the six leaf
|
|
15
|
+
* components, owning only the UI state app.js kept in module variables:
|
|
16
|
+
*
|
|
17
|
+
* - `activeTab` — persisted to `"shepherd.tab"` (default `"tasks"`).
|
|
18
|
+
* - `selectedRepo` — persisted to `"shepherd.repo"`; `null` derives a default on
|
|
19
|
+
* the first render with >=2 repos (see {@link resolveSelectedRepo}).
|
|
20
|
+
* - `doneShown` — the history page size, grown a page at a time by "Load older".
|
|
21
|
+
*
|
|
22
|
+
* Header chrome mirrors app.js: brand, the repo filter, vitals (online/active
|
|
23
|
+
* counts under the current filter), the poll status indicator, a freshness
|
|
24
|
+
* string, and the tab strip. The body shows the Tasks panel (crew + active +
|
|
25
|
+
* done) or the Chat panel (feed + composer); the composer's post-send hook is the
|
|
26
|
+
* polling hook's `refresh`, so a sent message shows up immediately.
|
|
27
|
+
*
|
|
28
|
+
* Renders the header even before the first snapshot arrives so the board never
|
|
29
|
+
* blanks — the panels simply render their own empty states off an empty
|
|
30
|
+
* landscape.
|
|
31
|
+
*
|
|
32
|
+
* @returns The dashboard element.
|
|
33
|
+
*/
|
|
34
|
+
export declare function Dashboard({ workspaceId }?: DashboardProps): ReactElement;
|