@korso/shepherd-ui 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -47
- package/dist/ShepherdProvider.d.ts +12 -0
- package/dist/ShepherdProvider.js +33 -0
- package/dist/ShepherdProvider.test.d.ts +1 -0
- package/dist/ShepherdProvider.test.js +47 -0
- package/dist/ShepherdRoot.d.ts +9 -0
- package/dist/ShepherdRoot.js +88 -0
- package/dist/ShepherdRoot.routing.test.d.ts +1 -0
- package/dist/ShepherdRoot.routing.test.js +61 -0
- package/dist/ShepherdRoot.test.d.ts +1 -0
- package/dist/ShepherdRoot.test.js +37 -0
- package/dist/app/assets/index-CDOCIg6s.js +11 -0
- package/dist/app/index.html +12 -0
- package/dist/client.d.ts +107 -0
- package/dist/client.js +238 -0
- package/dist/client.test.d.ts +1 -0
- package/dist/client.test.js +298 -0
- package/dist/components/ActiveList.d.ts +22 -0
- package/dist/components/ActiveList.js +59 -0
- package/dist/components/Chat.d.ts +30 -0
- package/dist/components/Chat.js +61 -0
- package/dist/components/Composer.d.ts +37 -0
- package/dist/components/Composer.js +146 -0
- package/dist/components/Crew.d.ts +23 -0
- package/dist/components/Crew.js +31 -0
- package/dist/components/Dashboard.d.ts +34 -0
- package/dist/components/Dashboard.js +209 -0
- package/dist/components/DoneList.d.ts +29 -0
- package/dist/components/DoneList.js +50 -0
- package/dist/components/RepoSelect.d.ts +38 -0
- package/dist/components/RepoSelect.js +56 -0
- package/dist/components/Territory.d.ts +21 -0
- package/dist/components/Territory.js +21 -0
- package/dist/config/ConnectAgent.d.ts +10 -0
- package/dist/config/ConnectAgent.js +119 -0
- package/dist/config/ConnectAgent.test.d.ts +1 -0
- package/dist/config/ConnectAgent.test.js +92 -0
- package/dist/config/EmptyState.d.ts +12 -0
- package/dist/config/EmptyState.js +4 -0
- package/dist/config/EmptyState.test.d.ts +1 -0
- package/dist/config/EmptyState.test.js +32 -0
- package/dist/config/Members.d.ts +10 -0
- package/dist/config/Members.js +83 -0
- package/dist/config/Members.test.d.ts +1 -0
- package/dist/config/Members.test.js +66 -0
- package/dist/config/Workspaces.d.ts +12 -0
- package/dist/config/Workspaces.js +96 -0
- package/dist/config/Workspaces.test.d.ts +1 -0
- package/dist/config/Workspaces.test.js +90 -0
- package/dist/config/index.d.ts +8 -0
- package/dist/config/index.js +6 -0
- package/dist/context.d.ts +28 -0
- package/dist/context.js +36 -0
- package/dist/index.cjs +5289 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +149 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/Dashboard-CG6KeFQ3.js +1214 -0
- package/dist/lib/index.d.ts +438 -16
- package/dist/lib/index.js +455 -6
- package/dist/lib/selfhost.js +9 -9
- package/dist/lib/styles.css +209 -182
- package/dist/logic.d.ts +208 -0
- package/dist/logic.js +372 -0
- package/dist/main.d.ts +1 -0
- package/dist/main.js +11 -0
- package/dist/selfhost/assets/{index-tSyzirZa.css → index-BZmImfYZ.css} +1 -1
- package/dist/selfhost/assets/index-CS268KSw.js +40 -0
- package/dist/selfhost/index.html +13 -13
- package/dist/selfhost.d.ts +22 -0
- package/dist/selfhost.js +97 -0
- package/dist/test/mockClient.d.ts +6 -0
- package/dist/test/mockClient.js +45 -0
- package/dist/test/setup.d.ts +1 -0
- package/dist/test/setup.js +2 -0
- package/dist/useLandscapePolling.d.ts +63 -0
- package/dist/useLandscapePolling.js +129 -0
- package/dist/views/Chat.d.ts +5 -0
- package/dist/views/Chat.js +60 -0
- package/dist/views/Chat.test.d.ts +1 -0
- package/dist/views/Chat.test.js +92 -0
- package/dist/views/Config.d.ts +12 -0
- package/dist/views/Config.js +6 -0
- package/dist/views/Tasks.d.ts +5 -0
- package/dist/views/Tasks.js +75 -0
- package/dist/views/Tasks.test.d.ts +1 -0
- package/dist/views/Tasks.test.js +115 -0
- package/dist/views/useLandscape.d.ts +14 -0
- package/dist/views/useLandscape.js +74 -0
- package/dist/views/useLandscape.test.d.ts +1 -0
- package/dist/views/useLandscape.test.js +101 -0
- package/dist/views/wallboard.d.ts +46 -0
- package/dist/views/wallboard.js +209 -0
- package/dist/views/wallboard.test.d.ts +1 -0
- package/dist/views/wallboard.test.js +143 -0
- package/package.json +67 -67
- package/dist/lib/Dashboard-CB6SL-J2.js +0 -1064
- package/dist/selfhost/assets/index-BBx3vo2Y.js +0 -40
|
@@ -0,0 +1,75 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,115 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
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;
|
|
@@ -0,0 +1,74 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,101 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
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;
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// wallboard — pure helpers ported from packages/hub/public/app.js, the vanilla
|
|
3
|
+
// wallboard the React Tasks/Chat views replace. These mirror that module's
|
|
4
|
+
// behavior exactly so the board reads the same data the same way (functional
|
|
5
|
+
// parity). No DOM, no clock: every helper takes `nowMs` so countdowns/relative
|
|
6
|
+
// times are computed against the server's clock (landscape.serverTime), not the
|
|
7
|
+
// browser's, keeping them correct under skew — same as the old page did.
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
/** Human "N ago" for a past ISO timestamp, relative to nowMs (epoch ms). */
|
|
10
|
+
export function formatRelative(iso, nowMs) {
|
|
11
|
+
const secs = Math.floor((nowMs - Date.parse(iso)) / 1000);
|
|
12
|
+
if (secs < 5)
|
|
13
|
+
return "just now";
|
|
14
|
+
if (secs < 60)
|
|
15
|
+
return `${secs}s ago`;
|
|
16
|
+
const mins = Math.floor(secs / 60);
|
|
17
|
+
if (mins < 60)
|
|
18
|
+
return `${mins}m ago`;
|
|
19
|
+
const hrs = Math.floor(mins / 60);
|
|
20
|
+
if (hrs < 24)
|
|
21
|
+
return `${hrs}h ago`;
|
|
22
|
+
return `${Math.floor(hrs / 24)}d ago`;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Deterministic chat color for an agent name — same name always maps to the
|
|
26
|
+
* same hue, so each speaker reads consistently across the announcement thread.
|
|
27
|
+
*/
|
|
28
|
+
export function colorForName(name) {
|
|
29
|
+
let h = 0;
|
|
30
|
+
for (let i = 0; i < name.length; i++)
|
|
31
|
+
h = (h * 31 + name.charCodeAt(i)) % 360;
|
|
32
|
+
return `hsl(${h}, 38%, 42%)`;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Up to two initials for an avatar. Prefers the capital letters of a CamelCase
|
|
36
|
+
* agent name (RedDragon → RD); otherwise the first two letters (alice → AL).
|
|
37
|
+
*/
|
|
38
|
+
export function initialsFor(name) {
|
|
39
|
+
if (!name)
|
|
40
|
+
return "?";
|
|
41
|
+
const caps = name.match(/[A-Z]/g);
|
|
42
|
+
if (caps && caps.length >= 2)
|
|
43
|
+
return caps[0] + caps[1];
|
|
44
|
+
return name.slice(0, 2).toUpperCase();
|
|
45
|
+
}
|
|
46
|
+
/** Human label for a history task's status. */
|
|
47
|
+
export function statusLabel(status) {
|
|
48
|
+
return status === "dropped" ? "dropped" : "done";
|
|
49
|
+
}
|
|
50
|
+
/** "active Nm/Nh" for the created->ended span; "" when the task hasn't ended. */
|
|
51
|
+
export function formatActiveDuration(createdIso, endedIso) {
|
|
52
|
+
if (!endedIso)
|
|
53
|
+
return "";
|
|
54
|
+
const secs = Math.floor((Date.parse(endedIso) - Date.parse(createdIso)) / 1000);
|
|
55
|
+
const mins = Math.floor(secs / 60);
|
|
56
|
+
if (mins < 60)
|
|
57
|
+
return `active ${mins}m`;
|
|
58
|
+
return `active ${Math.floor(mins / 60)}h`;
|
|
59
|
+
}
|
|
60
|
+
/** Local-day bucket label: "Today" / "Yesterday" / "Mon D". */
|
|
61
|
+
export function dayBucket(iso, nowMs) {
|
|
62
|
+
const d = new Date(Date.parse(iso));
|
|
63
|
+
const now = new Date(nowMs);
|
|
64
|
+
const startOf = (x) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
|
|
65
|
+
const dayDiff = Math.round((startOf(now) - startOf(d)) / 86400000);
|
|
66
|
+
if (dayDiff <= 0)
|
|
67
|
+
return "Today";
|
|
68
|
+
if (dayDiff === 1)
|
|
69
|
+
return "Yesterday";
|
|
70
|
+
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
|
71
|
+
}
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Glob containment + active-claim grouping (verbatim from app.js, retyped)
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
/**
|
|
76
|
+
* Normalize a glob into a segment list. Mirrors the server's normalize
|
|
77
|
+
* (packages/hub/src/globs.ts) so the two agree on path shape.
|
|
78
|
+
*/
|
|
79
|
+
function normalizeGlob(pattern) {
|
|
80
|
+
let p = pattern.toLowerCase().replace(/\\/g, "/");
|
|
81
|
+
if (p.endsWith("/"))
|
|
82
|
+
p = p.replace(/\/+$/, "") + "/**";
|
|
83
|
+
const out = [];
|
|
84
|
+
for (const seg of p.split("/")) {
|
|
85
|
+
if (seg === "" || seg === ".")
|
|
86
|
+
continue;
|
|
87
|
+
if (seg === "..") {
|
|
88
|
+
if (out.length)
|
|
89
|
+
out.pop();
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
out.push(seg);
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
const SEG_WILDCARD = /[*?{}[\]]/;
|
|
97
|
+
/** Does single-segment pattern `a` cover single-segment pattern `b`? */
|
|
98
|
+
function segmentCovers(a, b) {
|
|
99
|
+
if (a === "*")
|
|
100
|
+
return true;
|
|
101
|
+
if (SEG_WILDCARD.test(a))
|
|
102
|
+
return a === b;
|
|
103
|
+
return !SEG_WILDCARD.test(b) && a === b;
|
|
104
|
+
}
|
|
105
|
+
/** Does pattern A (segments) cover pattern B (segments)? '**' consumes 0+ segments. */
|
|
106
|
+
function patternCovers(a, b) {
|
|
107
|
+
const memo = new Map();
|
|
108
|
+
function go(i, j) {
|
|
109
|
+
if (b.length - j === 0) {
|
|
110
|
+
for (let k = i; k < a.length; k++)
|
|
111
|
+
if (a[k] !== "**")
|
|
112
|
+
return false;
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
if (a.length - i === 0)
|
|
116
|
+
return false;
|
|
117
|
+
const key = i * (b.length + 1) + j;
|
|
118
|
+
const cached = memo.get(key);
|
|
119
|
+
if (cached !== undefined)
|
|
120
|
+
return cached;
|
|
121
|
+
const ah = a[i], bh = b[j];
|
|
122
|
+
let res;
|
|
123
|
+
if (ah === "**") {
|
|
124
|
+
res = go(i + 1, j) || go(i, j + 1);
|
|
125
|
+
}
|
|
126
|
+
else if (bh === "**") {
|
|
127
|
+
res = false;
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
res = segmentCovers(ah, bh) && go(i + 1, j + 1);
|
|
131
|
+
}
|
|
132
|
+
memo.set(key, res);
|
|
133
|
+
return res;
|
|
134
|
+
}
|
|
135
|
+
return go(0, 0);
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Does glob set `outer` fully cover glob set `inner`? Conservative: unsure → false,
|
|
139
|
+
* so a claim only folds when it is certainly contained. Empty `inner` is covered.
|
|
140
|
+
*/
|
|
141
|
+
export function globsCover(outer, inner) {
|
|
142
|
+
if (inner.length === 0)
|
|
143
|
+
return true;
|
|
144
|
+
const o = outer.map(normalizeGlob);
|
|
145
|
+
return inner.every((g) => {
|
|
146
|
+
const ng = normalizeGlob(g);
|
|
147
|
+
return o.some((op) => patternCovers(op, ng));
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Group active claims by agent for the board. Within an agent's claims, one that
|
|
152
|
+
* is STRICTLY covered by a broader sibling folds into `narrower`; everything else
|
|
153
|
+
* stays a visible `primary`. Groups + claims are newest-first; header fields come
|
|
154
|
+
* from the group's newest claim. Pure — no DOM, no clock.
|
|
155
|
+
*/
|
|
156
|
+
export function groupActiveClaims(tasks) {
|
|
157
|
+
const byAgent = new Map();
|
|
158
|
+
for (const t of tasks) {
|
|
159
|
+
if (!byAgent.has(t.agentName))
|
|
160
|
+
byAgent.set(t.agentName, []);
|
|
161
|
+
byAgent.get(t.agentName).push(t);
|
|
162
|
+
}
|
|
163
|
+
const newestFirst = (a, b) => b.createdAt.localeCompare(a.createdAt);
|
|
164
|
+
const groups = [];
|
|
165
|
+
for (const [agentName, claims] of byAgent) {
|
|
166
|
+
const sorted = [...claims].sort(newestFirst);
|
|
167
|
+
const isNarrower = sorted.map((c) => sorted.some((o) => o !== c &&
|
|
168
|
+
globsCover(o.pathGlobs, c.pathGlobs) &&
|
|
169
|
+
!globsCover(c.pathGlobs, o.pathGlobs)));
|
|
170
|
+
const head = sorted[0];
|
|
171
|
+
groups.push({
|
|
172
|
+
agentName,
|
|
173
|
+
model: head.model,
|
|
174
|
+
program: head.program,
|
|
175
|
+
repo: head.repo,
|
|
176
|
+
primaries: sorted.filter((_, i) => !isNarrower[i]),
|
|
177
|
+
narrower: sorted.filter((_, i) => isNarrower[i]),
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
groups.sort((g1, g2) => {
|
|
181
|
+
const t1 = g1.primaries.concat(g1.narrower)[0].createdAt;
|
|
182
|
+
const t2 = g2.primaries.concat(g2.narrower)[0].createdAt;
|
|
183
|
+
return t2.localeCompare(t1);
|
|
184
|
+
});
|
|
185
|
+
return groups;
|
|
186
|
+
}
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
// @mention target extraction (composer → DM direction). Ported from app.js so a
|
|
189
|
+
// typed @name still directs the message, matching the old send behavior. The
|
|
190
|
+
// autocomplete POPUP UI is intentionally not ported (visual polish, deferred).
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
/** Characters allowed in an agent-name mention token. */
|
|
193
|
+
const MENTION_CHARS = "A-Za-z0-9_-";
|
|
194
|
+
/**
|
|
195
|
+
* The first `@mention` in `text` matching a known agent name, returned in the
|
|
196
|
+
* name's canonical casing (`@reddragon` → `RedDragon`), or null when none match.
|
|
197
|
+
* An unmatched `@foo` is plain text and the message broadcasts.
|
|
198
|
+
*/
|
|
199
|
+
export function extractTarget(text, knownNames) {
|
|
200
|
+
const re = new RegExp(`(?:^|\\s)@([${MENTION_CHARS}]+)`, "g");
|
|
201
|
+
let m;
|
|
202
|
+
while ((m = re.exec(text)) !== null) {
|
|
203
|
+
const typed = m[1].toLowerCase();
|
|
204
|
+
const hit = knownNames.find((n) => n.toLowerCase() === typed);
|
|
205
|
+
if (hit)
|
|
206
|
+
return hit;
|
|
207
|
+
}
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|