@korso/shepherd-ui 0.2.0 → 0.2.2

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 (65) hide show
  1. package/README.md +47 -47
  2. package/dist/ShepherdRoot.js +15 -57
  3. package/dist/components/Dashboard.d.ts +16 -2
  4. package/dist/components/Dashboard.js +76 -5
  5. package/dist/config/ConnectAgent.js +4 -4
  6. package/dist/config/EmptyState.js +5 -1
  7. package/dist/config/Members.js +4 -4
  8. package/dist/config/Workspaces.js +1 -1
  9. package/dist/lib/{Dashboard-CG6KeFQ3.js → Dashboard-B7nioe5N.js} +529 -457
  10. package/dist/lib/index.d.ts +15 -1
  11. package/dist/lib/index.js +300 -359
  12. package/dist/lib/selfhost.js +1 -1
  13. package/dist/lib/styles.css +252 -209
  14. package/dist/selfhost/assets/index-COMbJ-a4.js +40 -0
  15. package/dist/selfhost/assets/index-CcGvL65Q.css +1 -0
  16. package/dist/selfhost/index.html +13 -13
  17. package/dist/useLandscapePolling.d.ts +7 -0
  18. package/dist/useLandscapePolling.js +6 -1
  19. package/package.json +67 -67
  20. package/dist/ShepherdProvider.d.ts +0 -12
  21. package/dist/ShepherdProvider.js +0 -33
  22. package/dist/ShepherdProvider.test.d.ts +0 -1
  23. package/dist/ShepherdProvider.test.js +0 -47
  24. package/dist/ShepherdRoot.routing.test.d.ts +0 -1
  25. package/dist/ShepherdRoot.routing.test.js +0 -61
  26. package/dist/ShepherdRoot.test.d.ts +0 -1
  27. package/dist/ShepherdRoot.test.js +0 -37
  28. package/dist/app/assets/index-CDOCIg6s.js +0 -11
  29. package/dist/app/index.html +0 -12
  30. package/dist/client.test.d.ts +0 -1
  31. package/dist/client.test.js +0 -298
  32. package/dist/config/ConnectAgent.test.d.ts +0 -1
  33. package/dist/config/ConnectAgent.test.js +0 -92
  34. package/dist/config/EmptyState.test.d.ts +0 -1
  35. package/dist/config/EmptyState.test.js +0 -32
  36. package/dist/config/Members.test.d.ts +0 -1
  37. package/dist/config/Members.test.js +0 -66
  38. package/dist/config/Workspaces.test.d.ts +0 -1
  39. package/dist/config/Workspaces.test.js +0 -90
  40. package/dist/index.cjs +0 -5289
  41. package/dist/index.cjs.map +0 -1
  42. package/dist/index.d.cts +0 -149
  43. package/dist/index.js.map +0 -1
  44. package/dist/selfhost/assets/index-BZmImfYZ.css +0 -1
  45. package/dist/selfhost/assets/index-CS268KSw.js +0 -40
  46. package/dist/test/setup.d.ts +0 -1
  47. package/dist/test/setup.js +0 -2
  48. package/dist/views/Chat.d.ts +0 -5
  49. package/dist/views/Chat.js +0 -60
  50. package/dist/views/Chat.test.d.ts +0 -1
  51. package/dist/views/Chat.test.js +0 -92
  52. package/dist/views/Config.d.ts +0 -12
  53. package/dist/views/Config.js +0 -6
  54. package/dist/views/Tasks.d.ts +0 -5
  55. package/dist/views/Tasks.js +0 -75
  56. package/dist/views/Tasks.test.d.ts +0 -1
  57. package/dist/views/Tasks.test.js +0 -115
  58. package/dist/views/useLandscape.d.ts +0 -14
  59. package/dist/views/useLandscape.js +0 -74
  60. package/dist/views/useLandscape.test.d.ts +0 -1
  61. package/dist/views/useLandscape.test.js +0 -101
  62. package/dist/views/wallboard.d.ts +0 -46
  63. package/dist/views/wallboard.js +0 -209
  64. package/dist/views/wallboard.test.d.ts +0 -1
  65. package/dist/views/wallboard.test.js +0 -143
