@korso/shepherd-ui 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ShepherdProvider.d.ts +12 -0
- package/dist/ShepherdProvider.js +33 -0
- package/dist/ShepherdProvider.test.d.ts +1 -0
- package/dist/ShepherdProvider.test.js +47 -0
- package/dist/ShepherdRoot.d.ts +9 -0
- package/dist/ShepherdRoot.js +88 -0
- package/dist/ShepherdRoot.routing.test.d.ts +1 -0
- package/dist/ShepherdRoot.routing.test.js +61 -0
- package/dist/ShepherdRoot.test.d.ts +1 -0
- package/dist/ShepherdRoot.test.js +37 -0
- package/dist/app/assets/index-CDOCIg6s.js +11 -0
- package/dist/app/index.html +12 -0
- package/dist/client.d.ts +107 -0
- package/dist/client.js +238 -0
- package/dist/client.test.d.ts +1 -0
- package/dist/client.test.js +298 -0
- package/dist/components/ActiveList.d.ts +22 -0
- package/dist/components/ActiveList.js +59 -0
- package/dist/components/Chat.d.ts +30 -0
- package/dist/components/Chat.js +61 -0
- package/dist/components/Composer.d.ts +37 -0
- package/dist/components/Composer.js +146 -0
- package/dist/components/Crew.d.ts +23 -0
- package/dist/components/Crew.js +31 -0
- package/dist/components/Dashboard.d.ts +34 -0
- package/dist/components/Dashboard.js +209 -0
- package/dist/components/DoneList.d.ts +29 -0
- package/dist/components/DoneList.js +50 -0
- package/dist/components/RepoSelect.d.ts +38 -0
- package/dist/components/RepoSelect.js +56 -0
- package/dist/components/Territory.d.ts +21 -0
- package/dist/components/Territory.js +21 -0
- package/dist/config/ConnectAgent.d.ts +10 -0
- package/dist/config/ConnectAgent.js +119 -0
- package/dist/config/ConnectAgent.test.d.ts +1 -0
- package/dist/config/ConnectAgent.test.js +92 -0
- package/dist/config/EmptyState.d.ts +12 -0
- package/dist/config/EmptyState.js +4 -0
- package/dist/config/EmptyState.test.d.ts +1 -0
- package/dist/config/EmptyState.test.js +32 -0
- package/dist/config/Members.d.ts +10 -0
- package/dist/config/Members.js +83 -0
- package/dist/config/Members.test.d.ts +1 -0
- package/dist/config/Members.test.js +66 -0
- package/dist/config/Workspaces.d.ts +12 -0
- package/dist/config/Workspaces.js +96 -0
- package/dist/config/Workspaces.test.d.ts +1 -0
- package/dist/config/Workspaces.test.js +90 -0
- package/dist/config/index.d.ts +8 -0
- package/dist/config/index.js +6 -0
- package/dist/context.d.ts +28 -0
- package/dist/context.js +36 -0
- package/dist/index.cjs +5289 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +149 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/Dashboard-CG6KeFQ3.js +1214 -0
- package/dist/lib/index.d.ts +438 -16
- package/dist/lib/index.js +455 -6
- package/dist/lib/selfhost.js +9 -9
- package/dist/lib/styles.css +27 -0
- package/dist/logic.d.ts +208 -0
- package/dist/logic.js +372 -0
- package/dist/main.d.ts +1 -0
- package/dist/main.js +11 -0
- package/dist/selfhost/assets/{index-tSyzirZa.css → index-BZmImfYZ.css} +1 -1
- package/dist/selfhost/assets/index-CS268KSw.js +40 -0
- package/dist/selfhost/index.html +2 -2
- package/dist/selfhost.d.ts +22 -0
- package/dist/selfhost.js +97 -0
- package/dist/test/mockClient.d.ts +6 -0
- package/dist/test/mockClient.js +45 -0
- package/dist/test/setup.d.ts +1 -0
- package/dist/test/setup.js +2 -0
- package/dist/useLandscapePolling.d.ts +63 -0
- package/dist/useLandscapePolling.js +129 -0
- package/dist/views/Chat.d.ts +5 -0
- package/dist/views/Chat.js +60 -0
- package/dist/views/Chat.test.d.ts +1 -0
- package/dist/views/Chat.test.js +92 -0
- package/dist/views/Config.d.ts +12 -0
- package/dist/views/Config.js +6 -0
- package/dist/views/Tasks.d.ts +5 -0
- package/dist/views/Tasks.js +75 -0
- package/dist/views/Tasks.test.d.ts +1 -0
- package/dist/views/Tasks.test.js +115 -0
- package/dist/views/useLandscape.d.ts +14 -0
- package/dist/views/useLandscape.js +74 -0
- package/dist/views/useLandscape.test.d.ts +1 -0
- package/dist/views/useLandscape.test.js +101 -0
- package/dist/views/wallboard.d.ts +46 -0
- package/dist/views/wallboard.js +209 -0
- package/dist/views/wallboard.test.d.ts +1 -0
- package/dist/views/wallboard.test.js +143 -0
- package/package.json +1 -1
- package/dist/lib/Dashboard-CB6SL-J2.js +0 -1064
- package/dist/selfhost/assets/index-BBx3vo2Y.js +0 -40
package/dist/selfhost/index.html
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<title>Shepherd</title>
|
|
7
|
-
<script type="module" crossorigin src="/assets/index-
|
|
8
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
7
|
+
<script type="module" crossorigin src="/assets/index-CS268KSw.js"></script>
|
|
8
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BZmImfYZ.css">
|
|
9
9
|
</head>
|
|
10
10
|
<body>
|
|
11
11
|
<div id="root"></div>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ReactElement } from "react";
|
|
2
|
+
/**
|
|
3
|
+
* The self-host SPA root and the ONE place a team token is handled — the named
|
|
4
|
+
* auth exception to the auth-agnostic core. Everything below the provider (the
|
|
5
|
+
* client, the polling hook, the {@link Dashboard}) stays token-blind; this
|
|
6
|
+
* component owns the token's lifecycle:
|
|
7
|
+
*
|
|
8
|
+
* - It seeds token state from `localStorage["shepherd.token"]` (back-compat with
|
|
9
|
+
* the legacy board), so a returning viewer skips the gate.
|
|
10
|
+
* - With NO token it renders the {@link Gate}; a successful submit persists the
|
|
11
|
+
* token and re-renders into the dashboard.
|
|
12
|
+
* - With a token it builds a same-origin client (`baseUrl: ""`) that injects the
|
|
13
|
+
* `Authorization: Bearer <token>` header and, on a 401, clears the stored
|
|
14
|
+
* token and resets state — which re-renders straight back to the gate. This is
|
|
15
|
+
* the React analogue of app.js's `clearToken()` + re-prompt 401 flow.
|
|
16
|
+
*
|
|
17
|
+
* The client is memoised on the token so it is rebuilt only when the token
|
|
18
|
+
* actually changes, not on every freshness re-render of the dashboard subtree.
|
|
19
|
+
*
|
|
20
|
+
* @returns Either the token gate or the authenticated dashboard.
|
|
21
|
+
*/
|
|
22
|
+
export declare function SelfHostApp(): ReactElement;
|
package/dist/selfhost.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useMemo, useState } from "react";
|
|
3
|
+
import { createShepherdClient } from "./client.js";
|
|
4
|
+
import { ShepherdClientProvider } from "./context.js";
|
|
5
|
+
import { Dashboard } from "./components/Dashboard.js";
|
|
6
|
+
/**
|
|
7
|
+
* localStorage key the operator's team token is stored under. Kept identical to
|
|
8
|
+
* the legacy vanilla board (packages/hub/public/app.js's `TOKEN_KEY`) so a
|
|
9
|
+
* viewer who already authenticated against the old board stays signed in after
|
|
10
|
+
* the React cutover — no re-prompt on upgrade.
|
|
11
|
+
*/
|
|
12
|
+
const TOKEN_KEY = "shepherd.token";
|
|
13
|
+
/** localStorage read that never throws (private/quota modes return null). */
|
|
14
|
+
function readToken() {
|
|
15
|
+
try {
|
|
16
|
+
return localStorage.getItem(TOKEN_KEY) ?? "";
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return "";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The token gate, ported from the `.gate` markup in packages/hub/public/index.html.
|
|
24
|
+
* Rendered whenever no token is stored; submitting a non-empty token persists it
|
|
25
|
+
* and lifts the gate via {@link onSubmit}.
|
|
26
|
+
*
|
|
27
|
+
* @param props.onSubmit - Called with the entered token once the form is submitted.
|
|
28
|
+
*/
|
|
29
|
+
function Gate({ onSubmit }) {
|
|
30
|
+
const [value, setValue] = useState("");
|
|
31
|
+
const handle = (e) => {
|
|
32
|
+
e.preventDefault();
|
|
33
|
+
const trimmed = value.trim();
|
|
34
|
+
// Ignore an empty submit so the gate can't store a blank token and then
|
|
35
|
+
// bounce the viewer straight back to itself on the first 401.
|
|
36
|
+
if (trimmed !== "")
|
|
37
|
+
onSubmit(trimmed);
|
|
38
|
+
};
|
|
39
|
+
return (_jsxs("section", { id: "gate", className: "gate", children: [_jsx("h1", { children: "Shepherd" }), _jsx("p", { children: "Enter the team token to view the workspace." }), _jsxs("form", { id: "gate-form", onSubmit: handle, children: [_jsx("input", { id: "gate-input", type: "password", placeholder: "Team token", "aria-label": "Team token", autoComplete: "off", value: value, onChange: (e) => setValue(e.target.value) }), _jsx("button", { type: "submit", children: "View" })] })] }));
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The self-host SPA root and the ONE place a team token is handled — the named
|
|
43
|
+
* auth exception to the auth-agnostic core. Everything below the provider (the
|
|
44
|
+
* client, the polling hook, the {@link Dashboard}) stays token-blind; this
|
|
45
|
+
* component owns the token's lifecycle:
|
|
46
|
+
*
|
|
47
|
+
* - It seeds token state from `localStorage["shepherd.token"]` (back-compat with
|
|
48
|
+
* the legacy board), so a returning viewer skips the gate.
|
|
49
|
+
* - With NO token it renders the {@link Gate}; a successful submit persists the
|
|
50
|
+
* token and re-renders into the dashboard.
|
|
51
|
+
* - With a token it builds a same-origin client (`baseUrl: ""`) that injects the
|
|
52
|
+
* `Authorization: Bearer <token>` header and, on a 401, clears the stored
|
|
53
|
+
* token and resets state — which re-renders straight back to the gate. This is
|
|
54
|
+
* the React analogue of app.js's `clearToken()` + re-prompt 401 flow.
|
|
55
|
+
*
|
|
56
|
+
* The client is memoised on the token so it is rebuilt only when the token
|
|
57
|
+
* actually changes, not on every freshness re-render of the dashboard subtree.
|
|
58
|
+
*
|
|
59
|
+
* @returns Either the token gate or the authenticated dashboard.
|
|
60
|
+
*/
|
|
61
|
+
export function SelfHostApp() {
|
|
62
|
+
const [token, setToken] = useState(readToken);
|
|
63
|
+
// Rebuild the client only when the token changes. WHY same-origin (baseUrl
|
|
64
|
+
// ""): the hub serves this SPA, so requests hit the hub's own origin and need
|
|
65
|
+
// no cross-origin base. onUnauthorized clears the token + resets state, which
|
|
66
|
+
// re-renders back to the gate — the automatic 401 re-gate.
|
|
67
|
+
const client = useMemo(() => createShepherdClient({
|
|
68
|
+
baseUrl: "",
|
|
69
|
+
getAuthHeader: () => `Bearer ${token}`,
|
|
70
|
+
onUnauthorized: () => {
|
|
71
|
+
try {
|
|
72
|
+
localStorage.removeItem(TOKEN_KEY);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// Best-effort: even if the remove fails, resetting state below still
|
|
76
|
+
// returns the viewer to the gate for this session.
|
|
77
|
+
}
|
|
78
|
+
setToken("");
|
|
79
|
+
},
|
|
80
|
+
}), [token]);
|
|
81
|
+
if (token === "") {
|
|
82
|
+
return (_jsx(Gate, { onSubmit: (t) => {
|
|
83
|
+
try {
|
|
84
|
+
localStorage.setItem(TOKEN_KEY, t);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
// Best-effort persistence; the in-memory token still authenticates
|
|
88
|
+
// this session even if storage is unavailable.
|
|
89
|
+
}
|
|
90
|
+
setToken(t);
|
|
91
|
+
} }));
|
|
92
|
+
}
|
|
93
|
+
// Restore the legacy board's layout container: the ported styles size `.wrap`
|
|
94
|
+
// to max-width:1100px with page gutters. The gate centers itself (`.gate`), so
|
|
95
|
+
// only the dashboard view needs the wrapper.
|
|
96
|
+
return (_jsx("div", { className: "wrap", children: _jsx(ShepherdClientProvider, { client: client, children: _jsx(Dashboard, {}) }) }));
|
|
97
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { vi } from "vitest";
|
|
2
|
+
import type { ShepherdClient } from "../client.js";
|
|
3
|
+
export type MockClient = {
|
|
4
|
+
-readonly [K in keyof ShepherdClient]: ShepherdClient[K] extends (...a: infer A) => infer R ? ReturnType<typeof vi.fn<(...a: A) => R>> : ShepherdClient[K];
|
|
5
|
+
};
|
|
6
|
+
export declare function makeMockClient(overrides?: Partial<MockClient>): MockClient;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { vi } from "vitest";
|
|
2
|
+
/** A schema-valid empty landscape, shared by getLandscape and landscape(). */
|
|
3
|
+
const EMPTY_LANDSCAPE = {
|
|
4
|
+
agents: [],
|
|
5
|
+
tasks: [],
|
|
6
|
+
announcements: [],
|
|
7
|
+
serverTime: "2026-06-29T00:00:00.000Z",
|
|
8
|
+
};
|
|
9
|
+
/** A schema-valid empty announce result, shared by announce and announceTo. */
|
|
10
|
+
const EMPTY_ANNOUNCE = { ok: true, announcementIds: [] };
|
|
11
|
+
export function makeMockClient(overrides = {}) {
|
|
12
|
+
const base = {
|
|
13
|
+
baseUrl: "https://hub.example.run.app",
|
|
14
|
+
// --- multi-workspace management surface --------------------------------
|
|
15
|
+
listWorkspaces: vi.fn().mockResolvedValue({ workspaces: [] }),
|
|
16
|
+
createWorkspace: vi.fn().mockResolvedValue({
|
|
17
|
+
id: "ws_new",
|
|
18
|
+
slug: "new",
|
|
19
|
+
name: "New",
|
|
20
|
+
role: "admin",
|
|
21
|
+
}),
|
|
22
|
+
mintToken: vi.fn().mockResolvedValue({ token: "shp_mock", id: "tok_mock" }),
|
|
23
|
+
listTokens: vi.fn().mockResolvedValue({ tokens: [] }),
|
|
24
|
+
revokeToken: vi.fn().mockResolvedValue(undefined),
|
|
25
|
+
createInvite: vi.fn().mockResolvedValue({
|
|
26
|
+
code: "INV-MOCK",
|
|
27
|
+
expiresAt: null,
|
|
28
|
+
maxUses: 1,
|
|
29
|
+
useCount: 0,
|
|
30
|
+
}),
|
|
31
|
+
revokeInvite: vi.fn().mockResolvedValue(undefined),
|
|
32
|
+
redeemInvite: vi.fn().mockResolvedValue({
|
|
33
|
+
workspace: { id: "ws_joined", slug: "joined", name: "Joined", role: "member" },
|
|
34
|
+
}),
|
|
35
|
+
listMembers: vi.fn().mockResolvedValue({ members: [] }),
|
|
36
|
+
removeMember: vi.fn().mockResolvedValue(undefined),
|
|
37
|
+
leave: vi.fn().mockResolvedValue(undefined),
|
|
38
|
+
landscape: vi.fn().mockResolvedValue(EMPTY_LANDSCAPE),
|
|
39
|
+
announceTo: vi.fn().mockResolvedValue(EMPTY_ANNOUNCE),
|
|
40
|
+
// --- self-host singular aliases ----------------------------------------
|
|
41
|
+
getLandscape: vi.fn().mockResolvedValue(EMPTY_LANDSCAPE),
|
|
42
|
+
announce: vi.fn().mockResolvedValue(EMPTY_ANNOUNCE),
|
|
43
|
+
};
|
|
44
|
+
return Object.assign(base, overrides);
|
|
45
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import "@testing-library/jest-dom/vitest";
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { WorkspaceLandscapeResponseT } from "@shepherd/shared";
|
|
2
|
+
/**
|
|
3
|
+
* Connection state surfaced to the board chrome:
|
|
4
|
+
* - `live`: the most recent poll succeeded.
|
|
5
|
+
* - `reconnecting`: a poll failed transiently; the last good snapshot is kept.
|
|
6
|
+
* - `unauthorized`: the hub returned 401; the last good snapshot is kept while
|
|
7
|
+
* the host-supplied client's `onUnauthorized` handles any token clearing.
|
|
8
|
+
*/
|
|
9
|
+
export type LandscapeStatus = "live" | "reconnecting" | "unauthorized";
|
|
10
|
+
/** Options for {@link useLandscapePolling}. */
|
|
11
|
+
export interface UseLandscapePollingOptions {
|
|
12
|
+
/**
|
|
13
|
+
* Workspace to poll. When given, the hook calls `client.landscape(id)` (the
|
|
14
|
+
* hosted, multi-workspace route); when omitted it calls the singular
|
|
15
|
+
* `client.getLandscape()` self-host alias. Changing it re-pulls immediately.
|
|
16
|
+
*/
|
|
17
|
+
workspaceId?: string;
|
|
18
|
+
/** Poll cadence in ms; defaults to {@link DEFAULT_POLL_MS} (5000). */
|
|
19
|
+
pollMs?: number;
|
|
20
|
+
}
|
|
21
|
+
/** The reactive value returned by {@link useLandscapePolling}. */
|
|
22
|
+
export interface LandscapePolling {
|
|
23
|
+
/** Last good landscape, or `null` until the first successful poll. */
|
|
24
|
+
snapshot: WorkspaceLandscapeResponseT | null;
|
|
25
|
+
/** Current connection state. */
|
|
26
|
+
status: LandscapeStatus;
|
|
27
|
+
/** `Date.now()` of the last successful poll, or `null` before the first. */
|
|
28
|
+
lastUpdatedMs: number | null;
|
|
29
|
+
/** Run one poll immediately (used by the composer after a successful send). */
|
|
30
|
+
refresh: () => Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Polls the Shepherd hub's whole-workspace landscape on an interval and exposes
|
|
34
|
+
* the latest good snapshot plus a connection status, mirroring the browser-only
|
|
35
|
+
* polling loop in packages/hub/public/app.js (`poll` + `tickFreshness`).
|
|
36
|
+
*
|
|
37
|
+
* The client is read from {@link useShepherdClient} (context), keeping the hook
|
|
38
|
+
* auth-agnostic — it never touches tokens or the BFF. On mount it polls once
|
|
39
|
+
* immediately, then on `setInterval(pollMs)`. A SECOND `setInterval(1000)` only
|
|
40
|
+
* bumps a tick counter so relative-time text re-renders even between polls (the
|
|
41
|
+
* React analogue of app.js's `tickFreshness`); both intervals are cleared on
|
|
42
|
+
* unmount.
|
|
43
|
+
*
|
|
44
|
+
* Failure handling preserves the original board's resilience: a 401
|
|
45
|
+
* {@link ShepherdClientError} sets `unauthorized`, and ANY other error sets
|
|
46
|
+
* `reconnecting`. In both cases the last good `snapshot`/`lastUpdatedMs` are
|
|
47
|
+
* RETAINED (app.js's `lastSnapshot` retention), so the wallboard never blanks on
|
|
48
|
+
* a transient blip. The same last-good retention holds across a `workspaceId`
|
|
49
|
+
* switch — the prior board stays on screen until the new workspace's first poll
|
|
50
|
+
* lands, and an in-flight poll for the old workspace is dropped so it can't
|
|
51
|
+
* clobber the new one.
|
|
52
|
+
*
|
|
53
|
+
* Two refinements beyond the original loop: a `workspaceId` change re-pulls the
|
|
54
|
+
* new workspace immediately (the poll effect depends on it), and the interval
|
|
55
|
+
* poll is skipped while the tab is hidden (`document.visibilityState`), resuming
|
|
56
|
+
* with an immediate poll on `visibilitychange` back to visible — a backgrounded
|
|
57
|
+
* wallboard shouldn't hammer the hub.
|
|
58
|
+
*
|
|
59
|
+
* @param opts - Optional workspace to scope to and poll cadence override.
|
|
60
|
+
* @returns The current snapshot, status, last-updated timestamp, and a manual
|
|
61
|
+
* {@link LandscapePolling.refresh}.
|
|
62
|
+
*/
|
|
63
|
+
export declare function useLandscapePolling(opts?: UseLandscapePollingOptions): LandscapePolling;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
import { ShepherdClientError } from "./client.js";
|
|
3
|
+
import { useShepherdClient } from "./context.js";
|
|
4
|
+
/** Default poll cadence, mirroring `POLL_MS = 5000` in the original app.js. */
|
|
5
|
+
const DEFAULT_POLL_MS = 5000;
|
|
6
|
+
/** The freshness re-render cadence, mirroring app.js's 1s `tickFreshness` loop. */
|
|
7
|
+
const FRESHNESS_MS = 1000;
|
|
8
|
+
/**
|
|
9
|
+
* Polls the Shepherd hub's whole-workspace landscape on an interval and exposes
|
|
10
|
+
* the latest good snapshot plus a connection status, mirroring the browser-only
|
|
11
|
+
* polling loop in packages/hub/public/app.js (`poll` + `tickFreshness`).
|
|
12
|
+
*
|
|
13
|
+
* The client is read from {@link useShepherdClient} (context), keeping the hook
|
|
14
|
+
* auth-agnostic — it never touches tokens or the BFF. On mount it polls once
|
|
15
|
+
* immediately, then on `setInterval(pollMs)`. A SECOND `setInterval(1000)` only
|
|
16
|
+
* bumps a tick counter so relative-time text re-renders even between polls (the
|
|
17
|
+
* React analogue of app.js's `tickFreshness`); both intervals are cleared on
|
|
18
|
+
* unmount.
|
|
19
|
+
*
|
|
20
|
+
* Failure handling preserves the original board's resilience: a 401
|
|
21
|
+
* {@link ShepherdClientError} sets `unauthorized`, and ANY other error sets
|
|
22
|
+
* `reconnecting`. In both cases the last good `snapshot`/`lastUpdatedMs` are
|
|
23
|
+
* RETAINED (app.js's `lastSnapshot` retention), so the wallboard never blanks on
|
|
24
|
+
* a transient blip. The same last-good retention holds across a `workspaceId`
|
|
25
|
+
* switch — the prior board stays on screen until the new workspace's first poll
|
|
26
|
+
* lands, and an in-flight poll for the old workspace is dropped so it can't
|
|
27
|
+
* clobber the new one.
|
|
28
|
+
*
|
|
29
|
+
* Two refinements beyond the original loop: a `workspaceId` change re-pulls the
|
|
30
|
+
* new workspace immediately (the poll effect depends on it), and the interval
|
|
31
|
+
* poll is skipped while the tab is hidden (`document.visibilityState`), resuming
|
|
32
|
+
* with an immediate poll on `visibilitychange` back to visible — a backgrounded
|
|
33
|
+
* wallboard shouldn't hammer the hub.
|
|
34
|
+
*
|
|
35
|
+
* @param opts - Optional workspace to scope to and poll cadence override.
|
|
36
|
+
* @returns The current snapshot, status, last-updated timestamp, and a manual
|
|
37
|
+
* {@link LandscapePolling.refresh}.
|
|
38
|
+
*/
|
|
39
|
+
export function useLandscapePolling(opts) {
|
|
40
|
+
const client = useShepherdClient();
|
|
41
|
+
const workspaceId = opts?.workspaceId;
|
|
42
|
+
const pollMs = opts?.pollMs ?? DEFAULT_POLL_MS;
|
|
43
|
+
const [snapshot, setSnapshot] = useState(null);
|
|
44
|
+
const [status, setStatus] = useState("live");
|
|
45
|
+
const [lastUpdatedMs, setLastUpdatedMs] = useState(null);
|
|
46
|
+
// A monotonically-bumped counter whose only job is to force a re-render every
|
|
47
|
+
// second so relative timestamps in the tree refresh between polls. The value
|
|
48
|
+
// itself is never read — the state change IS the freshness tick.
|
|
49
|
+
const [, setTick] = useState(0);
|
|
50
|
+
// The latest client lives in a ref so the stable `poll`/`refresh` callbacks
|
|
51
|
+
// always call through to the current provider value without re-subscribing.
|
|
52
|
+
const clientRef = useRef(client);
|
|
53
|
+
clientRef.current = client;
|
|
54
|
+
// Each poll effect run bumps this "generation". A poll captures the generation
|
|
55
|
+
// it started under and compares against the live ref before any setState, so a
|
|
56
|
+
// response that resolves after its effect was torn down — by unmount OR a
|
|
57
|
+
// `workspaceId` switch — is dropped and can't clobber a newer workspace's
|
|
58
|
+
// board. A single shared boolean can't do this: a switch runs old-cleanup then
|
|
59
|
+
// new-setup synchronously, so a boolean reset by setup would let the stale
|
|
60
|
+
// promise's late resolve sail through. The generation only ever moves forward,
|
|
61
|
+
// so a superseded poll's captured value is always behind `gen.current`.
|
|
62
|
+
const gen = useRef(0);
|
|
63
|
+
/**
|
|
64
|
+
* Runs exactly one poll, mapping the outcome onto status/snapshot. Reads
|
|
65
|
+
* `workspaceId` via closure (it's a `useCallback` dep): a non-empty id hits the
|
|
66
|
+
* workspace-scoped `landscape(id)` route, an absent one the singular
|
|
67
|
+
* `getLandscape()` alias. `myGen` is the caller's generation — the result is
|
|
68
|
+
* applied only while it's still the current one.
|
|
69
|
+
*/
|
|
70
|
+
const poll = useCallback(async (myGen) => {
|
|
71
|
+
try {
|
|
72
|
+
const next = workspaceId
|
|
73
|
+
? await clientRef.current.landscape(workspaceId)
|
|
74
|
+
: await clientRef.current.getLandscape();
|
|
75
|
+
if (myGen !== gen.current)
|
|
76
|
+
return;
|
|
77
|
+
setSnapshot(next);
|
|
78
|
+
setLastUpdatedMs(Date.now());
|
|
79
|
+
setStatus("live");
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
if (myGen !== gen.current)
|
|
83
|
+
return;
|
|
84
|
+
// A 401 is terminal-ish: the client's onUnauthorized already ran. Keep
|
|
85
|
+
// the last good board up and flag it; everything else is a transient
|
|
86
|
+
// blip and also keeps the last good board ("reconnecting…" in app.js).
|
|
87
|
+
if (err instanceof ShepherdClientError && err.status === 401) {
|
|
88
|
+
setStatus("unauthorized");
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
setStatus("reconnecting");
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}, [workspaceId]);
|
|
95
|
+
// Poll once on mount, then on the cadence interval. Re-runs when `poll`
|
|
96
|
+
// changes (i.e. on a `workspaceId` switch), re-pulling the new workspace
|
|
97
|
+
// immediately. The interval tick is skipped while the tab is hidden, and a
|
|
98
|
+
// return to visibility polls right away to catch up. The 1s freshness interval
|
|
99
|
+
// is independent so a slow/failed poll never blocks the relative-time refresh.
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
const myGen = ++gen.current;
|
|
102
|
+
void poll(myGen);
|
|
103
|
+
const pollTimer = setInterval(() => {
|
|
104
|
+
// A backgrounded wallboard doesn't need to poll; resume below on visible.
|
|
105
|
+
if (document.visibilityState === "hidden")
|
|
106
|
+
return;
|
|
107
|
+
void poll(myGen);
|
|
108
|
+
}, pollMs);
|
|
109
|
+
const freshnessTimer = setInterval(() => {
|
|
110
|
+
setTick((t) => t + 1);
|
|
111
|
+
}, FRESHNESS_MS);
|
|
112
|
+
const onVisible = () => {
|
|
113
|
+
if (document.visibilityState === "visible")
|
|
114
|
+
void poll(myGen);
|
|
115
|
+
};
|
|
116
|
+
document.addEventListener("visibilitychange", onVisible);
|
|
117
|
+
return () => {
|
|
118
|
+
// Bumping the generation invalidates any in-flight poll from this run.
|
|
119
|
+
gen.current++;
|
|
120
|
+
clearInterval(pollTimer);
|
|
121
|
+
clearInterval(freshnessTimer);
|
|
122
|
+
document.removeEventListener("visibilitychange", onVisible);
|
|
123
|
+
};
|
|
124
|
+
}, [poll, pollMs]);
|
|
125
|
+
// Manual refresh always runs under the current generation, so its result
|
|
126
|
+
// applies as long as the hook hasn't since switched workspace or unmounted.
|
|
127
|
+
const refresh = useCallback(() => poll(gen.current), [poll]);
|
|
128
|
+
return { snapshot, status, lastUpdatedMs, refresh };
|
|
129
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// Chat view — the team chat ported from packages/hub/public (app.js): the
|
|
3
|
+
// announcement feed (oldest→newest, top→bottom) plus a composer. Reads the feed
|
|
4
|
+
// from client.landscape(workspaceId) (same 5s poll as Tasks, via useLandscape)
|
|
5
|
+
// and posts through client.announce, then refreshes immediately. A typed
|
|
6
|
+
// @mention matching a live agent directs the message as a DM, mirroring app.js's
|
|
7
|
+
// send behavior; the autocomplete POPUP UI is intentionally not ported (visual
|
|
8
|
+
// polish, deferred). Task 6.3 fixed the prop signature ({ workspaceId }).
|
|
9
|
+
import { useMemo, useState } from "react";
|
|
10
|
+
import { useShepherdClient } from "../ShepherdProvider.js";
|
|
11
|
+
import { useLandscape } from "./useLandscape.js";
|
|
12
|
+
import { describeError } from "../client.js";
|
|
13
|
+
import { formatRelative, initialsFor, colorForName, extractTarget } from "./wallboard.js";
|
|
14
|
+
/** One message row: avatar + sender + optional target + time + body. */
|
|
15
|
+
function Message({ a, nowMs }) {
|
|
16
|
+
const cls = "msg" +
|
|
17
|
+
(a.targetAgentName || a.toAdmin ? " msg--targeted" : "") +
|
|
18
|
+
(a.fromAdmin ? " msg--me" : "");
|
|
19
|
+
return (_jsxs("div", { className: cls, 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 }), a.targetAgentName ? (_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 })] })] }));
|
|
20
|
+
}
|
|
21
|
+
export function Chat({ workspaceId }) {
|
|
22
|
+
const client = useShepherdClient();
|
|
23
|
+
const { data, error, loading, refresh } = useLandscape(workspaceId);
|
|
24
|
+
const [draft, setDraft] = useState("");
|
|
25
|
+
const [sending, setSending] = useState(false);
|
|
26
|
+
const [sendError, setSendError] = useState(null);
|
|
27
|
+
const nowMs = data ? Date.parse(data.serverTime) : Date.now();
|
|
28
|
+
// Memoised on `data` so they don't recompute on every composer keystroke
|
|
29
|
+
// (each `draft` change re-renders this component).
|
|
30
|
+
// The API returns newest-first; a chat reads oldest→newest, top→bottom.
|
|
31
|
+
const messages = useMemo(() => (data ? [...data.announcements].reverse() : []), [data]);
|
|
32
|
+
// Live agents are addressable by @mention (the canonical names extractTarget
|
|
33
|
+
// resolves against), matching app.js's mention pool.
|
|
34
|
+
const liveNames = useMemo(() => (data?.agents ?? [])
|
|
35
|
+
.filter((agent) => agent.presence === "live")
|
|
36
|
+
.map((agent) => agent.name), [data]);
|
|
37
|
+
async function send(e) {
|
|
38
|
+
e.preventDefault();
|
|
39
|
+
const body = draft.trim();
|
|
40
|
+
if (!body || sending)
|
|
41
|
+
return;
|
|
42
|
+
// The first @mention matching a live agent directs the message; otherwise it
|
|
43
|
+
// broadcasts (no repo scoping here — the repo selector was not ported).
|
|
44
|
+
const targetAgentName = extractTarget(body, liveNames);
|
|
45
|
+
setSending(true);
|
|
46
|
+
setSendError(null);
|
|
47
|
+
try {
|
|
48
|
+
await client.announce(workspaceId, { body, targetAgentName });
|
|
49
|
+
setDraft("");
|
|
50
|
+
await refresh(); // reflect the just-sent message without waiting for the tick
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
setSendError(describeError(err));
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
setSending(false);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return (_jsxs("section", { "aria-labelledby": "chat-heading", children: [_jsx("h2", { id: "chat-heading", children: "Chat" }), error && (_jsx("p", { role: "alert", className: "status status--error", children: `Couldn't refresh the chat — reconnecting… (${error})` })), _jsxs("div", { className: "chat-wrap", children: [_jsx("div", { className: "chat", id: "chat", children: loading && !data ? (_jsx("div", { className: "empty", children: "Loading\u2026" })) : messages.length === 0 ? (_jsx("div", { className: "empty", children: "No announcements yet \u2014 agents will post here as they coordinate." })) : (messages.map((a) => (_jsx(Message, { a: a, nowMs: nowMs }, `${a.fromAgentName}|${a.createdAt}|${a.body}`)))) }), _jsxs("div", { className: "composer", children: [_jsxs("form", { className: "chat-form", onSubmit: send, children: [_jsx("input", { className: "chat-input", type: "text", autoComplete: "off", "aria-label": "Message the team", placeholder: "Message the team\u2026 use @name to direct it", value: draft, onChange: (e) => setDraft(e.target.value) }), _jsx("button", { className: "chat-send", type: "submit", disabled: sending || !draft.trim(), children: "Send" })] }), sendError && (_jsx("p", { role: "alert", className: "status status--error", children: sendError }))] })] })] }));
|
|
60
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
3
|
+
import { render, screen, waitFor } from "@testing-library/react";
|
|
4
|
+
import { userEvent } from "@testing-library/user-event";
|
|
5
|
+
import { ShepherdProvider } from "../ShepherdProvider.js";
|
|
6
|
+
import { Chat } from "./Chat.js";
|
|
7
|
+
import { makeMockClient } from "../test/mockClient.js";
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Chat — the ported team chat (announcement feed + composer). Reads the feed
|
|
10
|
+
// from client.landscape(workspaceId), posts via client.announce. DB-free.
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
const WORKSPACE_ID = "ws_1";
|
|
13
|
+
function landscape(over = {}) {
|
|
14
|
+
return {
|
|
15
|
+
serverTime: "2026-06-29T01:00:00.000Z",
|
|
16
|
+
agents: [],
|
|
17
|
+
tasks: [],
|
|
18
|
+
announcements: [],
|
|
19
|
+
...over,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
describe("Chat", () => {
|
|
23
|
+
let client;
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
client = makeMockClient();
|
|
26
|
+
});
|
|
27
|
+
function renderChat() {
|
|
28
|
+
return render(_jsx(ShepherdProvider, { client: client, children: _jsx(Chat, { workspaceId: WORKSPACE_ID }) }));
|
|
29
|
+
}
|
|
30
|
+
it("renders announcements from the landscape feed", async () => {
|
|
31
|
+
client.landscape = vi.fn().mockResolvedValue(landscape({
|
|
32
|
+
announcements: [
|
|
33
|
+
{
|
|
34
|
+
fromAgentName: "RedDragon",
|
|
35
|
+
fromHuman: "daichi",
|
|
36
|
+
body: "shipping the parser fix",
|
|
37
|
+
targetAgentName: null,
|
|
38
|
+
repo: "acme/web",
|
|
39
|
+
fromAdmin: false,
|
|
40
|
+
toAdmin: false,
|
|
41
|
+
createdAt: "2026-06-29T00:50:00.000Z",
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
}));
|
|
45
|
+
renderChat();
|
|
46
|
+
await waitFor(() => expect(client.landscape).toHaveBeenCalledWith(WORKSPACE_ID));
|
|
47
|
+
await waitFor(() => expect(screen.getByText("shipping the parser fix")).toBeInTheDocument());
|
|
48
|
+
expect(screen.getByText("RedDragon")).toBeInTheDocument();
|
|
49
|
+
});
|
|
50
|
+
it("posts a composed message via announce, then refreshes the feed", async () => {
|
|
51
|
+
client.landscape = vi.fn().mockResolvedValue(landscape());
|
|
52
|
+
client.announce = vi.fn().mockResolvedValue({ ok: true, announcementIds: ["a_1"] });
|
|
53
|
+
renderChat();
|
|
54
|
+
await waitFor(() => expect(client.landscape).toHaveBeenCalledTimes(1));
|
|
55
|
+
await userEvent.type(screen.getByRole("textbox"), "hello team");
|
|
56
|
+
await userEvent.click(screen.getByRole("button", { name: /send/i }));
|
|
57
|
+
await waitFor(() => expect(client.announce).toHaveBeenCalledWith(WORKSPACE_ID, expect.objectContaining({ body: "hello team" })));
|
|
58
|
+
// Refresh after send: landscape re-fetched.
|
|
59
|
+
await waitFor(() => expect(client.landscape.mock.calls.length).toBeGreaterThan(1));
|
|
60
|
+
});
|
|
61
|
+
it("directs the message to the first @mention matching a live agent", async () => {
|
|
62
|
+
client.landscape = vi.fn().mockResolvedValue(landscape({
|
|
63
|
+
agents: [
|
|
64
|
+
{
|
|
65
|
+
name: "RedDragon",
|
|
66
|
+
human: "daichi",
|
|
67
|
+
program: "claude",
|
|
68
|
+
model: "opus",
|
|
69
|
+
repo: "acme/web",
|
|
70
|
+
branch: "main",
|
|
71
|
+
lastHeartbeatAt: "2026-06-29T00:59:00.000Z",
|
|
72
|
+
presence: "live",
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
}));
|
|
76
|
+
client.announce = vi.fn().mockResolvedValue({ ok: true, announcementIds: ["a_1"] });
|
|
77
|
+
renderChat();
|
|
78
|
+
await waitFor(() => expect(client.landscape).toHaveBeenCalled());
|
|
79
|
+
await userEvent.type(screen.getByRole("textbox"), "@reddragon ping");
|
|
80
|
+
await userEvent.click(screen.getByRole("button", { name: /send/i }));
|
|
81
|
+
await waitFor(() => expect(client.announce).toHaveBeenCalledWith(WORKSPACE_ID, expect.objectContaining({ body: "@reddragon ping", targetAgentName: "RedDragon" })));
|
|
82
|
+
});
|
|
83
|
+
it("renders a non-crashing error state when landscape fails", async () => {
|
|
84
|
+
client.landscape = vi.fn().mockRejectedValue(new Error("boom"));
|
|
85
|
+
renderChat();
|
|
86
|
+
await waitFor(() => expect(screen.getByRole("alert")).toBeInTheDocument());
|
|
87
|
+
expect(screen.getByRole("heading", { name: /chat/i })).toBeInTheDocument();
|
|
88
|
+
});
|
|
89
|
+
afterEach(() => {
|
|
90
|
+
vi.restoreAllMocks();
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { WorkspaceSummaryT } from "@shepherd/shared";
|
|
2
|
+
export interface ConfigProps {
|
|
3
|
+
workspaces: WorkspaceSummaryT[];
|
|
4
|
+
selected: WorkspaceSummaryT | null;
|
|
5
|
+
/** The DIRECT Hub URL embedded in the agent install command (decision #2). */
|
|
6
|
+
hubUrl?: string;
|
|
7
|
+
/** Refetch the workspace list after a create/join. */
|
|
8
|
+
onChanged: () => void;
|
|
9
|
+
/** Switch the active workspace. */
|
|
10
|
+
onSelect?: (workspaceId: string) => void;
|
|
11
|
+
}
|
|
12
|
+
export declare function Config({ workspaces, selected, hubUrl, onChanged, onSelect }: ConfigProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { ConnectAgent } from "../config/ConnectAgent.js";
|
|
3
|
+
import { Workspaces } from "../config/Workspaces.js";
|
|
4
|
+
export function Config({ workspaces, selected, hubUrl, onChanged, onSelect }) {
|
|
5
|
+
return (_jsxs("section", { "aria-labelledby": "config-heading", children: [_jsx("h2", { id: "config-heading", children: "Config" }), _jsx(Workspaces, { workspaces: workspaces, selected: selected, onChanged: onChanged, onSelect: onSelect }), selected && _jsx(ConnectAgent, { workspaceId: selected.id, hubUrl: hubUrl })] }));
|
|
6
|
+
}
|