@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.
Files changed (99) hide show
  1. package/dist/ShepherdProvider.d.ts +12 -0
  2. package/dist/ShepherdProvider.js +33 -0
  3. package/dist/ShepherdProvider.test.d.ts +1 -0
  4. package/dist/ShepherdProvider.test.js +47 -0
  5. package/dist/ShepherdRoot.d.ts +9 -0
  6. package/dist/ShepherdRoot.js +88 -0
  7. package/dist/ShepherdRoot.routing.test.d.ts +1 -0
  8. package/dist/ShepherdRoot.routing.test.js +61 -0
  9. package/dist/ShepherdRoot.test.d.ts +1 -0
  10. package/dist/ShepherdRoot.test.js +37 -0
  11. package/dist/app/assets/index-CDOCIg6s.js +11 -0
  12. package/dist/app/index.html +12 -0
  13. package/dist/client.d.ts +107 -0
  14. package/dist/client.js +238 -0
  15. package/dist/client.test.d.ts +1 -0
  16. package/dist/client.test.js +298 -0
  17. package/dist/components/ActiveList.d.ts +22 -0
  18. package/dist/components/ActiveList.js +59 -0
  19. package/dist/components/Chat.d.ts +30 -0
  20. package/dist/components/Chat.js +61 -0
  21. package/dist/components/Composer.d.ts +37 -0
  22. package/dist/components/Composer.js +146 -0
  23. package/dist/components/Crew.d.ts +23 -0
  24. package/dist/components/Crew.js +31 -0
  25. package/dist/components/Dashboard.d.ts +34 -0
  26. package/dist/components/Dashboard.js +209 -0
  27. package/dist/components/DoneList.d.ts +29 -0
  28. package/dist/components/DoneList.js +50 -0
  29. package/dist/components/RepoSelect.d.ts +38 -0
  30. package/dist/components/RepoSelect.js +56 -0
  31. package/dist/components/Territory.d.ts +21 -0
  32. package/dist/components/Territory.js +21 -0
  33. package/dist/config/ConnectAgent.d.ts +10 -0
  34. package/dist/config/ConnectAgent.js +119 -0
  35. package/dist/config/ConnectAgent.test.d.ts +1 -0
  36. package/dist/config/ConnectAgent.test.js +92 -0
  37. package/dist/config/EmptyState.d.ts +12 -0
  38. package/dist/config/EmptyState.js +4 -0
  39. package/dist/config/EmptyState.test.d.ts +1 -0
  40. package/dist/config/EmptyState.test.js +32 -0
  41. package/dist/config/Members.d.ts +10 -0
  42. package/dist/config/Members.js +83 -0
  43. package/dist/config/Members.test.d.ts +1 -0
  44. package/dist/config/Members.test.js +66 -0
  45. package/dist/config/Workspaces.d.ts +12 -0
  46. package/dist/config/Workspaces.js +96 -0
  47. package/dist/config/Workspaces.test.d.ts +1 -0
  48. package/dist/config/Workspaces.test.js +90 -0
  49. package/dist/config/index.d.ts +8 -0
  50. package/dist/config/index.js +6 -0
  51. package/dist/context.d.ts +28 -0
  52. package/dist/context.js +36 -0
  53. package/dist/index.cjs +5289 -0
  54. package/dist/index.cjs.map +1 -0
  55. package/dist/index.d.cts +149 -0
  56. package/dist/index.d.ts +18 -0
  57. package/dist/index.js +15 -0
  58. package/dist/index.js.map +1 -0
  59. package/dist/lib/Dashboard-CG6KeFQ3.js +1214 -0
  60. package/dist/lib/index.d.ts +438 -16
  61. package/dist/lib/index.js +455 -6
  62. package/dist/lib/selfhost.js +9 -9
  63. package/dist/lib/styles.css +27 -0
  64. package/dist/logic.d.ts +208 -0
  65. package/dist/logic.js +372 -0
  66. package/dist/main.d.ts +1 -0
  67. package/dist/main.js +11 -0
  68. package/dist/selfhost/assets/{index-tSyzirZa.css → index-BZmImfYZ.css} +1 -1
  69. package/dist/selfhost/assets/index-CS268KSw.js +40 -0
  70. package/dist/selfhost/index.html +2 -2
  71. package/dist/selfhost.d.ts +22 -0
  72. package/dist/selfhost.js +97 -0
  73. package/dist/test/mockClient.d.ts +6 -0
  74. package/dist/test/mockClient.js +45 -0
  75. package/dist/test/setup.d.ts +1 -0
  76. package/dist/test/setup.js +2 -0
  77. package/dist/useLandscapePolling.d.ts +63 -0
  78. package/dist/useLandscapePolling.js +129 -0
  79. package/dist/views/Chat.d.ts +5 -0
  80. package/dist/views/Chat.js +60 -0
  81. package/dist/views/Chat.test.d.ts +1 -0
  82. package/dist/views/Chat.test.js +92 -0
  83. package/dist/views/Config.d.ts +12 -0
  84. package/dist/views/Config.js +6 -0
  85. package/dist/views/Tasks.d.ts +5 -0
  86. package/dist/views/Tasks.js +75 -0
  87. package/dist/views/Tasks.test.d.ts +1 -0
  88. package/dist/views/Tasks.test.js +115 -0
  89. package/dist/views/useLandscape.d.ts +14 -0
  90. package/dist/views/useLandscape.js +74 -0
  91. package/dist/views/useLandscape.test.d.ts +1 -0
  92. package/dist/views/useLandscape.test.js +101 -0
  93. package/dist/views/wallboard.d.ts +46 -0
  94. package/dist/views/wallboard.js +209 -0
  95. package/dist/views/wallboard.test.d.ts +1 -0
  96. package/dist/views/wallboard.test.js +143 -0
  97. package/package.json +1 -1
  98. package/dist/lib/Dashboard-CB6SL-J2.js +0 -1064
  99. package/dist/selfhost/assets/index-BBx3vo2Y.js +0 -40
@@ -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-BBx3vo2Y.js"></script>
8
- <link rel="stylesheet" crossorigin href="/assets/index-tSyzirZa.css">
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;
@@ -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,2 @@
1
+ // Testing Library matchers (toBeInTheDocument, etc.) for the jsdom lane.
2
+ 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,5 @@
1
+ export interface ChatProps {
2
+ /** The workspace whose chat to render. Selected by the shell. */
3
+ workspaceId: string;
4
+ }
5
+ export declare function Chat({ workspaceId }: ChatProps): import("react").JSX.Element;
@@ -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
+ }
@@ -0,0 +1,5 @@
1
+ export interface TasksProps {
2
+ /** The workspace whose wallboard to render. Selected by the shell. */
3
+ workspaceId: string;
4
+ }
5
+ export declare function Tasks({ workspaceId }: TasksProps): import("react").JSX.Element;