@@ -1 +0,0 @@
1
- import "@testing-library/jest-dom/vitest";
@@ -1,2 +0,0 @@
1
- // Testing Library matchers (toBeInTheDocument, etc.) for the jsdom lane.
2
- import "@testing-library/jest-dom/vitest";
@@ -1,5 +0,0 @@
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;
@@ -1,60 +0,0 @@
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
- }
@@ -1 +0,0 @@
1
- export {};
@@ -1,92 +0,0 @@
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
- });
@@ -1,12 +0,0 @@
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;
@@ -1,6 +0,0 @@
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
- }
@@ -1,5 +0,0 @@
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;
@@ -1,75 +0,0 @@
1
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- // Tasks view — the wallboard ported from packages/hub/public (app.js): a crew
3
- // strip of live agents + two columns, Active (grouped live claims) and Done
4
- // (finished/dropped history). Driven by client.landscape(workspaceId) on mount
5
- // and on a 5s poll via useLandscape. Functional parity with the old page; visual
6
- // polish is deferred. Task 6.3 fixed the prop signature ({ workspaceId }); this
7
- // fills the body without changing it.
8
- import { useMemo } from "react";
9
- import { useLandscape } from "./useLandscape.js";
10
- import { formatRelative, statusLabel, formatActiveDuration, dayBucket, initialsFor, colorForName, groupActiveClaims, } from "./wallboard.js";
11
- /** Avatar bubble: two initials on the name's deterministic hue (as in app.js). */
12
- function Avatar({ name, className }) {
13
- return (_jsx("div", { className: className, style: { background: colorForName(name) }, children: initialsFor(name) }));
14
- }
15
- /** A claim's body: intent + territory globs + a meta line (started/finished). */
16
- function ClaimBody({ task, meta }) {
17
- return (_jsxs(_Fragment, { children: [_jsx("div", { className: "task__intent", children: task.intent }), _jsxs("div", { className: "terr", children: [_jsx("span", { className: "terr__lbl", children: "territory" }), task.pathGlobs.map((g) => (_jsx("span", { className: "glob", children: g }, g)))] }), _jsx("div", { className: "task__meta", children: meta })] }));
18
- }
19
- /** A lone agent/claim — the classic single card. */
20
- function PlainCard({ task, nowMs }) {
21
- return (_jsxs("div", { className: "task", children: [_jsxs("div", { className: "task__r1", children: [_jsx("span", { className: "task__who", children: task.agentName }), (task.model || task.program) && (_jsx("span", { className: "task__tag", children: task.model || task.program })), _jsx("span", { className: "livedot", title: "active" })] }), _jsx(ClaimBody, { task: task, meta: `started ${formatRelative(task.createdAt, nowMs)}` })] }));
22
- }
23
- /** An agent with multiple live claims: header + primaries + folded narrower set. */
24
- function GroupCard({ group, nowMs }) {
25
- const count = group.primaries.length + group.narrower.length;
26
- return (_jsxs("div", { className: "grp", children: [_jsxs("div", { className: "grp__head", children: [_jsx("span", { className: "grp__who", children: group.agentName }), (group.model || group.program) && (_jsx("span", { className: "grp__tag", children: group.model || group.program })), _jsx("span", { className: "grp__count", children: `· ${count} 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, meta: `started ${formatRelative(c.createdAt, nowMs)}` }) }, `${c.agentName}|${c.repo}|${c.createdAt}`))) }), group.narrower.length > 0 && (_jsxs("details", { className: "fold", children: [_jsx("summary", { children: `+${group.narrower.length} narrower claim${group.narrower.length > 1 ? "s" : ""}` }), _jsx("div", { className: "fold__body", children: group.narrower.map((c) => (_jsxs("div", { className: "claim", children: [_jsx(ClaimBody, { task: c, meta: `started ${formatRelative(c.createdAt, nowMs)}` }), _jsx("div", { className: "covered", children: "\u2282 covered by a claim above" })] }, `${c.agentName}|${c.repo}|${c.createdAt}`))) })] }))] }));
27
- }
28
- /** Crew strip: live agents, those with an active claim first, then alphabetical. */
29
- function Crew({ agents, tasks }) {
30
- const live = agents.filter((a) => a.presence === "live");
31
- const active = new Set(tasks.filter((t) => t.status === "active").map((t) => t.agentName));
32
- const ordered = [...live].sort((x, y) => {
33
- const ax = active.has(x.name), ay = active.has(y.name);
34
- if (ax !== ay)
35
- return ax ? -1 : 1;
36
- return x.name.localeCompare(y.name);
37
- });
38
- return (_jsx("div", { className: "crew", children: ordered.map((a) => (_jsxs("div", { className: "person" + (active.has(a.name) ? "" : " person--idle"), children: [_jsx(Avatar, { name: a.name, className: "avatar" }), _jsx("span", { className: "person__name", children: a.name })] }, a.name))) }));
39
- }
40
- export function Tasks({ workspaceId }) {
41
- const { data, error, loading } = useLandscape(workspaceId);
42
- const nowMs = data ? Date.parse(data.serverTime) : Date.now();
43
- const tasks = data?.tasks ?? [];
44
- const agents = data?.agents ?? [];
45
- const active = tasks.filter((t) => t.status === "active");
46
- const done = tasks.filter((t) => t.status !== "active");
47
- // Grouping is the only non-trivial derivation here; memoise it on `data` (the
48
- // sole source of `tasks`) so it recomputes only when the snapshot changes,
49
- // not on unrelated re-renders.
50
- const groups = useMemo(() => groupActiveClaims((data?.tasks ?? []).filter((t) => t.status === "active")), [data]);
51
- return (_jsxs("section", { "aria-labelledby": "tasks-heading", children: [_jsx("h2", { id: "tasks-heading", children: "Tasks" }), _jsx("span", { "data-testid": "tasks-workspace-id", hidden: true, children: workspaceId }), error && (_jsx("p", { role: "alert", className: "status status--error", children: `Couldn't refresh the workspace — reconnecting… (${error})` })), loading && !data ? (_jsx("p", { className: "empty", children: "Loading\u2026" })) : (_jsxs(_Fragment, { children: [_jsx(Crew, { agents: agents, tasks: tasks }), _jsxs("div", { className: "board", children: [_jsxs("div", { className: "col", children: [_jsxs("div", { className: "colhead", children: [_jsx("h3", { children: "Active" }), _jsx("span", { className: "n", children: active.length })] }), _jsx("div", { id: "active-list", children: active.length === 0 ? (_jsx("div", { className: "empty", children: "Nothing active right now." })) : (groups.map((g) => {
52
- const single = g.primaries.length === 1 && g.narrower.length === 0;
53
- const key = `${g.agentName}|${g.repo}`;
54
- return single ? (_jsx(PlainCard, { task: g.primaries[0], nowMs: nowMs }, key)) : (_jsx(GroupCard, { group: g, nowMs: nowMs }, key));
55
- })) })] }), _jsxs("div", { className: "col", children: [_jsxs("div", { className: "colhead", children: [_jsx("h3", { children: "Done" }), _jsx("span", { className: "n", children: done.length })] }), _jsx("div", { id: "done-list", children: done.length === 0 ? (_jsx("div", { className: "empty", children: "No finished tasks yet." })) : (_jsx(DoneList, { tasks: done, nowMs: nowMs })) })] })] })] }))] }));
56
- }
57
- /** The Done column with day buckets + per-row status chip and meta, as in app.js. */
58
- function DoneList({ tasks, nowMs }) {
59
- let lastDay = null;
60
- const rows = [];
61
- for (const t of tasks) {
62
- const day = dayBucket(t.endedAt || t.createdAt, nowMs);
63
- if (day !== lastDay) {
64
- rows.push(_jsx("div", { className: "day", children: day }, `day|${day}|${t.createdAt}`));
65
- lastDay = day;
66
- }
67
- const meta = t.status === "dropped"
68
- ? `went offline ${formatRelative(t.endedAt ?? t.createdAt, nowMs)} — no done signal`
69
- : `finished ${formatRelative(t.endedAt ?? t.createdAt, nowMs)}${formatActiveDuration(t.createdAt, t.endedAt)
70
- ? " · " + formatActiveDuration(t.createdAt, t.endedAt)
71
- : ""}`;
72
- rows.push(_jsxs("div", { className: "task", children: [_jsxs("div", { className: "task__r1", children: [_jsx("span", { className: "task__who", children: t.agentName }), _jsx("span", { className: "task__stat" + (t.status === "dropped" ? " task__stat--drop" : ""), children: statusLabel(t.status) })] }), _jsx(ClaimBody, { task: t, meta: meta })] }, `${t.agentName}|${t.repo}|${t.createdAt}`));
73
- }
74
- return _jsx(_Fragment, { children: rows });
75
- }
@@ -1 +0,0 @@
1
- export {};
@@ -1,115 +0,0 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
2
- import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
3
- import { render, screen, waitFor, act } from "@testing-library/react";
4
- import { ShepherdProvider } from "../ShepherdProvider.js";
5
- import { Tasks } from "./Tasks.js";
6
- import { makeMockClient } from "../test/mockClient.js";
7
- // ---------------------------------------------------------------------------
8
- // Tasks — the ported wallboard (crew strip + Active/Done columns), driven by
9
- // client.landscape(workspaceId) on mount and on a 5s poll. DB-free.
10
- // ---------------------------------------------------------------------------
11
- const WORKSPACE_ID = "ws_1";
12
- function landscape(over = {}) {
13
- return {
14
- serverTime: "2026-06-29T01:00:00.000Z",
15
- agents: [],
16
- tasks: [],
17
- announcements: [],
18
- ...over,
19
- };
20
- }
21
- describe("Tasks", () => {
22
- let client;
23
- beforeEach(() => {
24
- client = makeMockClient();
25
- });
26
- function renderTasks() {
27
- return render(_jsx(ShepherdProvider, { client: client, children: _jsx(Tasks, { workspaceId: WORKSPACE_ID }) }));
28
- }
29
- it("calls landscape(workspaceId) on mount and renders agents, claims, and history", async () => {
30
- client.landscape = vi.fn().mockResolvedValue(landscape({
31
- agents: [
32
- {
33
- name: "RedDragon",
34
- human: "daichi",
35
- program: "claude",
36
- model: "opus",
37
- repo: "acme/web",
38
- branch: "main",
39
- lastHeartbeatAt: "2026-06-29T00:59:00.000Z",
40
- presence: "live",
41
- },
42
- ],
43
- tasks: [
44
- {
45
- agentName: "RedDragon",
46
- program: "claude",
47
- model: "opus",
48
- repo: "acme/web",
49
- intent: "refactor the parser",
50
- pathGlobs: ["src/parser/**"],
51
- status: "active",
52
- createdAt: "2026-06-29T00:30:00.000Z",
53
- endedAt: null,
54
- },
55
- {
56
- agentName: "BlueWolf",
57
- program: "codex",
58
- model: null,
59
- repo: "acme/web",
60
- intent: "fixed the build",
61
- pathGlobs: ["build/**"],
62
- status: "done",
63
- createdAt: "2026-06-29T00:00:00.000Z",
64
- endedAt: "2026-06-29T00:20:00.000Z",
65
- },
66
- ],
67
- }));
68
- renderTasks();
69
- await waitFor(() => expect(client.landscape).toHaveBeenCalledWith(WORKSPACE_ID));
70
- // Live agent's name appears (crew strip + the active claim card).
71
- await waitFor(() => expect(screen.getAllByText("RedDragon").length).toBeGreaterThan(0));
72
- // Active claim's intent.
73
- expect(screen.getByText("refactor the parser")).toBeInTheDocument();
74
- expect(screen.getByText("src/parser/**")).toBeInTheDocument();
75
- // Done/history entry.
76
- expect(screen.getByText("fixed the build")).toBeInTheDocument();
77
- expect(screen.getByText("done")).toBeInTheDocument();
78
- });
79
- it("re-polls landscape on the interval and stops after unmount", async () => {
80
- vi.useFakeTimers();
81
- try {
82
- client.landscape = vi.fn().mockResolvedValue(landscape());
83
- let unmount = () => { };
84
- await act(async () => {
85
- ({ unmount } = renderTasks());
86
- });
87
- // Initial mount fetch.
88
- expect(client.landscape).toHaveBeenCalledTimes(1);
89
- // Advance one poll interval (5s) → a second fetch.
90
- await act(async () => {
91
- await vi.advanceTimersByTimeAsync(5000);
92
- });
93
- expect(client.landscape).toHaveBeenCalledTimes(2);
94
- // Unmount clears the interval: no further calls.
95
- unmount();
96
- await act(async () => {
97
- await vi.advanceTimersByTimeAsync(15000);
98
- });
99
- expect(client.landscape).toHaveBeenCalledTimes(2);
100
- }
101
- finally {
102
- vi.useRealTimers();
103
- }
104
- });
105
- it("renders a non-crashing error state when landscape fails", async () => {
106
- client.landscape = vi.fn().mockRejectedValue(new Error("boom"));
107
- renderTasks();
108
- await waitFor(() => expect(screen.getByRole("alert")).toBeInTheDocument());
109
- // The heading still renders — the view did not crash.
110
- expect(screen.getByRole("heading", { name: /tasks/i })).toBeInTheDocument();
111
- });
112
- afterEach(() => {
113
- vi.restoreAllMocks();
114
- });
115
- });
@@ -1,14 +0,0 @@
1
- import type { WorkspaceLandscapeResponseT } from "@shepherd/shared";
2
- /** The wallboard poll cadence, matching app.js's POLL_MS. */
3
- export declare const POLL_MS = 5000;
4
- export interface LandscapeState {
5
- /** Last good snapshot, or null before the first successful load. */
6
- data: WorkspaceLandscapeResponseT | null;
7
- /** A non-fatal error message from the most recent failed fetch, else null. */
8
- error: string | null;
9
- /** True until the first fetch settles (success or failure). */
10
- loading: boolean;
11
- /** Re-pull immediately (used after a mutation). Resolves when the fetch settles. */
12
- refresh: () => Promise<void>;
13
- }
14
- export declare function useLandscape(workspaceId: string): LandscapeState;
@@ -1,74 +0,0 @@
1
- import { useEffect, useRef, useState, useCallback } from "react";
2
- import { useShepherdClient } from "../ShepherdProvider.js";
3
- import { describeError } from "../client.js";
4
- // ---------------------------------------------------------------------------
5
- // useLandscape — the shared poll loop behind both Tasks and Chat. Mirrors the
6
- // old app.js `poll()` cadence (5s) and its "keep the last good snapshot on a
7
- // transient failure" behavior: a failed fetch surfaces `error` but leaves the
8
- // previous `data` on screen, so the board doesn't blank out while reconnecting.
9
- //
10
- // Lifecycle safety (the 6.3 review flagged the absence of guards as a footgun):
11
- // - a `cancelled` ref gates every setState so a fetch that resolves after the
12
- // view unmounts (or after workspaceId changes) is dropped,
13
- // - the interval is cleared on unmount AND re-established when workspaceId
14
- // changes (it's in the effect deps),
15
- // - `refresh()` lets a mutation (e.g. Chat's announce) re-pull immediately
16
- // without waiting for the next tick — the same as app.js awaiting poll().
17
- // ---------------------------------------------------------------------------
18
- /** The wallboard poll cadence, matching app.js's POLL_MS. */
19
- export const POLL_MS = 5000;
20
- export function useLandscape(workspaceId) {
21
- const client = useShepherdClient();
22
- const [data, setData] = useState(null);
23
- const [error, setError] = useState(null);
24
- const [loading, setLoading] = useState(true);
25
- // Guards setState after unmount / workspace change. A ref (not state) so the
26
- // async fetch closure reads the live value, not the value at fetch-start.
27
- const cancelled = useRef(false);
28
- // Stable across renders so the poll effect doesn't re-subscribe each tick.
29
- // Reads `workspaceId`/`client` via the deps; `cancelled` via the live ref.
30
- const load = useCallback(async () => {
31
- try {
32
- const snapshot = await client.landscape(workspaceId);
33
- if (cancelled.current)
34
- return;
35
- setData(snapshot);
36
- setError(null);
37
- }
38
- catch (err) {
39
- if (cancelled.current)
40
- return;
41
- // Keep the last good snapshot on screen; just note the failure.
42
- setError(describeError(err));
43
- }
44
- finally {
45
- if (!cancelled.current)
46
- setLoading(false);
47
- }
48
- }, [client, workspaceId]);
49
- useEffect(() => {
50
- cancelled.current = false;
51
- setLoading(true);
52
- void load();
53
- // Skip the periodic fetch while the tab is hidden — a backgrounded wallboard
54
- // doesn't need to poll. We resume below on `visibilitychange`.
55
- const id = setInterval(() => {
56
- if (document.visibilityState === "hidden")
57
- return;
58
- void load();
59
- }, POLL_MS);
60
- // On returning to a visible tab, re-pull immediately so the board catches up
61
- // without waiting for the next tick.
62
- const onVisible = () => {
63
- if (document.visibilityState === "visible")
64
- void load();
65
- };
66
- document.addEventListener("visibilitychange", onVisible);
67
- return () => {
68
- cancelled.current = true;
69
- clearInterval(id);
70
- document.removeEventListener("visibilitychange", onVisible);
71
- };
72
- }, [load]);
73
- return { data, error, loading, refresh: load };
74
- }
@@ -1 +0,0 @@
1
- export {};
@@ -1,101 +0,0 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
2
- import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
3
- import { renderHook, act } from "@testing-library/react";
4
- import { ShepherdProvider } from "../ShepherdProvider.js";
5
- import { useLandscape, POLL_MS } from "./useLandscape.js";
6
- import { makeMockClient } from "../test/mockClient.js";
7
- // ---------------------------------------------------------------------------
8
- // useLandscape — the shared 5s poll behind Tasks/Chat. This covers the subtlest
9
- // lifecycle behavior: cancel-on-unmount (no setState after teardown) and the
10
- // workspace-change re-subscribe (a new workspaceId re-pulls immediately).
11
- // ---------------------------------------------------------------------------
12
- describe("useLandscape", () => {
13
- let client;
14
- beforeEach(() => {
15
- client = makeMockClient();
16
- });
17
- afterEach(() => {
18
- vi.useRealTimers();
19
- vi.restoreAllMocks();
20
- });
21
- function wrapper({ children }) {
22
- return _jsx(ShepherdProvider, { client: client, children: children });
23
- }
24
- it("fetches on mount, re-polls on the interval, and clears the interval on unmount", async () => {
25
- vi.useFakeTimers();
26
- client.landscape = vi.fn().mockResolvedValue({
27
- agents: [],
28
- tasks: [],
29
- announcements: [],
30
- serverTime: "2026-06-29T00:00:00.000Z",
31
- });
32
- let unmount = () => { };
33
- await act(async () => {
34
- ({ unmount } = renderHook(() => useLandscape("ws_1"), { wrapper }));
35
- });
36
- // Initial mount fetch.
37
- expect(client.landscape).toHaveBeenCalledTimes(1);
38
- expect(client.landscape).toHaveBeenLastCalledWith("ws_1");
39
- // One poll interval → a second fetch.
40
- await act(async () => {
41
- await vi.advanceTimersByTimeAsync(POLL_MS);
42
- });
43
- expect(client.landscape).toHaveBeenCalledTimes(2);
44
- // Unmount cancels the loop: no further calls.
45
- act(() => unmount());
46
- await act(async () => {
47
- await vi.advanceTimersByTimeAsync(POLL_MS * 3);
48
- });
49
- expect(client.landscape).toHaveBeenCalledTimes(2);
50
- });
51
- it("re-subscribes (re-pulls) when workspaceId changes", async () => {
52
- vi.useFakeTimers();
53
- client.landscape = vi.fn().mockResolvedValue({
54
- agents: [],
55
- tasks: [],
56
- announcements: [],
57
- serverTime: "2026-06-29T00:00:00.000Z",
58
- });
59
- let rerender = () => { };
60
- await act(async () => {
61
- ({ rerender } = renderHook(({ id }) => useLandscape(id), {
62
- wrapper,
63
- initialProps: { id: "ws_1" },
64
- }));
65
- });
66
- expect(client.landscape).toHaveBeenLastCalledWith("ws_1");
67
- const afterMount = client.landscape.mock.calls.length;
68
- // Switching workspace re-runs the effect: an immediate fetch for the new id.
69
- await act(async () => {
70
- rerender({ id: "ws_2" });
71
- });
72
- expect(client.landscape.mock.calls.length).toBeGreaterThan(afterMount);
73
- expect(client.landscape).toHaveBeenLastCalledWith("ws_2");
74
- });
75
- it("does not poll while the tab is hidden, and refetches on becoming visible", async () => {
76
- vi.useFakeTimers();
77
- client.landscape = vi.fn().mockResolvedValue({
78
- agents: [],
79
- tasks: [],
80
- announcements: [],
81
- serverTime: "2026-06-29T00:00:00.000Z",
82
- });
83
- await act(async () => {
84
- renderHook(() => useLandscape("ws_1"), { wrapper });
85
- });
86
- expect(client.landscape).toHaveBeenCalledTimes(1); // mount fetch
87
- // Hide the tab: the interval tick should skip the fetch.
88
- const visibility = vi.spyOn(document, "visibilityState", "get");
89
- visibility.mockReturnValue("hidden");
90
- await act(async () => {
91
- await vi.advanceTimersByTimeAsync(POLL_MS);
92
- });
93
- expect(client.landscape).toHaveBeenCalledTimes(1);
94
- // Becoming visible again re-pulls immediately.
95
- visibility.mockReturnValue("visible");
96
- await act(async () => {
97
- document.dispatchEvent(new Event("visibilitychange"));
98
- });
99
- expect(client.landscape).toHaveBeenCalledTimes(2);
100
- });
101
- });
@@ -1,46 +0,0 @@
1
- import type { WorkspaceTaskT, TaskStatusT } from "@shepherd/shared";
2
- /** Human "N ago" for a past ISO timestamp, relative to nowMs (epoch ms). */
3
- export declare function formatRelative(iso: string, nowMs: number): string;
4
- /**
5
- * Deterministic chat color for an agent name — same name always maps to the
6
- * same hue, so each speaker reads consistently across the announcement thread.
7
- */
8
- export declare function colorForName(name: string): string;
9
- /**
10
- * Up to two initials for an avatar. Prefers the capital letters of a CamelCase
11
- * agent name (RedDragon → RD); otherwise the first two letters (alice → AL).
12
- */
13
- export declare function initialsFor(name: string): string;
14
- /** Human label for a history task's status. */
15
- export declare function statusLabel(status: TaskStatusT): string;
16
- /** "active Nm/Nh" for the created->ended span; "" when the task hasn't ended. */
17
- export declare function formatActiveDuration(createdIso: string, endedIso: string | null): string;
18
- /** Local-day bucket label: "Today" / "Yesterday" / "Mon D". */
19
- export declare function dayBucket(iso: string, nowMs: number): string;
20
- /**
21
- * Does glob set `outer` fully cover glob set `inner`? Conservative: unsure → false,
22
- * so a claim only folds when it is certainly contained. Empty `inner` is covered.
23
- */
24
- export declare function globsCover(outer: string[], inner: string[]): boolean;
25
- /** One agent's grouped active claims: visible primaries + folded narrower claims. */
26
- export interface ClaimGroup {
27
- agentName: string;
28
- model: string | null;
29
- program: string;
30
- repo: string;
31
- primaries: WorkspaceTaskT[];
32
- narrower: WorkspaceTaskT[];
33
- }
34
- /**
35
- * Group active claims by agent for the board. Within an agent's claims, one that
36
- * is STRICTLY covered by a broader sibling folds into `narrower`; everything else
37
- * stays a visible `primary`. Groups + claims are newest-first; header fields come
38
- * from the group's newest claim. Pure — no DOM, no clock.
39
- */
40
- export declare function groupActiveClaims(tasks: WorkspaceTaskT[]): ClaimGroup[];
41
- /**
42
- * The first `@mention` in `text` matching a known agent name, returned in the
43
- * name's canonical casing (`@reddragon` → `RedDragon`), or null when none match.
44
- * An unmatched `@foo` is plain text and the message broadcasts.
45
- */
46
- export declare function extractTarget(text: string, knownNames: string[]): string | null;