@agent-native/dispatch 0.31.13 → 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/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/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
|
@@ -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);
|
|
@@ -149,6 +149,86 @@ export function workspaceAppRoute(appId: string): string {
|
|
|
149
149
|
return `/apps/${encodeURIComponent(appId)}`;
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
+
function normalizeWorkspaceAppRoutePath(rawPath: string): URL | null {
|
|
153
|
+
if (
|
|
154
|
+
typeof rawPath !== "string" ||
|
|
155
|
+
!rawPath.startsWith("/") ||
|
|
156
|
+
rawPath.startsWith("//") ||
|
|
157
|
+
/[\u0000-\u001f\u007f]/.test(rawPath)
|
|
158
|
+
) {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
return new URL(rawPath, "https://agent-native.invalid");
|
|
164
|
+
} catch {
|
|
165
|
+
// coercion-ok: malformed child routes are ignored by the host.
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function normalizedWorkspaceAppMountPath(rawPath: string): string {
|
|
171
|
+
const pathname = rawPath.split(/[?#]/, 1)[0] ?? rawPath;
|
|
172
|
+
const normalized = `/${pathname.replace(/^[/\\]+/, "")}`;
|
|
173
|
+
return normalized.replace(/\/+$/, "") || "/";
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function workspaceAppMountPath(
|
|
177
|
+
app: Pick<WorkspaceAppSummary, "path" | "url">,
|
|
178
|
+
): string {
|
|
179
|
+
const rawUrl = app.url?.trim();
|
|
180
|
+
if (rawUrl) {
|
|
181
|
+
try {
|
|
182
|
+
const parsed = new URL(rawUrl);
|
|
183
|
+
if (parsed.protocol === "http:" || parsed.protocol === "https:") {
|
|
184
|
+
return normalizedWorkspaceAppMountPath(parsed.pathname);
|
|
185
|
+
}
|
|
186
|
+
// coercion-ok: invalid URL falls back to app path.
|
|
187
|
+
} catch {
|
|
188
|
+
// Fall through to the mounted path when optional URL metadata is invalid.
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return normalizedWorkspaceAppMountPath(app.path?.trim() || "/");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Convert a child app route into Dispatch's shareable workspace-app route.
|
|
196
|
+
* Mounted workspace apps may report their full mount path, while hosted apps
|
|
197
|
+
* normally report only the app-local path, so accept both forms here.
|
|
198
|
+
*/
|
|
199
|
+
export function workspaceAppRouteForChildPath(
|
|
200
|
+
app: Pick<WorkspaceAppSummary, "id" | "path" | "url">,
|
|
201
|
+
childPath: string,
|
|
202
|
+
): string | null {
|
|
203
|
+
const parsed = normalizeWorkspaceAppRoutePath(childPath);
|
|
204
|
+
if (!parsed) return null;
|
|
205
|
+
|
|
206
|
+
const mountPath = workspaceAppMountPath(app);
|
|
207
|
+
const relativePathname =
|
|
208
|
+
mountPath !== "/" &&
|
|
209
|
+
(parsed.pathname === mountPath ||
|
|
210
|
+
parsed.pathname.startsWith(`${mountPath}/`))
|
|
211
|
+
? parsed.pathname.slice(mountPath.length) || "/"
|
|
212
|
+
: parsed.pathname;
|
|
213
|
+
const routePath = workspaceAppRoute(app.id);
|
|
214
|
+
const suffix = relativePathname === "/" ? "" : relativePathname;
|
|
215
|
+
return `${routePath}${suffix}${parsed.search}${parsed.hash}`;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function workspaceAppInitialPathFromSplat(
|
|
219
|
+
routeSplat: string | undefined,
|
|
220
|
+
search: string,
|
|
221
|
+
hash: string,
|
|
222
|
+
): string | undefined {
|
|
223
|
+
const pathname = routeSplat?.trim()
|
|
224
|
+
? `/${routeSplat.trim().replace(/^[/\\]+/, "")}`
|
|
225
|
+
: "/";
|
|
226
|
+
const parsed = normalizeWorkspaceAppRoutePath(`${pathname}${search}${hash}`);
|
|
227
|
+
if (!parsed) return undefined;
|
|
228
|
+
const path = `${parsed.pathname}${parsed.search}${parsed.hash}`;
|
|
229
|
+
return path === "/" ? undefined : path;
|
|
230
|
+
}
|
|
231
|
+
|
|
152
232
|
export function workspaceAppIdFromRoute(pathname: string): string | null {
|
|
153
233
|
const match = pathname.match(/^\/apps\/([^/]+)(?:\/|$)/);
|
|
154
234
|
if (!match) return null;
|
package/src/routes/index.spec.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { matchRoutes } from "react-router";
|
|
1
2
|
import { describe, expect, it } from "vitest";
|
|
2
3
|
|
|
3
4
|
import { dispatchRoutes } from "./index.js";
|
|
@@ -19,6 +20,7 @@ describe("Dispatch route registration", () => {
|
|
|
19
20
|
expect(paths).toContain("operations");
|
|
20
21
|
expect(paths).toContain("admin");
|
|
21
22
|
expect(paths).toContain("agents");
|
|
23
|
+
expect(paths).toContain("apps/:appId/*");
|
|
22
24
|
expect(paths.indexOf("chat")).toBeLessThan(paths.indexOf(":appId"));
|
|
23
25
|
expect(paths.indexOf("chat/:threadId")).toBeLessThan(
|
|
24
26
|
paths.indexOf(":appId"),
|
|
@@ -36,4 +38,12 @@ describe("Dispatch route registration", () => {
|
|
|
36
38
|
expect(adminPaths).not.toContain("apps/:appId");
|
|
37
39
|
expect(adminPaths.indexOf("operations")).toBeGreaterThanOrEqual(0);
|
|
38
40
|
});
|
|
41
|
+
|
|
42
|
+
it("matches both workspace-app roots and nested deep links", () => {
|
|
43
|
+
for (const path of ["/apps/design", "/apps/design/foobar"]) {
|
|
44
|
+
expect(matchRoutes(dispatchRoutes, path)?.at(-1)?.route.path).toBe(
|
|
45
|
+
"apps/:appId/*",
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
39
49
|
});
|
package/src/routes/index.ts
CHANGED
|
@@ -62,7 +62,7 @@ export const dispatchRoutes: RouteConfig = [
|
|
|
62
62
|
route("metrics", "./pages/metrics.js"),
|
|
63
63
|
route("operations", "./pages/operations.js"),
|
|
64
64
|
route("apps", "./pages/apps.js"),
|
|
65
|
-
route("apps/:appId", "./pages/apps.$appId.js"),
|
|
65
|
+
route("apps/:appId/*", "./pages/apps.$appId.js"),
|
|
66
66
|
route("new-app", "./pages/new-app.js"),
|
|
67
67
|
route("vault", "./pages/vault.js"),
|
|
68
68
|
route("integrations", "./pages/integrations.js"),
|
|
@@ -5,15 +5,22 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
5
5
|
|
|
6
6
|
const routeState = vi.hoisted(() => ({
|
|
7
7
|
appId: "mail",
|
|
8
|
+
routeSplat: undefined as string | undefined,
|
|
9
|
+
location: { pathname: "/apps/mail", search: "", hash: "" },
|
|
8
10
|
hostProps: null as {
|
|
9
11
|
appId?: string;
|
|
10
12
|
navigateToTopWindow?: (href: string) => boolean | void;
|
|
13
|
+
initialPath?: string;
|
|
14
|
+
onChildRouteChange?: (path: string) => void;
|
|
11
15
|
} | null,
|
|
12
16
|
navigateToWorkspaceApp: vi.fn(() => true),
|
|
17
|
+
navigate: vi.fn(),
|
|
13
18
|
}));
|
|
14
19
|
|
|
15
20
|
vi.mock("react-router", () => ({
|
|
16
|
-
useParams: () => ({ appId: routeState.appId }),
|
|
21
|
+
useParams: () => ({ appId: routeState.appId, "*": routeState.routeSplat }),
|
|
22
|
+
useLocation: () => routeState.location,
|
|
23
|
+
useNavigate: () => routeState.navigate,
|
|
17
24
|
}));
|
|
18
25
|
|
|
19
26
|
vi.mock("../../components/workspace-app-host", () => ({
|
|
@@ -25,6 +32,14 @@ vi.mock("../../components/workspace-app-host", () => ({
|
|
|
25
32
|
|
|
26
33
|
vi.mock("../../lib/workspace-apps", () => ({
|
|
27
34
|
navigateToWorkspaceApp: routeState.navigateToWorkspaceApp,
|
|
35
|
+
workspaceAppInitialPathFromSplat: (
|
|
36
|
+
routeSplat: string | undefined,
|
|
37
|
+
search: string,
|
|
38
|
+
hash: string,
|
|
39
|
+
) =>
|
|
40
|
+
routeSplat || search || hash
|
|
41
|
+
? `/${routeSplat ?? ""}${search}${hash}`
|
|
42
|
+
: undefined,
|
|
28
43
|
}));
|
|
29
44
|
|
|
30
45
|
import WorkspaceAppRoute from "./apps.$appId";
|
|
@@ -36,8 +51,11 @@ describe("WorkspaceAppRoute", () => {
|
|
|
36
51
|
beforeEach(() => {
|
|
37
52
|
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
|
38
53
|
routeState.appId = "mail";
|
|
54
|
+
routeState.routeSplat = undefined;
|
|
55
|
+
routeState.location = { pathname: "/apps/mail", search: "", hash: "" };
|
|
39
56
|
routeState.hostProps = null;
|
|
40
57
|
routeState.navigateToWorkspaceApp.mockClear();
|
|
58
|
+
routeState.navigate.mockClear();
|
|
41
59
|
container = document.createElement("div");
|
|
42
60
|
document.body.appendChild(container);
|
|
43
61
|
root = createRoot(container);
|
|
@@ -63,4 +81,35 @@ describe("WorkspaceAppRoute", () => {
|
|
|
63
81
|
|
|
64
82
|
expect(routeState.navigateToWorkspaceApp).toHaveBeenCalledWith("/mail");
|
|
65
83
|
});
|
|
84
|
+
|
|
85
|
+
it("keeps the iframe synchronized with standalone deep-link navigation", async () => {
|
|
86
|
+
routeState.routeSplat = "foobar";
|
|
87
|
+
routeState.location = {
|
|
88
|
+
pathname: "/apps/mail/foobar",
|
|
89
|
+
search: "?view=all",
|
|
90
|
+
hash: "#top",
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
await act(async () => {
|
|
94
|
+
root.render(<WorkspaceAppRoute />);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
expect(routeState.hostProps?.initialPath).toBe("/foobar?view=all#top");
|
|
98
|
+
|
|
99
|
+
routeState.routeSplat = "sent";
|
|
100
|
+
routeState.location = {
|
|
101
|
+
pathname: "/apps/mail/sent",
|
|
102
|
+
search: "",
|
|
103
|
+
hash: "",
|
|
104
|
+
};
|
|
105
|
+
await act(async () => {
|
|
106
|
+
root.render(<WorkspaceAppRoute />);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
expect(routeState.hostProps?.initialPath).toBe("/sent");
|
|
110
|
+
routeState.hostProps?.onChildRouteChange?.("/apps/mail/archive");
|
|
111
|
+
expect(routeState.navigate).toHaveBeenCalledWith("/apps/mail/archive", {
|
|
112
|
+
replace: true,
|
|
113
|
+
});
|
|
114
|
+
});
|
|
66
115
|
});
|
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useCallback } from "react";
|
|
2
|
+
import { useLocation, useNavigate, useParams } from "react-router";
|
|
2
3
|
|
|
3
4
|
import { WorkspaceAppHost } from "../../components/workspace-app-host";
|
|
4
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
navigateToWorkspaceApp,
|
|
7
|
+
workspaceAppInitialPathFromSplat,
|
|
8
|
+
} from "../../lib/workspace-apps";
|
|
5
9
|
|
|
6
10
|
export function navigateWorkspaceAppFromRoute(href: string): boolean {
|
|
7
11
|
return navigateToWorkspaceApp(href);
|
|
@@ -12,11 +16,30 @@ export function meta() {
|
|
|
12
16
|
}
|
|
13
17
|
|
|
14
18
|
export default function WorkspaceAppRoute() {
|
|
15
|
-
const
|
|
19
|
+
const params = useParams();
|
|
20
|
+
const { appId } = params;
|
|
21
|
+
const location = useLocation();
|
|
22
|
+
const navigate = useNavigate();
|
|
23
|
+
const initialPath = workspaceAppInitialPathFromSplat(
|
|
24
|
+
params["*"],
|
|
25
|
+
location.search,
|
|
26
|
+
location.hash,
|
|
27
|
+
);
|
|
28
|
+
const onChildRouteChange = useCallback(
|
|
29
|
+
(path: string) => {
|
|
30
|
+
const currentPath = `${location.pathname}${location.search}${location.hash}`;
|
|
31
|
+
if (path === currentPath) return;
|
|
32
|
+
void navigate(path, { replace: true });
|
|
33
|
+
},
|
|
34
|
+
[location.hash, location.pathname, location.search, navigate],
|
|
35
|
+
);
|
|
36
|
+
|
|
16
37
|
return (
|
|
17
38
|
<WorkspaceAppHost
|
|
18
39
|
appId={appId}
|
|
19
40
|
navigateToTopWindow={navigateWorkspaceAppFromRoute}
|
|
41
|
+
initialPath={initialPath}
|
|
42
|
+
onChildRouteChange={onChildRouteChange}
|
|
20
43
|
/>
|
|
21
44
|
);
|
|
22
45
|
}
|
|
@@ -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) ? (
|