@agent-native/dispatch 0.31.13 → 0.31.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/actions/list-dispatch-usage-metrics.js +9 -1
- package/dist/actions/list-dispatch-usage-metrics.js.map +1 -1
- package/dist/actions/provider-api-register.d.ts +12 -12
- package/dist/components/dispatch-control-plane.d.ts.map +1 -1
- package/dist/components/dispatch-control-plane.js +2 -2
- package/dist/components/dispatch-control-plane.js.map +1 -1
- package/dist/components/layout/Layout.d.ts +17 -4
- package/dist/components/layout/Layout.d.ts.map +1 -1
- package/dist/components/layout/Layout.js +45 -20
- package/dist/components/layout/Layout.js.map +1 -1
- package/dist/components/other-apps-section.d.ts +5 -3
- package/dist/components/other-apps-section.d.ts.map +1 -1
- package/dist/components/other-apps-section.js +15 -3
- package/dist/components/other-apps-section.js.map +1 -1
- package/dist/components/workspace-app-card.d.ts.map +1 -1
- package/dist/components/workspace-app-card.js +18 -1
- package/dist/components/workspace-app-card.js.map +1 -1
- package/dist/components/workspace-app-host.d.ts +8 -2
- package/dist/components/workspace-app-host.d.ts.map +1 -1
- package/dist/components/workspace-app-host.js +46 -8
- package/dist/components/workspace-app-host.js.map +1 -1
- package/dist/lib/catch-all-target.d.ts.map +1 -1
- package/dist/lib/catch-all-target.js +4 -2
- package/dist/lib/catch-all-target.js.map +1 -1
- package/dist/lib/workspace-apps.d.ts +7 -0
- package/dist/lib/workspace-apps.d.ts.map +1 -1
- package/dist/lib/workspace-apps.js +65 -0
- package/dist/lib/workspace-apps.js.map +1 -1
- package/dist/routes/index.js +1 -1
- package/dist/routes/index.js.map +1 -1
- package/dist/routes/pages/apps.$appId.d.ts.map +1 -1
- package/dist/routes/pages/apps.$appId.js +15 -4
- package/dist/routes/pages/apps.$appId.js.map +1 -1
- package/dist/routes/pages/apps.d.ts.map +1 -1
- package/dist/routes/pages/apps.js +16 -4
- package/dist/routes/pages/apps.js.map +1 -1
- package/dist/routes/pages/automations.d.ts.map +1 -1
- package/dist/routes/pages/automations.js +19 -16
- package/dist/routes/pages/automations.js.map +1 -1
- package/dist/server/lib/usage-metrics-store.d.ts +20 -0
- package/dist/server/lib/usage-metrics-store.d.ts.map +1 -1
- package/dist/server/lib/usage-metrics-store.js +86 -32
- package/dist/server/lib/usage-metrics-store.js.map +1 -1
- package/dist/server/plugins/agent-chat.js +3 -0
- package/dist/server/plugins/agent-chat.js.map +1 -1
- package/package.json +3 -3
- package/src/actions/index.spec.ts +17 -0
- package/src/actions/list-dispatch-usage-metrics.ts +9 -1
- package/src/components/dispatch-control-plane.spec.tsx +3 -1
- package/src/components/dispatch-control-plane.tsx +2 -1
- package/src/components/layout/Layout.spec.tsx +143 -25
- package/src/components/layout/Layout.tsx +79 -47
- package/src/components/other-apps-section.spec.ts +49 -0
- package/src/components/other-apps-section.tsx +31 -6
- package/src/components/workspace-app-card.tsx +27 -1
- package/src/components/workspace-app-host.spec.tsx +98 -0
- package/src/components/workspace-app-host.tsx +70 -4
- package/src/lib/catch-all-target.spec.ts +8 -0
- package/src/lib/catch-all-target.ts +4 -2
- package/src/lib/workspace-apps.spec.ts +40 -0
- package/src/lib/workspace-apps.ts +80 -0
- package/src/routes/index.spec.ts +10 -0
- package/src/routes/index.ts +1 -1
- package/src/routes/pages/apps.$appId.spec.tsx +50 -1
- package/src/routes/pages/apps.$appId.tsx +26 -3
- package/src/routes/pages/apps.tsx +42 -26
- package/src/routes/pages/automations.spec.tsx +240 -0
- package/src/routes/pages/automations.tsx +22 -16
- package/src/server/lib/usage-metrics-store.spec.ts +132 -1
- package/src/server/lib/usage-metrics-store.ts +173 -65
- package/src/server/plugins/agent-chat.spec.ts +2 -0
- package/src/server/plugins/agent-chat.ts +3 -0
- package/src/styles/dispatch-css.spec.ts +3 -0
- package/src/styles/dispatch.css +5 -0
|
@@ -18,7 +18,11 @@ import {
|
|
|
18
18
|
} from "../../components/app-list-row";
|
|
19
19
|
import { CreateAppPopover } from "../../components/create-app-popover";
|
|
20
20
|
import { DispatchShell } from "../../components/dispatch-shell";
|
|
21
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
mergeOtherAppEntries,
|
|
23
|
+
otherAppEntryMatchesQuery,
|
|
24
|
+
OtherAppsSection,
|
|
25
|
+
} from "../../components/other-apps-section";
|
|
22
26
|
import { Button } from "../../components/ui/button";
|
|
23
27
|
import {
|
|
24
28
|
Collapsible,
|
|
@@ -92,6 +96,24 @@ function AppsRoute() {
|
|
|
92
96
|
const activeApps = visibleApps.filter((app) => app.status !== "pending");
|
|
93
97
|
const pendingApps = visibleApps.filter((app) => app.status === "pending");
|
|
94
98
|
const archivedApps = allApps.filter((app) => app.archived);
|
|
99
|
+
const connectedApps =
|
|
100
|
+
(connectedAppsQuery.data as ConnectedAppSummary[] | undefined) ?? [];
|
|
101
|
+
const curatedTemplates = curatedTemplatesQuery.data as
|
|
102
|
+
| CuratedWorkspaceTemplatesResult
|
|
103
|
+
| undefined;
|
|
104
|
+
const otherAppEntries = mergeOtherAppEntries({
|
|
105
|
+
templates: curatedTemplates,
|
|
106
|
+
connectedApps,
|
|
107
|
+
workspaceApps: allApps,
|
|
108
|
+
});
|
|
109
|
+
const otherAppsSearchReady =
|
|
110
|
+
!connectedAppsQuery.isLoading && !curatedTemplatesQuery.isLoading;
|
|
111
|
+
const otherAppsMatchSearch =
|
|
112
|
+
!searchQuery.trim() ||
|
|
113
|
+
!otherAppsSearchReady ||
|
|
114
|
+
otherAppEntries.some((entry) =>
|
|
115
|
+
otherAppEntryMatchesQuery(entry, searchQuery),
|
|
116
|
+
);
|
|
95
117
|
const orderedActiveApps = orderWorkspaceApps(activeApps, layout);
|
|
96
118
|
const orderedPendingApps = orderWorkspaceApps(pendingApps, layout);
|
|
97
119
|
const orderedArchivedApps = orderWorkspaceApps(archivedApps, layout);
|
|
@@ -107,7 +129,8 @@ function AppsRoute() {
|
|
|
107
129
|
const hasSearchResults =
|
|
108
130
|
filteredActiveApps.length > 0 ||
|
|
109
131
|
filteredPendingApps.length > 0 ||
|
|
110
|
-
filteredArchivedApps.length > 0
|
|
132
|
+
filteredArchivedApps.length > 0 ||
|
|
133
|
+
otherAppsMatchSearch;
|
|
111
134
|
const showAppSkeletons = appsLoading && allApps.length === 0;
|
|
112
135
|
const templateLabels: WorkspaceTemplateLabels = {
|
|
113
136
|
appId: t("dispatch.pages.remixAppIdLabel"),
|
|
@@ -277,30 +300,23 @@ function AppsRoute() {
|
|
|
277
300
|
</Collapsible>
|
|
278
301
|
) : null}
|
|
279
302
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
templateLabels={templateLabels}
|
|
298
|
-
onRemixSuccess={() => {
|
|
299
|
-
void appsQuery.refetch();
|
|
300
|
-
void curatedTemplatesQuery.refetch();
|
|
301
|
-
}}
|
|
302
|
-
/>
|
|
303
|
-
) : null}
|
|
303
|
+
<OtherAppsSection
|
|
304
|
+
templates={curatedTemplates}
|
|
305
|
+
connectedApps={connectedApps}
|
|
306
|
+
workspaceApps={allApps}
|
|
307
|
+
query={searchQuery}
|
|
308
|
+
templatesLoading={curatedTemplatesQuery.isLoading}
|
|
309
|
+
connectedAppsLoading={connectedAppsQuery.isLoading}
|
|
310
|
+
templatesError={curatedTemplatesQuery.error}
|
|
311
|
+
connectedAppsError={connectedAppsQuery.error}
|
|
312
|
+
onRetryTemplates={() => void curatedTemplatesQuery.refetch()}
|
|
313
|
+
onRetryConnectedApps={() => void connectedAppsQuery.refetch()}
|
|
314
|
+
templateLabels={templateLabels}
|
|
315
|
+
onRemixSuccess={() => {
|
|
316
|
+
void appsQuery.refetch();
|
|
317
|
+
void curatedTemplatesQuery.refetch();
|
|
318
|
+
}}
|
|
319
|
+
/>
|
|
304
320
|
|
|
305
321
|
{archivedApps.length > 0 &&
|
|
306
322
|
(!searchQuery.trim() || filteredArchivedApps.length > 0) ? (
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { act, type ReactNode } from "react";
|
|
3
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
4
|
+
import { MemoryRouter, useLocation, useNavigate } from "react-router";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
|
|
7
|
+
import { automationIdentity } from "../../lib/automation-display";
|
|
8
|
+
import type { DispatchAutomationItem } from "../../lib/automations";
|
|
9
|
+
import AutomationsRoute from "./automations";
|
|
10
|
+
|
|
11
|
+
const automationA: DispatchAutomationItem = {
|
|
12
|
+
id: "auto-a",
|
|
13
|
+
name: "Nightly digest",
|
|
14
|
+
path: "jobs/nightly-digest.md",
|
|
15
|
+
owner: "user@example.com",
|
|
16
|
+
appId: "dispatch",
|
|
17
|
+
scope: "personal",
|
|
18
|
+
enabled: true,
|
|
19
|
+
triggerType: "schedule",
|
|
20
|
+
schedule: "0 6 * * *",
|
|
21
|
+
scheduleDescription: "Every day at 6am",
|
|
22
|
+
lastStatus: "success",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const automationB: DispatchAutomationItem = {
|
|
26
|
+
id: "auto-b",
|
|
27
|
+
name: "Error triage",
|
|
28
|
+
path: "jobs/error-triage.md",
|
|
29
|
+
owner: "user@example.com",
|
|
30
|
+
appId: "dispatch",
|
|
31
|
+
scope: "personal",
|
|
32
|
+
enabled: true,
|
|
33
|
+
triggerType: "event",
|
|
34
|
+
event: "issue.created",
|
|
35
|
+
lastStatus: "success",
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// Real @tanstack/react-query is not exercised here — the route reads its
|
|
39
|
+
// list straight from useQuery, so the hook itself is stubbed with a fixed
|
|
40
|
+
// two-item list rather than standing up a QueryClient + network mock.
|
|
41
|
+
vi.mock("@tanstack/react-query", () => ({
|
|
42
|
+
useQuery: ({ queryKey }: { queryKey: readonly unknown[] }) => {
|
|
43
|
+
if (String(queryKey[0]).includes("automations")) {
|
|
44
|
+
return {
|
|
45
|
+
data: [automationA, automationB],
|
|
46
|
+
isLoading: false,
|
|
47
|
+
isError: false,
|
|
48
|
+
error: null,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
return { data: undefined, isLoading: false, isError: false, error: null };
|
|
52
|
+
},
|
|
53
|
+
useMutation: () => ({
|
|
54
|
+
mutate: vi.fn(),
|
|
55
|
+
isPending: false,
|
|
56
|
+
variables: undefined,
|
|
57
|
+
}),
|
|
58
|
+
useQueryClient: () => ({
|
|
59
|
+
cancelQueries: vi.fn(),
|
|
60
|
+
getQueriesData: vi.fn(() => []),
|
|
61
|
+
setQueriesData: vi.fn(),
|
|
62
|
+
invalidateQueries: vi.fn(),
|
|
63
|
+
}),
|
|
64
|
+
}));
|
|
65
|
+
|
|
66
|
+
vi.mock("@agent-native/core/client/hooks", () => ({
|
|
67
|
+
useChangeVersions: () => 0,
|
|
68
|
+
}));
|
|
69
|
+
|
|
70
|
+
vi.mock("@agent-native/core/client/agent-chat", () => ({
|
|
71
|
+
sendToAgentChat: vi.fn(),
|
|
72
|
+
}));
|
|
73
|
+
|
|
74
|
+
vi.mock("@agent-native/core/client/composer", () => ({
|
|
75
|
+
PromptComposer: () => null,
|
|
76
|
+
}));
|
|
77
|
+
|
|
78
|
+
vi.mock("../../components/dispatch-shell", () => ({
|
|
79
|
+
DispatchShell: ({ children }: { children: ReactNode }) => (
|
|
80
|
+
<main>{children}</main>
|
|
81
|
+
),
|
|
82
|
+
}));
|
|
83
|
+
|
|
84
|
+
vi.mock("../../components/automation-details-panel", () => ({
|
|
85
|
+
AutomationDetailsPanel: ({
|
|
86
|
+
automation,
|
|
87
|
+
}: {
|
|
88
|
+
automation: DispatchAutomationItem;
|
|
89
|
+
}) => <div data-testid="details-panel">{automation.name}</div>,
|
|
90
|
+
}));
|
|
91
|
+
|
|
92
|
+
function LocationProbe() {
|
|
93
|
+
const location = useLocation();
|
|
94
|
+
return (
|
|
95
|
+
<div data-testid="location">{location.pathname + location.search}</div>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function GoBackProbe() {
|
|
100
|
+
const navigate = useNavigate();
|
|
101
|
+
return (
|
|
102
|
+
<button type="button" data-testid="go-back" onClick={() => navigate(-1)}>
|
|
103
|
+
Back
|
|
104
|
+
</button>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
describe("AutomationsRoute", () => {
|
|
109
|
+
let container: HTMLDivElement;
|
|
110
|
+
let root: Root;
|
|
111
|
+
|
|
112
|
+
beforeEach(() => {
|
|
113
|
+
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
|
114
|
+
container = document.createElement("div");
|
|
115
|
+
document.body.appendChild(container);
|
|
116
|
+
root = createRoot(container);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
afterEach(() => {
|
|
120
|
+
act(() => root.unmount());
|
|
121
|
+
container.remove();
|
|
122
|
+
vi.unstubAllGlobals();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
function locationText() {
|
|
126
|
+
return container.querySelector('[data-testid="location"]')?.textContent;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function findRowButton(name: string) {
|
|
130
|
+
return [...container.querySelectorAll("button")].find((button) =>
|
|
131
|
+
button.textContent?.includes(name),
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
it("writes the selected automation into the URL so it survives reload and back navigation", async () => {
|
|
136
|
+
await act(async () => {
|
|
137
|
+
root.render(
|
|
138
|
+
<MemoryRouter initialEntries={["/automations"]}>
|
|
139
|
+
<AutomationsRoute />
|
|
140
|
+
<LocationProbe />
|
|
141
|
+
</MemoryRouter>,
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
expect(locationText()).toBe("/automations");
|
|
146
|
+
|
|
147
|
+
const row = findRowButton(automationA.name);
|
|
148
|
+
expect(row).toBeTruthy();
|
|
149
|
+
|
|
150
|
+
await act(async () => {
|
|
151
|
+
row?.click();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
expect(
|
|
155
|
+
container.querySelector('[data-testid="details-panel"]')?.textContent,
|
|
156
|
+
).toBe(automationA.name);
|
|
157
|
+
// Selecting a row must push the identity into the address bar — a plain
|
|
158
|
+
// local-state selection leaves the URL unchanged, which is exactly the
|
|
159
|
+
// bug this test guards against (no way to link/reload/Back to a row).
|
|
160
|
+
expect(locationText()).toBe(
|
|
161
|
+
`/automations?automationId=${encodeURIComponent(automationIdentity(automationA))}`,
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
const otherRow = findRowButton(automationB.name);
|
|
165
|
+
await act(async () => {
|
|
166
|
+
otherRow?.click();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
expect(locationText()).toBe(
|
|
170
|
+
`/automations?automationId=${encodeURIComponent(automationIdentity(automationB))}`,
|
|
171
|
+
);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("pushes a history entry per row selection so Back steps through prior selections", async () => {
|
|
175
|
+
await act(async () => {
|
|
176
|
+
root.render(
|
|
177
|
+
<MemoryRouter initialEntries={["/automations"]}>
|
|
178
|
+
<AutomationsRoute />
|
|
179
|
+
<LocationProbe />
|
|
180
|
+
<GoBackProbe />
|
|
181
|
+
</MemoryRouter>,
|
|
182
|
+
);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const goBack = container.querySelector<HTMLButtonElement>(
|
|
186
|
+
'[data-testid="go-back"]',
|
|
187
|
+
);
|
|
188
|
+
expect(goBack).toBeTruthy();
|
|
189
|
+
|
|
190
|
+
await act(async () => {
|
|
191
|
+
findRowButton(automationA.name)?.click();
|
|
192
|
+
});
|
|
193
|
+
expect(locationText()).toBe(
|
|
194
|
+
`/automations?automationId=${encodeURIComponent(automationIdentity(automationA))}`,
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
await act(async () => {
|
|
198
|
+
findRowButton(automationB.name)?.click();
|
|
199
|
+
});
|
|
200
|
+
expect(locationText()).toBe(
|
|
201
|
+
`/automations?automationId=${encodeURIComponent(automationIdentity(automationB))}`,
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
// A row click is an explicit user selection, not URL canonicalization —
|
|
205
|
+
// it must push a new history entry so Back steps back through the prior
|
|
206
|
+
// selection (A) instead of skipping past the whole page's history in one
|
|
207
|
+
// jump. Replacing on every click (the bug this guards against) collapses
|
|
208
|
+
// all selections into a single entry.
|
|
209
|
+
await act(async () => {
|
|
210
|
+
goBack?.click();
|
|
211
|
+
});
|
|
212
|
+
expect(locationText()).toBe(
|
|
213
|
+
`/automations?automationId=${encodeURIComponent(automationIdentity(automationA))}`,
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
await act(async () => {
|
|
217
|
+
goBack?.click();
|
|
218
|
+
});
|
|
219
|
+
expect(locationText()).toBe("/automations");
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("reopens the matching detail panel when automationId is already in the URL", async () => {
|
|
223
|
+
const targetId = automationIdentity(automationB);
|
|
224
|
+
await act(async () => {
|
|
225
|
+
root.render(
|
|
226
|
+
<MemoryRouter
|
|
227
|
+
initialEntries={[
|
|
228
|
+
`/automations?automationId=${encodeURIComponent(targetId)}`,
|
|
229
|
+
]}
|
|
230
|
+
>
|
|
231
|
+
<AutomationsRoute />
|
|
232
|
+
</MemoryRouter>,
|
|
233
|
+
);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
expect(
|
|
237
|
+
container.querySelector('[data-testid="details-panel"]')?.textContent,
|
|
238
|
+
).toBe(automationB.name);
|
|
239
|
+
});
|
|
240
|
+
});
|
|
@@ -9,8 +9,8 @@ import {
|
|
|
9
9
|
IconSettingsAutomation,
|
|
10
10
|
} from "@tabler/icons-react";
|
|
11
11
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
12
|
-
import {
|
|
13
|
-
import { Link } from "react-router";
|
|
12
|
+
import { useMemo, useState } from "react";
|
|
13
|
+
import { Link, useSearchParams } from "react-router";
|
|
14
14
|
import { toast } from "sonner";
|
|
15
15
|
|
|
16
16
|
import { AutomationDetailsPanel } from "../../components/automation-details-panel";
|
|
@@ -189,10 +189,9 @@ function CreateAutomationButton() {
|
|
|
189
189
|
}
|
|
190
190
|
|
|
191
191
|
export default function AutomationsRoute() {
|
|
192
|
+
const [searchParams, setSearchParams] = useSearchParams();
|
|
192
193
|
const [view, setView] = useState<"dispatch" | "all">("dispatch");
|
|
193
194
|
const [query, setQuery] = useState("");
|
|
194
|
-
const [detailsTarget, setDetailsTarget] =
|
|
195
|
-
useState<DispatchAutomationItem | null>(null);
|
|
196
195
|
const automationsQuery = useAutomations();
|
|
197
196
|
const toggleAutomation = useToggleAutomation();
|
|
198
197
|
const automations = automationsQuery.data ?? [];
|
|
@@ -238,17 +237,24 @@ export default function AutomationsRoute() {
|
|
|
238
237
|
: null
|
|
239
238
|
: null;
|
|
240
239
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
240
|
+
// URL-backed selection (mirrors dreams.tsx's `?dreamId=`): the currently
|
|
241
|
+
// open automation lives in `automationId` so it survives reload, Back, and
|
|
242
|
+
// sharing a link, instead of vanishing local state.
|
|
243
|
+
const selectedAutomationId = searchParams.get("automationId");
|
|
244
|
+
const detailsTarget = selectedAutomationId
|
|
245
|
+
? (filtered.find(
|
|
246
|
+
(item) => automationIdentity(item) === selectedAutomationId,
|
|
247
|
+
) ?? null)
|
|
248
|
+
: null;
|
|
249
|
+
|
|
250
|
+
function selectAutomation(item: DispatchAutomationItem) {
|
|
251
|
+
// Push, don't replace: each row click is an explicit selection the user
|
|
252
|
+
// should be able to Back out of one step at a time, not a URL
|
|
253
|
+
// canonicalization that should collapse into the current entry.
|
|
254
|
+
const next = new URLSearchParams(searchParams);
|
|
255
|
+
next.set("automationId", automationIdentity(item));
|
|
256
|
+
setSearchParams(next);
|
|
257
|
+
}
|
|
252
258
|
|
|
253
259
|
return (
|
|
254
260
|
<DispatchShell
|
|
@@ -334,7 +340,7 @@ export default function AutomationsRoute() {
|
|
|
334
340
|
type="button"
|
|
335
341
|
className="w-full min-w-0 cursor-pointer text-left outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card"
|
|
336
342
|
aria-pressed={isSelected}
|
|
337
|
-
onClick={() =>
|
|
343
|
+
onClick={() => selectAutomation(item)}
|
|
338
344
|
>
|
|
339
345
|
<div className="flex min-w-0 items-center gap-2">
|
|
340
346
|
<StatusDot tone={status.tone} />
|
|
@@ -33,6 +33,7 @@ vi.mock("@agent-native/core/settings", () => ({
|
|
|
33
33
|
}));
|
|
34
34
|
|
|
35
35
|
vi.mock("@agent-native/core/usage", () => ({
|
|
36
|
+
builderCreditsFromCostCents: (cents: number) => cents / 4,
|
|
36
37
|
getUsageSummary: (...args: any[]) => mocks.getUsageSummary(...args),
|
|
37
38
|
usageBillingForEngine: () => ({
|
|
38
39
|
unit: "usd",
|
|
@@ -101,7 +102,7 @@ describe("listDispatchUsageMetrics", () => {
|
|
|
101
102
|
orgId: null,
|
|
102
103
|
role: null,
|
|
103
104
|
scope: "solo",
|
|
104
|
-
totalUsers:
|
|
105
|
+
totalUsers: 1,
|
|
105
106
|
});
|
|
106
107
|
expect(metrics.totals).toEqual({
|
|
107
108
|
costCents: 0,
|
|
@@ -195,6 +196,35 @@ describe("listDispatchUsageMetrics", () => {
|
|
|
195
196
|
).toBe(true);
|
|
196
197
|
});
|
|
197
198
|
|
|
199
|
+
it("keeps an unaffiliated workspace request scoped to the signed-in user", async () => {
|
|
200
|
+
mocks.getUsageSummary.mockResolvedValue(null);
|
|
201
|
+
mocks.listWorkspaceApps.mockResolvedValue([]);
|
|
202
|
+
mocks.execute.mockResolvedValue({ rows: [] });
|
|
203
|
+
|
|
204
|
+
const metrics = await listDispatchUsageMetrics({
|
|
205
|
+
sinceDays: 30,
|
|
206
|
+
scope: "workspace",
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
expect(metrics.access).toMatchObject({
|
|
210
|
+
viewerEmail: "owner@example.test",
|
|
211
|
+
orgId: null,
|
|
212
|
+
scope: "solo",
|
|
213
|
+
totalUsers: 1,
|
|
214
|
+
});
|
|
215
|
+
const scopedTokenQueries = mocks.execute.mock.calls
|
|
216
|
+
.map(([query]) => String((query as { sql?: string }).sql))
|
|
217
|
+
.filter(
|
|
218
|
+
(sql) => sql.includes("FROM token_usage") && sql.includes("WHERE"),
|
|
219
|
+
);
|
|
220
|
+
expect(scopedTokenQueries.length).toBeGreaterThan(0);
|
|
221
|
+
expect(
|
|
222
|
+
scopedTokenQueries.every((sql) =>
|
|
223
|
+
sql.includes("LOWER(owner_email) IN (?)"),
|
|
224
|
+
),
|
|
225
|
+
).toBe(true);
|
|
226
|
+
});
|
|
227
|
+
|
|
198
228
|
it("filters workspace usage and prompts to a selected member", async () => {
|
|
199
229
|
mocks.currentOrgId.mockReturnValue("org-a");
|
|
200
230
|
mocks.currentOwnerEmail.mockReturnValue("owner@example.test");
|
|
@@ -237,4 +267,105 @@ describe("listDispatchUsageMetrics", () => {
|
|
|
237
267
|
}),
|
|
238
268
|
).toBe(true);
|
|
239
269
|
});
|
|
270
|
+
|
|
271
|
+
it("returns monthly credits and workspace app creation rows from shared tables", async () => {
|
|
272
|
+
const firstUsageAt = Date.UTC(2026, 6, 1, 12);
|
|
273
|
+
const secondUsageAt = Date.UTC(2026, 6, 15, 12);
|
|
274
|
+
const firstCreationAt = Date.UTC(2026, 6, 2, 12);
|
|
275
|
+
const secondCreationAt = Date.UTC(2026, 6, 20, 12);
|
|
276
|
+
|
|
277
|
+
mocks.currentOrgId.mockReturnValue("org-a");
|
|
278
|
+
mocks.currentOwnerEmail.mockReturnValue("owner@example.test");
|
|
279
|
+
mocks.getUsageSummary.mockResolvedValue(null);
|
|
280
|
+
mocks.listWorkspaceApps.mockResolvedValue([]);
|
|
281
|
+
mocks.execute.mockImplementation(async ({ sql }: { sql: string }) => {
|
|
282
|
+
if (sql.includes("SELECT role FROM org_members")) {
|
|
283
|
+
return { rows: [{ role: "owner" }] };
|
|
284
|
+
}
|
|
285
|
+
if (sql.includes("SELECT email, role, joined_at")) {
|
|
286
|
+
return {
|
|
287
|
+
rows: [
|
|
288
|
+
{ email: "owner@example.test", role: "owner", joined_at: null },
|
|
289
|
+
{ email: "member@example.test", role: "member", joined_at: null },
|
|
290
|
+
],
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
if (
|
|
294
|
+
sql.includes("FROM token_usage") &&
|
|
295
|
+
sql.includes("ORDER BY created_at ASC")
|
|
296
|
+
) {
|
|
297
|
+
return {
|
|
298
|
+
rows: [
|
|
299
|
+
{
|
|
300
|
+
created_at: firstUsageAt,
|
|
301
|
+
owner_email: "member@example.test",
|
|
302
|
+
label: "chat",
|
|
303
|
+
input_tokens: 10,
|
|
304
|
+
output_tokens: 20,
|
|
305
|
+
cache_read_tokens: 0,
|
|
306
|
+
cache_write_tokens: 0,
|
|
307
|
+
cost_cents_x100: 10000,
|
|
308
|
+
},
|
|
309
|
+
{
|
|
310
|
+
created_at: secondUsageAt,
|
|
311
|
+
owner_email: "member@example.test",
|
|
312
|
+
label: "tool",
|
|
313
|
+
input_tokens: 30,
|
|
314
|
+
output_tokens: 40,
|
|
315
|
+
cache_read_tokens: 5,
|
|
316
|
+
cache_write_tokens: 2,
|
|
317
|
+
cost_cents_x100: 20000,
|
|
318
|
+
},
|
|
319
|
+
],
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
if (sql.includes("FROM dispatch_audit_events")) {
|
|
323
|
+
return {
|
|
324
|
+
rows: [
|
|
325
|
+
{
|
|
326
|
+
created_at: firstCreationAt,
|
|
327
|
+
owner_email: "member@example.test",
|
|
328
|
+
actor: "member@example.test",
|
|
329
|
+
target_id: "app-one",
|
|
330
|
+
},
|
|
331
|
+
{
|
|
332
|
+
created_at: secondCreationAt,
|
|
333
|
+
owner_email: "member@example.test",
|
|
334
|
+
actor: "member@example.test",
|
|
335
|
+
target_id: "app-two",
|
|
336
|
+
},
|
|
337
|
+
],
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
return { rows: [] };
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
const metrics = await listDispatchUsageMetrics({
|
|
344
|
+
sinceDays: 365,
|
|
345
|
+
scope: "workspace",
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
expect(metrics.monthlyByUser).toEqual([
|
|
349
|
+
{
|
|
350
|
+
month: "2026-07",
|
|
351
|
+
ownerEmail: "member@example.test",
|
|
352
|
+
costCents: 300,
|
|
353
|
+
credits: 75,
|
|
354
|
+
calls: 2,
|
|
355
|
+
chatCalls: 1,
|
|
356
|
+
inputTokens: 40,
|
|
357
|
+
outputTokens: 60,
|
|
358
|
+
cacheReadTokens: 5,
|
|
359
|
+
cacheWriteTokens: 2,
|
|
360
|
+
},
|
|
361
|
+
]);
|
|
362
|
+
expect(metrics.workspaceAppCreationsByUserMonth).toEqual([
|
|
363
|
+
{
|
|
364
|
+
month: "2026-07",
|
|
365
|
+
ownerEmail: "member@example.test",
|
|
366
|
+
count: 2,
|
|
367
|
+
appIds: ["app-one", "app-two"],
|
|
368
|
+
},
|
|
369
|
+
]);
|
|
370
|
+
});
|
|
240
371
|
});
|