@agent-native/dispatch 0.31.12 → 0.31.14
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/layout/Layout.d.ts +2 -1
- package/dist/components/layout/Layout.d.ts.map +1 -1
- package/dist/components/layout/Layout.js +3 -3
- 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-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/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/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/layout/Layout.spec.tsx +75 -0
- package/src/components/layout/Layout.tsx +22 -23
- package/src/components/other-apps-section.spec.ts +49 -0
- package/src/components/other-apps-section.tsx +31 -6
- package/src/components/workspace-app-host.spec.tsx +98 -0
- package/src/components/workspace-app-host.tsx +70 -4
- 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/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
|
@@ -220,6 +220,81 @@ describe("Dispatch NavContent", () => {
|
|
|
220
220
|
expect(lists[0].querySelector("a")?.className).toContain("size-9");
|
|
221
221
|
});
|
|
222
222
|
|
|
223
|
+
it.each([
|
|
224
|
+
{ mode: "standard", chatFirstMode: false, collapsed: false },
|
|
225
|
+
{ mode: "standard", chatFirstMode: false, collapsed: true },
|
|
226
|
+
{ mode: "chat-first", chatFirstMode: true, collapsed: false },
|
|
227
|
+
{ mode: "chat-first", chatFirstMode: true, collapsed: true },
|
|
228
|
+
])(
|
|
229
|
+
"reserves environment pill space in the $mode desktop footer when collapsed is $collapsed",
|
|
230
|
+
async ({ mode, chatFirstMode, collapsed }) => {
|
|
231
|
+
await act(async () => {
|
|
232
|
+
root.render(
|
|
233
|
+
<MemoryRouter
|
|
234
|
+
initialEntries={[chatFirstMode ? "/chat" : "/overview"]}
|
|
235
|
+
>
|
|
236
|
+
<TooltipProvider>
|
|
237
|
+
<NavContent
|
|
238
|
+
chatFirstMode={chatFirstMode}
|
|
239
|
+
collapsed={collapsed}
|
|
240
|
+
reserveEnvironmentBadgeSpace
|
|
241
|
+
/>
|
|
242
|
+
</TooltipProvider>
|
|
243
|
+
</MemoryRouter>,
|
|
244
|
+
);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
const footer = container.querySelector(
|
|
248
|
+
`[data-dispatch-sidebar-footer="${mode}"]`,
|
|
249
|
+
);
|
|
250
|
+
const adminLink = footer?.querySelector('a[href="/admin"]');
|
|
251
|
+
const settingsLink = footer?.querySelector('a[href="/settings"]');
|
|
252
|
+
const organization = [...(footer?.querySelectorAll("div") ?? [])].find(
|
|
253
|
+
(element) => element.textContent?.trim() === "Organization",
|
|
254
|
+
);
|
|
255
|
+
const footerActions = footer?.querySelector(
|
|
256
|
+
"[data-sidebar-footer-actions]",
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
expect(footer?.className).toContain("mt-auto");
|
|
260
|
+
expect(footer?.className).toContain("shrink-0");
|
|
261
|
+
expect(footer?.className).toContain("pb-10");
|
|
262
|
+
if (mode === "standard") {
|
|
263
|
+
expect(footer?.closest(".overflow-y-auto")).toBeNull();
|
|
264
|
+
}
|
|
265
|
+
expect(adminLink).not.toBeNull();
|
|
266
|
+
expect(settingsLink).not.toBeNull();
|
|
267
|
+
expect(organization).toBeDefined();
|
|
268
|
+
expect(footerActions).not.toBeNull();
|
|
269
|
+
expect(adminLink!.compareDocumentPosition(settingsLink!)).toBe(
|
|
270
|
+
Node.DOCUMENT_POSITION_FOLLOWING,
|
|
271
|
+
);
|
|
272
|
+
expect(settingsLink!.compareDocumentPosition(organization!)).toBe(
|
|
273
|
+
Node.DOCUMENT_POSITION_FOLLOWING,
|
|
274
|
+
);
|
|
275
|
+
expect(organization!.compareDocumentPosition(footerActions!)).toBe(
|
|
276
|
+
Node.DOCUMENT_POSITION_FOLLOWING,
|
|
277
|
+
);
|
|
278
|
+
},
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
it("reserves environment pill space in the mobile footer", async () => {
|
|
282
|
+
await act(async () => {
|
|
283
|
+
root.render(
|
|
284
|
+
<MemoryRouter initialEntries={["/overview"]}>
|
|
285
|
+
<TooltipProvider>
|
|
286
|
+
<NavContent reserveEnvironmentBadgeSpace />
|
|
287
|
+
</TooltipProvider>
|
|
288
|
+
</MemoryRouter>,
|
|
289
|
+
);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
expect(
|
|
293
|
+
container.querySelector('[data-dispatch-sidebar-footer="standard"]')
|
|
294
|
+
?.className,
|
|
295
|
+
).toContain("pb-10");
|
|
296
|
+
});
|
|
297
|
+
|
|
223
298
|
it("keeps chat-first primary actions in the collapsed sidebar", async () => {
|
|
224
299
|
await act(async () => {
|
|
225
300
|
root.render(
|
|
@@ -953,6 +953,7 @@ export function NavContent({
|
|
|
953
953
|
chatFirstEmbedded = false,
|
|
954
954
|
collapsed = false,
|
|
955
955
|
collapsible = false,
|
|
956
|
+
reserveEnvironmentBadgeSpace = false,
|
|
956
957
|
onCollapsedChange,
|
|
957
958
|
chatFirstAppLayout,
|
|
958
959
|
onChatFirstAppLayoutChange,
|
|
@@ -971,6 +972,7 @@ export function NavContent({
|
|
|
971
972
|
chatFirstEmbedded?: boolean;
|
|
972
973
|
collapsed?: boolean;
|
|
973
974
|
collapsible?: boolean;
|
|
975
|
+
reserveEnvironmentBadgeSpace?: boolean;
|
|
974
976
|
onCollapsedChange?: (collapsed: boolean) => void;
|
|
975
977
|
chatFirstAppLayout?: ChatFirstAppLayoutPreference;
|
|
976
978
|
onChatFirstAppLayoutChange?: (layout: ChatFirstAppLayoutPreference) => void;
|
|
@@ -1298,35 +1300,30 @@ export function NavContent({
|
|
|
1298
1300
|
/>
|
|
1299
1301
|
</div>
|
|
1300
1302
|
) : null}
|
|
1303
|
+
{!chatFirstMode ? (
|
|
1304
|
+
<div className="min-h-0 flex-1 overflow-y-auto">
|
|
1305
|
+
<nav className={cn("py-2", collapsed ? "px-1.5" : "px-2")}>
|
|
1306
|
+
<ul
|
|
1307
|
+
className={cn(
|
|
1308
|
+
collapsed ? "flex flex-col items-center gap-1" : "space-y-0.5",
|
|
1309
|
+
)}
|
|
1310
|
+
>
|
|
1311
|
+
{primaryNavItems.map(renderNavItem)}
|
|
1312
|
+
</ul>
|
|
1313
|
+
</nav>
|
|
1314
|
+
</div>
|
|
1315
|
+
) : null}
|
|
1301
1316
|
<div
|
|
1302
1317
|
className={cn(
|
|
1303
|
-
"
|
|
1304
|
-
|
|
1318
|
+
"mt-auto shrink-0",
|
|
1319
|
+
reserveEnvironmentBadgeSpace && "pb-10",
|
|
1305
1320
|
)}
|
|
1321
|
+
data-dispatch-sidebar-footer={chatFirstMode ? "chat-first" : "standard"}
|
|
1306
1322
|
>
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
className={cn(
|
|
1310
|
-
collapsed ? "flex flex-col items-center gap-1" : "space-y-0.5",
|
|
1311
|
-
)}
|
|
1312
|
-
>
|
|
1313
|
-
{primaryNavItems.map(renderNavItem)}
|
|
1314
|
-
</ul>
|
|
1315
|
-
</nav>
|
|
1316
|
-
|
|
1317
|
-
<div className="mt-auto shrink-0">
|
|
1318
|
-
{bottomNavigation}
|
|
1319
|
-
{organizationPicker}
|
|
1320
|
-
</div>
|
|
1323
|
+
{bottomNavigation}
|
|
1324
|
+
{organizationPicker}
|
|
1321
1325
|
{sidebarFooterActions}
|
|
1322
1326
|
</div>
|
|
1323
|
-
{chatFirstMode ? (
|
|
1324
|
-
<div className="mt-auto shrink-0">
|
|
1325
|
-
{bottomNavigation}
|
|
1326
|
-
{organizationPicker}
|
|
1327
|
-
{sidebarFooterActions}
|
|
1328
|
-
</div>
|
|
1329
|
-
) : null}
|
|
1330
1327
|
</>
|
|
1331
1328
|
);
|
|
1332
1329
|
}
|
|
@@ -2466,6 +2463,7 @@ export function Layout({
|
|
|
2466
2463
|
}}
|
|
2467
2464
|
onChatFirstAppsRetry={() => void chatFirstAppsQuery.refetch()}
|
|
2468
2465
|
collapsible
|
|
2466
|
+
reserveEnvironmentBadgeSpace
|
|
2469
2467
|
onCollapsedChange={setSidebarCollapsed}
|
|
2470
2468
|
/>
|
|
2471
2469
|
</aside>
|
|
@@ -2487,6 +2485,7 @@ export function Layout({
|
|
|
2487
2485
|
chatFirstMode={chatFirstMode}
|
|
2488
2486
|
chatFirstEmbedded={chatFirstEmbedded}
|
|
2489
2487
|
collapsed={false}
|
|
2488
|
+
reserveEnvironmentBadgeSpace
|
|
2490
2489
|
chatFirstAppLayout={chatFirstAppLayout}
|
|
2491
2490
|
onChatFirstAppLayoutChange={persistChatFirstAppLayout}
|
|
2492
2491
|
chatFirstApps={chatFirstAppItems}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
filterOtherAppEntries,
|
|
5
|
+
mergeOtherAppEntries,
|
|
6
|
+
otherAppEntryMatchesQuery,
|
|
7
|
+
} from "./other-apps-section.js";
|
|
8
|
+
|
|
9
|
+
describe("other app search", () => {
|
|
10
|
+
const entries = mergeOtherAppEntries({
|
|
11
|
+
templates: {
|
|
12
|
+
templates: [
|
|
13
|
+
{
|
|
14
|
+
id: "brain",
|
|
15
|
+
name: "Brain",
|
|
16
|
+
description: "Search cited company knowledge",
|
|
17
|
+
template: "brain",
|
|
18
|
+
liveUrl: "https://brain.agent-native.com",
|
|
19
|
+
},
|
|
20
|
+
],
|
|
21
|
+
},
|
|
22
|
+
connectedApps: [
|
|
23
|
+
{
|
|
24
|
+
id: "slides",
|
|
25
|
+
name: "Slides",
|
|
26
|
+
description: "Create presentations",
|
|
27
|
+
url: "https://slides.agent-native.com",
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
workspaceApps: [],
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("matches linked and curated apps by name or description", () => {
|
|
34
|
+
expect(
|
|
35
|
+
entries.filter((entry) => otherAppEntryMatchesQuery(entry, "brain")),
|
|
36
|
+
).toHaveLength(1);
|
|
37
|
+
expect(
|
|
38
|
+
entries.filter((entry) =>
|
|
39
|
+
otherAppEntryMatchesQuery(entry, "presentations"),
|
|
40
|
+
),
|
|
41
|
+
).toHaveLength(1);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("filters the Other apps entries used by the page search", () => {
|
|
45
|
+
expect(filterOtherAppEntries(entries, "brain")).toEqual([
|
|
46
|
+
expect.objectContaining({ kind: "template" }),
|
|
47
|
+
]);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
type WorkspaceTemplateLabels,
|
|
20
20
|
} from "./workspace-template-card";
|
|
21
21
|
|
|
22
|
-
type OtherAppEntry =
|
|
22
|
+
export type OtherAppEntry =
|
|
23
23
|
| { kind: "template"; template: CuratedWorkspaceTemplate }
|
|
24
24
|
| { kind: "connected"; app: ConnectedAppSummary };
|
|
25
25
|
|
|
@@ -36,6 +36,26 @@ function getTemplateItems(
|
|
|
36
36
|
return Array.isArray(result) ? result : result.templates;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
export function otherAppEntryMatchesQuery(
|
|
40
|
+
entry: OtherAppEntry,
|
|
41
|
+
query: string,
|
|
42
|
+
): boolean {
|
|
43
|
+
const normalizedQuery = query.trim().toLowerCase();
|
|
44
|
+
if (!normalizedQuery) return true;
|
|
45
|
+
const searchable =
|
|
46
|
+
entry.kind === "template"
|
|
47
|
+
? `${entry.template.name} ${entry.template.description ?? ""}`
|
|
48
|
+
: `${entry.app.name} ${entry.app.description ?? ""}`;
|
|
49
|
+
return searchable.toLowerCase().includes(normalizedQuery);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function filterOtherAppEntries(
|
|
53
|
+
entries: OtherAppEntry[],
|
|
54
|
+
query: string,
|
|
55
|
+
): OtherAppEntry[] {
|
|
56
|
+
return entries.filter((entry) => otherAppEntryMatchesQuery(entry, query));
|
|
57
|
+
}
|
|
58
|
+
|
|
39
59
|
export function mergeOtherAppEntries({
|
|
40
60
|
templates,
|
|
41
61
|
connectedApps,
|
|
@@ -86,6 +106,7 @@ export function OtherAppsSection({
|
|
|
86
106
|
onRetryConnectedApps,
|
|
87
107
|
templateLabels,
|
|
88
108
|
onRemixSuccess,
|
|
109
|
+
query = "",
|
|
89
110
|
heading = "Other apps",
|
|
90
111
|
embeddedInList = false,
|
|
91
112
|
className,
|
|
@@ -104,16 +125,20 @@ export function OtherAppsSection({
|
|
|
104
125
|
result: unknown,
|
|
105
126
|
template: CuratedWorkspaceTemplate,
|
|
106
127
|
) => void;
|
|
128
|
+
query?: string;
|
|
107
129
|
heading?: string | null;
|
|
108
130
|
embeddedInList?: boolean;
|
|
109
131
|
className?: string;
|
|
110
132
|
}) {
|
|
111
133
|
const t = useT();
|
|
112
|
-
const entries =
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
134
|
+
const entries = filterOtherAppEntries(
|
|
135
|
+
mergeOtherAppEntries({
|
|
136
|
+
templates,
|
|
137
|
+
connectedApps,
|
|
138
|
+
workspaceApps,
|
|
139
|
+
}),
|
|
140
|
+
query,
|
|
141
|
+
);
|
|
117
142
|
const isLoading = templatesLoading || connectedAppsLoading;
|
|
118
143
|
const hasError = Boolean(templatesError || connectedAppsError);
|
|
119
144
|
|
|
@@ -183,6 +183,9 @@ describe("WorkspaceAppKeepAlive", () => {
|
|
|
183
183
|
clientState.actionQueryParams.length = 0;
|
|
184
184
|
clientState.actionQueryOptions.length = 0;
|
|
185
185
|
clientState.workspaceApps = null;
|
|
186
|
+
clientState.legacyMutateAsync.mockImplementation(async () => ({
|
|
187
|
+
startUrl: "about:blank",
|
|
188
|
+
}));
|
|
186
189
|
clientState.legacyMutateAsync.mockClear();
|
|
187
190
|
clientState.legacyMutateError = null;
|
|
188
191
|
clientState.legacyErrorMutateAsync.mockReset();
|
|
@@ -469,6 +472,101 @@ describe("WorkspaceAppKeepAlive", () => {
|
|
|
469
472
|
);
|
|
470
473
|
});
|
|
471
474
|
|
|
475
|
+
it("reports trusted child routes to the standalone Dispatch host", async () => {
|
|
476
|
+
clientState.legacyMutateAsync.mockResolvedValue({
|
|
477
|
+
startUrl: "https://design.example.test/_agent-native/embed",
|
|
478
|
+
});
|
|
479
|
+
clientState.suppressFrameLoad = true;
|
|
480
|
+
const onChildRouteChange = vi.fn();
|
|
481
|
+
|
|
482
|
+
await act(async () => {
|
|
483
|
+
root.render(
|
|
484
|
+
<WorkspaceAppFrame
|
|
485
|
+
app={{
|
|
486
|
+
id: "design",
|
|
487
|
+
name: "Design",
|
|
488
|
+
path: "/",
|
|
489
|
+
url: "https://design.example.test",
|
|
490
|
+
}}
|
|
491
|
+
onChildRouteChange={onChildRouteChange}
|
|
492
|
+
/>,
|
|
493
|
+
);
|
|
494
|
+
await Promise.resolve();
|
|
495
|
+
await Promise.resolve();
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
const iframe = container.querySelector<HTMLIFrameElement>("iframe");
|
|
499
|
+
expect(iframe).not.toBeNull();
|
|
500
|
+
if (!iframe) throw new Error("Workspace app iframe was not rendered");
|
|
501
|
+
|
|
502
|
+
const frameWindow = iframe.contentWindow;
|
|
503
|
+
expect(frameWindow).not.toBeNull();
|
|
504
|
+
if (!frameWindow) throw new Error("Workspace app frame window is missing");
|
|
505
|
+
|
|
506
|
+
await act(async () => {
|
|
507
|
+
window.dispatchEvent(
|
|
508
|
+
new MessageEvent("message", {
|
|
509
|
+
data: {
|
|
510
|
+
type: "agent-native:workspace-app-route",
|
|
511
|
+
path: "/foobar?mode=edit#canvas",
|
|
512
|
+
},
|
|
513
|
+
origin: "https://design.example.test",
|
|
514
|
+
source: frameWindow,
|
|
515
|
+
}),
|
|
516
|
+
);
|
|
517
|
+
await Promise.resolve();
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
expect(onChildRouteChange).toHaveBeenCalledWith(
|
|
521
|
+
"/apps/design/foobar?mode=edit#canvas",
|
|
522
|
+
);
|
|
523
|
+
|
|
524
|
+
await act(async () => {
|
|
525
|
+
window.dispatchEvent(
|
|
526
|
+
new MessageEvent("message", {
|
|
527
|
+
data: {
|
|
528
|
+
type: "agent-native:workspace-app-route",
|
|
529
|
+
path: "/ignored",
|
|
530
|
+
},
|
|
531
|
+
origin: "https://evil.example.test",
|
|
532
|
+
source: frameWindow,
|
|
533
|
+
}),
|
|
534
|
+
);
|
|
535
|
+
window.dispatchEvent(
|
|
536
|
+
new MessageEvent("message", {
|
|
537
|
+
data: {
|
|
538
|
+
type: "agent-native:workspace-app-route",
|
|
539
|
+
path: "/ignored",
|
|
540
|
+
},
|
|
541
|
+
origin: "https://design.example.test",
|
|
542
|
+
source: {} as Window,
|
|
543
|
+
}),
|
|
544
|
+
);
|
|
545
|
+
await Promise.resolve();
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
expect(onChildRouteChange).toHaveBeenCalledTimes(1);
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
it("seeds a standalone iframe from its initial Dispatch route", async () => {
|
|
552
|
+
await act(async () => {
|
|
553
|
+
root.render(
|
|
554
|
+
<WorkspaceAppFrame
|
|
555
|
+
app={{ id: "design", name: "Design", path: "/design" }}
|
|
556
|
+
initialPath="/foobar?mode=edit#canvas"
|
|
557
|
+
/>,
|
|
558
|
+
);
|
|
559
|
+
await Promise.resolve();
|
|
560
|
+
await Promise.resolve();
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
expect(clientState.legacyMutateAsync).toHaveBeenCalledWith({
|
|
564
|
+
app: "design",
|
|
565
|
+
path: "/foobar?mode=edit#canvas",
|
|
566
|
+
chrome: "minimal",
|
|
567
|
+
});
|
|
568
|
+
});
|
|
569
|
+
|
|
472
570
|
it("clears the embed error after a direct fallback iframe loads", async () => {
|
|
473
571
|
clientState.legacyMutateError = new Error("Restore request failed");
|
|
474
572
|
clientState.legacyErrorMutateAsync.mockRejectedValue(
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
useActionQuery,
|
|
12
12
|
} from "@agent-native/core/client/hooks";
|
|
13
13
|
import { useT } from "@agent-native/core/client/i18n";
|
|
14
|
+
import { AGENT_NATIVE_WORKSPACE_APP_ROUTE_MESSAGE_TYPE } from "@agent-native/core/client/navigation";
|
|
14
15
|
import { withBuilderUtmTrackingParams } from "@agent-native/core/shared/builder-link-tracking";
|
|
15
16
|
import {
|
|
16
17
|
IconAlertTriangle,
|
|
@@ -34,6 +35,7 @@ import {
|
|
|
34
35
|
isWorkspaceSsoApp,
|
|
35
36
|
navigateToWorkspaceApp,
|
|
36
37
|
shouldOpenWorkspaceAppInTopWindow,
|
|
38
|
+
workspaceAppRouteForChildPath,
|
|
37
39
|
workspaceAppDirectHref,
|
|
38
40
|
workspaceAppEmbedTarget,
|
|
39
41
|
workspaceAppHref,
|
|
@@ -251,6 +253,10 @@ interface WorkspaceAppFrameProps {
|
|
|
251
253
|
navigateToTopWindow?: (href: string) => boolean | void;
|
|
252
254
|
/** Chat-first app tabs use their own route while standalone hosts use app metadata. */
|
|
253
255
|
embedPath?: string;
|
|
256
|
+
/** Standalone Dispatch routes seed the iframe once from their initial suffix. */
|
|
257
|
+
initialPath?: string;
|
|
258
|
+
/** Standalone Dispatch hosts mirror child route changes into the shell URL. */
|
|
259
|
+
onChildRouteChange?: (path: string) => void;
|
|
254
260
|
/** Chat-first app surfaces own the parent chat rail around the iframe. */
|
|
255
261
|
chatSidebar?: boolean;
|
|
256
262
|
copy?: ChatFirstCopy;
|
|
@@ -260,6 +266,8 @@ export function WorkspaceAppFrame({
|
|
|
260
266
|
app,
|
|
261
267
|
navigateToTopWindow = navigateToWorkspaceApp,
|
|
262
268
|
embedPath,
|
|
269
|
+
initialPath,
|
|
270
|
+
onChildRouteChange,
|
|
263
271
|
chatSidebar = false,
|
|
264
272
|
copy = defaultChatFirstCopy,
|
|
265
273
|
}: WorkspaceAppFrameProps) {
|
|
@@ -315,27 +323,36 @@ export function WorkspaceAppFrame({
|
|
|
315
323
|
embedPath,
|
|
316
324
|
);
|
|
317
325
|
}
|
|
326
|
+
if (initialPath !== undefined) {
|
|
327
|
+
return workspaceAppDirectHref(
|
|
328
|
+
{ path: app.path, url: app.url },
|
|
329
|
+
initialPath,
|
|
330
|
+
);
|
|
331
|
+
}
|
|
318
332
|
|
|
319
333
|
const target = workspaceAppEmbedTarget({
|
|
320
334
|
path: app.path ?? "",
|
|
321
335
|
url: app.url,
|
|
322
336
|
});
|
|
323
337
|
return target.url ?? target.path ?? null;
|
|
324
|
-
}, [app.path, app.url, embedPath]);
|
|
338
|
+
}, [app.path, app.url, embedPath, initialPath]);
|
|
325
339
|
const openInTopWindow = shouldOpenWorkspaceAppInTopWindow();
|
|
326
|
-
const topWindowSsoAttemptKey = `${app.id}\u0000${app.path ?? ""}\u0000${app.url ?? ""}\u0000${embedPath ?? ""}\u0000${embedAttempt}`;
|
|
340
|
+
const topWindowSsoAttemptKey = `${app.id}\u0000${app.path ?? ""}\u0000${app.url ?? ""}\u0000${embedPath ?? ""}\u0000${initialPath ?? ""}\u0000${embedAttempt}`;
|
|
327
341
|
const topWindowSsoAttemptedRef = useRef<string | null>(null);
|
|
328
342
|
const embedInput = useMemo<EmbedSessionInput | null>(() => {
|
|
329
343
|
if (embedPath !== undefined) {
|
|
330
344
|
return buildChatFirstEmbedSessionInput(app.id, embedPath);
|
|
331
345
|
}
|
|
346
|
+
if (initialPath !== undefined) {
|
|
347
|
+
return buildChatFirstEmbedSessionInput(app.id, initialPath);
|
|
348
|
+
}
|
|
332
349
|
if (!appHref) return null;
|
|
333
350
|
return {
|
|
334
351
|
app: app.id,
|
|
335
352
|
...workspaceAppEmbedTarget({ path: app.path ?? "", url: app.url }),
|
|
336
353
|
chrome: "minimal",
|
|
337
354
|
};
|
|
338
|
-
}, [app.id, app.path, app.url, appHref, embedPath]);
|
|
355
|
+
}, [app.id, app.path, app.url, appHref, embedPath, initialPath]);
|
|
339
356
|
|
|
340
357
|
useEffect(() => {
|
|
341
358
|
if (openInTopWindow && useWorkspaceSso && embedInput) {
|
|
@@ -425,7 +442,7 @@ export function WorkspaceAppFrame({
|
|
|
425
442
|
setEmbedUrl(
|
|
426
443
|
workspaceAppDirectHref(
|
|
427
444
|
{ path: app.path ?? "", url: app.url },
|
|
428
|
-
embedPath ?? "/",
|
|
445
|
+
initialPath ?? embedPath ?? "/",
|
|
429
446
|
),
|
|
430
447
|
);
|
|
431
448
|
setEmbedError(error);
|
|
@@ -441,6 +458,7 @@ export function WorkspaceAppFrame({
|
|
|
441
458
|
createWorkspaceSsoEmbedSession.mutateAsync,
|
|
442
459
|
embedInput,
|
|
443
460
|
embedPath,
|
|
461
|
+
initialPath,
|
|
444
462
|
embedAttempt,
|
|
445
463
|
openInTopWindow,
|
|
446
464
|
navigateToTopWindow,
|
|
@@ -464,6 +482,48 @@ export function WorkspaceAppFrame({
|
|
|
464
482
|
window.removeEventListener("message", handleEmbedSessionExpired);
|
|
465
483
|
}, [embedUrl]);
|
|
466
484
|
|
|
485
|
+
useEffect(() => {
|
|
486
|
+
if (!onChildRouteChange || !embedUrl) return;
|
|
487
|
+
|
|
488
|
+
const frame = embedFrameRef.current;
|
|
489
|
+
if (!frame) return;
|
|
490
|
+
|
|
491
|
+
let expectedOrigin: string;
|
|
492
|
+
try {
|
|
493
|
+
expectedOrigin = new URL(embedUrl, window.location.href).origin;
|
|
494
|
+
} catch {
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const handleWorkspaceAppRoute = (event: MessageEvent) => {
|
|
499
|
+
if (
|
|
500
|
+
event.source !== frame.contentWindow ||
|
|
501
|
+
event.origin !== expectedOrigin
|
|
502
|
+
) {
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
const message = event.data as {
|
|
506
|
+
type?: unknown;
|
|
507
|
+
path?: unknown;
|
|
508
|
+
} | null;
|
|
509
|
+
if (
|
|
510
|
+
message?.type !== AGENT_NATIVE_WORKSPACE_APP_ROUTE_MESSAGE_TYPE ||
|
|
511
|
+
typeof message.path !== "string"
|
|
512
|
+
) {
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const route = workspaceAppRouteForChildPath(
|
|
517
|
+
{ id: app.id, path: app.path ?? "", url: app.url },
|
|
518
|
+
message.path,
|
|
519
|
+
);
|
|
520
|
+
if (route) onChildRouteChange(route);
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
window.addEventListener("message", handleWorkspaceAppRoute);
|
|
524
|
+
return () => window.removeEventListener("message", handleWorkspaceAppRoute);
|
|
525
|
+
}, [app.id, app.path, app.url, embedUrl, onChildRouteChange]);
|
|
526
|
+
|
|
467
527
|
useEffect(() => {
|
|
468
528
|
postThemeToFrame();
|
|
469
529
|
}, [embedUrl, postThemeToFrame]);
|
|
@@ -514,9 +574,13 @@ export function WorkspaceAppFrame({
|
|
|
514
574
|
export function WorkspaceAppHost({
|
|
515
575
|
appId,
|
|
516
576
|
navigateToTopWindow = navigateToWorkspaceApp,
|
|
577
|
+
initialPath,
|
|
578
|
+
onChildRouteChange,
|
|
517
579
|
}: {
|
|
518
580
|
appId?: string;
|
|
519
581
|
navigateToTopWindow?: (href: string) => boolean | void;
|
|
582
|
+
initialPath?: string;
|
|
583
|
+
onChildRouteChange?: (path: string) => void;
|
|
520
584
|
}) {
|
|
521
585
|
const t = useT();
|
|
522
586
|
const workspaceAppsQuery = useActionQuery<WorkspaceAppSummary[]>(
|
|
@@ -702,6 +766,8 @@ export function WorkspaceAppHost({
|
|
|
702
766
|
<WorkspaceAppFrame
|
|
703
767
|
app={app}
|
|
704
768
|
navigateToTopWindow={navigateToTopWindow}
|
|
769
|
+
initialPath={initialPath}
|
|
770
|
+
onChildRouteChange={onChildRouteChange}
|
|
705
771
|
/>
|
|
706
772
|
</div>
|
|
707
773
|
</div>
|
|
@@ -8,6 +8,8 @@ import {
|
|
|
8
8
|
isWorkspaceSsoApp,
|
|
9
9
|
mergeChatFirstWorkspaceApps,
|
|
10
10
|
workspaceAppIdFromRoute,
|
|
11
|
+
workspaceAppInitialPathFromSplat,
|
|
12
|
+
workspaceAppRouteForChildPath,
|
|
11
13
|
workspaceAppDirectHref,
|
|
12
14
|
workspaceAppRoute,
|
|
13
15
|
} from "./workspace-apps";
|
|
@@ -29,6 +31,44 @@ describe("workspace app routes", () => {
|
|
|
29
31
|
expect(workspaceAppIdFromRoute("/apps/mail/inbox")).toBe("mail");
|
|
30
32
|
});
|
|
31
33
|
|
|
34
|
+
it("preserves the initial app suffix for a standalone deep link", () => {
|
|
35
|
+
expect(
|
|
36
|
+
workspaceAppInitialPathFromSplat("inbox", "?view=unread", "#top"),
|
|
37
|
+
).toBe("/inbox?view=unread#top");
|
|
38
|
+
expect(workspaceAppInitialPathFromSplat(undefined, "", "")).toBeUndefined();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("maps child routes to shareable Dispatch app routes", () => {
|
|
42
|
+
expect(
|
|
43
|
+
workspaceAppRouteForChildPath(
|
|
44
|
+
{ id: "design", path: "/", url: "https://design.example.test" },
|
|
45
|
+
"/foobar?mode=edit#canvas",
|
|
46
|
+
),
|
|
47
|
+
).toBe("/apps/design/foobar?mode=edit#canvas");
|
|
48
|
+
expect(
|
|
49
|
+
workspaceAppRouteForChildPath(
|
|
50
|
+
{
|
|
51
|
+
id: "internal-design",
|
|
52
|
+
path: "/design",
|
|
53
|
+
url: "https://workspace.example.test/design",
|
|
54
|
+
},
|
|
55
|
+
"/design/foobar",
|
|
56
|
+
),
|
|
57
|
+
).toBe("/apps/internal-design/foobar");
|
|
58
|
+
expect(
|
|
59
|
+
workspaceAppRouteForChildPath(
|
|
60
|
+
{ id: "design", path: "/", url: "https://design.example.test" },
|
|
61
|
+
"//evil.example/route",
|
|
62
|
+
),
|
|
63
|
+
).toBeNull();
|
|
64
|
+
expect(
|
|
65
|
+
workspaceAppDirectHref(
|
|
66
|
+
{ path: "/", url: "https://design.example.test" },
|
|
67
|
+
"/foobar?mode=edit#canvas",
|
|
68
|
+
),
|
|
69
|
+
).toBe("https://design.example.test/foobar?mode=edit#canvas");
|
|
70
|
+
});
|
|
71
|
+
|
|
32
72
|
it("identifies Dispatch regardless of casing or surrounding whitespace", () => {
|
|
33
73
|
expect(isDispatchWorkspaceAppId(" Dispatch ")).toBe(true);
|
|
34
74
|
expect(isDispatchWorkspaceAppId("dispatch-tools")).toBe(false);
|